BareGit
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <optional>
#include <string>
#include <vector>

#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <mw/crypto_mock.hpp>

#include "authentication.h"
#include "startup.h"

namespace
{

class TemporaryDatabase
{
public:
    TemporaryDatabase()
        : path_(
              std::filesystem::path(testing::TempDir()) /
              ("authentication_service_" + std::to_string(
                  std::chrono::steady_clock::now()
                      .time_since_epoch().count()) + ".sqlite3"))
    {}

    ~TemporaryDatabase()
    {
        std::error_code error;
        std::filesystem::remove(path_, error);
        std::filesystem::remove(path_.string() + "-shm", error);
        std::filesystem::remove(path_.string() + "-wal", error);
    }

    const std::filesystem::path& path() const
    {
        return path_;
    }

private:
    std::filesystem::path path_;
};

class ClockMock final : public ClockInterface
{
public:
    std::chrono::system_clock::time_point now() const override
    {
        return current;
    }

    std::chrono::system_clock::time_point current =
        std::chrono::system_clock::time_point(std::chrono::seconds(100));
};

class EmailSenderMock final : public EmailSenderInterface
{
public:
    mw::E<void> send(const AuthenticationEmail& email) override
    {
        last_email = email;
        if(fail)
        {
            return std::unexpected(mw::runtimeError("Sender failed"));
        }
        return {};
    }

    bool usesGlobalQuota() const override
    {
        return global_quota;
    }

    std::optional<AuthenticationEmail> last_email;
    bool fail = false;
    bool global_quota = true;
};

std::string finalPathSegment(const mw::URL& url)
{
    const std::string path = url.path();
    return path.substr(path.find_last_of('/') + 1);
}

std::vector<std::byte> bytes(unsigned char value)
{
    return std::vector<std::byte>(32, static_cast<std::byte>(value));
}

std::string hexToken(char low_nibble)
{
    std::string result;
    result.reserve(64);
    for(int index = 0; index < 32; ++index)
    {
        result.push_back('0');
        result.push_back(low_nibble);
    }
    return result;
}

} // namespace

TEST(AuthenticationServiceTest, CompletesSingleUseNonSlidingFlow)
{
    TemporaryDatabase database;
    auto data_source = prepareDataSource(database.path());
    ASSERT_TRUE(data_source);
    auto base_url = mw::URL::fromStr("http://127.0.0.1/app/");
    ASSERT_TRUE(base_url);
    ClockMock clock;
    EmailSenderMock sender;
    mw::CryptoMock crypto;
    EXPECT_CALL(crypto, randomBytes(32))
        .WillOnce(testing::Return(bytes(1)))
        .WillOnce(testing::Return(bytes(2)))
        .WillOnce(testing::Return(bytes(3)));
    AuthenticationService authentication(
        **data_source, sender, clock, crypto, std::move(*base_url), 10);

    auto requested = authentication.requestEmail("Player@Example.com");
    ASSERT_TRUE(requested) << requested.error().msg();
    ASSERT_TRUE(sender.last_email);
    EXPECT_EQ(sender.last_email->recipient, "Player@Example.com");
    const std::string challenge_token = finalPathSegment(
        sender.last_email->confirmation_url);
    ASSERT_EQ(challenge_token, hexToken('1'));
    auto opened = authentication.validate(challenge_token);
    ASSERT_TRUE(opened);
    ASSERT_TRUE(*opened);
    auto opened_again = authentication.validate(challenge_token);
    ASSERT_TRUE(opened_again);
    ASSERT_TRUE(*opened_again);

    auto established = authentication.confirm(challenge_token);
    ASSERT_TRUE(established) << established.error().msg();
    EXPECT_EQ(established->user.email, "Player@Example.com");
    EXPECT_EQ(established->user.role, UserRole::PLAYER);
    EXPECT_FALSE(established->user.username);
    EXPECT_EQ(established->token, hexToken('2'));
    EXPECT_EQ(
        established->csrf_token,
        hexToken('3'));
    opened = authentication.validate(challenge_token);
    ASSERT_TRUE(opened);
    EXPECT_FALSE(*opened);

    clock.current = std::chrono::system_clock::time_point(
        std::chrono::seconds(200));
    auto session = authentication.session(established->token);
    ASSERT_TRUE(session);
    ASSERT_TRUE(*session);
    EXPECT_EQ((**session).expires_at, established->expires_at);
    ASSERT_TRUE(authentication.logout(established->token));
    session = authentication.session(established->token);
    ASSERT_TRUE(session);
    EXPECT_FALSE(*session);
}

TEST(AuthenticationServiceTest, RateLimitsAndInvalidatesSenderFailure)
{
    TemporaryDatabase database;
    auto data_source = prepareDataSource(database.path());
    ASSERT_TRUE(data_source);
    auto base_url = mw::URL::fromStr("http://127.0.0.1/");
    ASSERT_TRUE(base_url);
    ClockMock clock;
    EmailSenderMock sender;
    sender.global_quota = false;
    mw::CryptoMock crypto;
    EXPECT_CALL(crypto, randomBytes(32))
        .WillOnce(testing::Return(bytes(4)))
        .WillOnce(testing::Return(bytes(5)));
    AuthenticationService authentication(
        **data_source, sender, clock, crypto, std::move(*base_url), 10);

    ASSERT_TRUE(authentication.requestEmail("person@example.com"));
    EXPECT_FALSE(authentication.requestEmail("PERSON@example.com"));
    clock.current = std::chrono::system_clock::time_point(
        std::chrono::seconds(161));
    sender.fail = true;
    EXPECT_FALSE(authentication.requestEmail("other@example.com"));
    ASSERT_TRUE(sender.last_email);
    auto challenge = authentication.validate(
        finalPathSegment(sender.last_email->confirmation_url));
    ASSERT_TRUE(challenge);
    EXPECT_FALSE(*challenge);
}