BareGit
// HTTP integration tests. Spins a `Server` on 127.0.0.1 against an
// AuthMock and drives requests with httplib::Client.
#include <cerrno>
#include <chrono>
#include <cstdint>
#include <cstring>
#include <memory>
#include <string>
#include <thread>
#include <vector>

#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>

#include <gtest/gtest.h>
#include <httplib.h>

#include <mw/auth.hpp>
#include <mw/auth_mock.hpp>
#include <mw/database.hpp>

#include "db/migrations.hpp"
#include "overseer/config.hpp"
#include "overseer/modules/inventory/routes.hpp"
#include "overseer/server.hpp"
#include "overseer/session.hpp"
#include "support/seed.hpp"

namespace
{

// Find an OS-assigned free TCP port, then close the probe socket. The
// returned port is racy but works fine for single-process test runs.
int findFreePort()
{
    int s = ::socket(AF_INET, SOCK_STREAM, 0);
    if(s < 0) return 0;
    sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = ::htonl(INADDR_LOOPBACK);
    addr.sin_port = 0;
    if(::bind(s, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0)
    {
        ::close(s);
        return 0;
    }
    socklen_t len = sizeof(addr);
    if(::getsockname(s, reinterpret_cast<sockaddr*>(&addr), &len) != 0)
    {
        ::close(s);
        return 0;
    }
    int port = ::ntohs(addr.sin_port);
    ::close(s);
    return port;
}

struct TestServer
{
    std::unique_ptr<mw::SQLite> db;
    std::unique_ptr<overseer::Server> server;
    std::vector<std::unique_ptr<overseer::Module>> modules;
    int port = 0;

    // Drives the call once we know the port the listener bound to.
    httplib::Client client() { return httplib::Client("127.0.0.1", port); }
};

std::unique_ptr<TestServer> startServer()
{
    auto ts = std::make_unique<TestServer>();

    auto db_r = mw::SQLite::connectMemory();
    EXPECT_TRUE(db_r.has_value());
    ts->db = std::move(*db_r);
    EXPECT_TRUE(overseer::db::migrateIfNeeded(*ts->db, "").has_value());

    ts->port = findFreePort();
    EXPECT_NE(ts->port, 0);

    overseer::Config cfg;
    cfg.bind_address = "127.0.0.1";
    cfg.port = ts->port;
    cfg.oidc.redirect_uri = "http://127.0.0.1/oidc/callback";
    // Empty issuer_url makes AuthHandler skip OIDC discovery.
    cfg.oidc.issuer_url = "";

    auto auth = std::make_unique<::testing::NiceMock<mw::AuthMock>>();

    ts->server = std::make_unique<overseer::Server>(
        cfg, *ts->db,
        OVERSEER_TEST_STATIC_DIR,
        OVERSEER_TEST_TEMPLATE_DIR,
        /*dev_mode=*/true,
        std::move(auth));

    ts->modules.emplace_back(
        overseer::modules::inventory_module::create());
    ts->server->setModules(std::move(ts->modules));

    EXPECT_TRUE(ts->server->start().has_value());
    // Give the listener a brief moment to accept connections.
    std::this_thread::sleep_for(std::chrono::milliseconds(50));
    return ts;
}

void stopServer(TestServer& ts)
{
    ts.server->stop();
    ts.server->wait();
}

// Authenticate a fake session and return (cookie, csrf_token).
struct FakeSession
{
    std::string sid;
    std::string csrf;
};

FakeSession installFakeSession(TestServer& ts,
                               const std::string& uid = "tester",
                               const std::string& name = "Tester")
{
    mw::UserInfo u; u.id = uid; u.name = name;
    mw::Tokens t;
    auto sid = ts.server->auth().sessions().create(std::move(u),
                                                   std::move(t));
    auto sess = ts.server->auth().sessions().get(sid);
    return {sid, sess->csrf_token};
}

} // namespace

TEST(HttpServer, HealthzReturns204)
{
    auto ts = startServer();
    auto cli = ts->client();
    auto resp = cli.Get("/healthz");
    ASSERT_TRUE(resp);
    EXPECT_EQ(resp->status, 204);
    stopServer(*ts);
}

TEST(HttpServer, RootRedirectsToInventory)
{
    auto ts = startServer();
    auto cli = ts->client();
    auto resp = cli.Get("/");
    ASSERT_TRUE(resp);
    EXPECT_EQ(resp->status, 302);
    EXPECT_EQ(resp->get_header_value("Location"), "/inventory");
    stopServer(*ts);
}

TEST(HttpServer, TrailingSlashRedirects)
{
    auto ts = startServer();
    auto cli = ts->client();
    auto resp = cli.Get("/inventory/");
    ASSERT_TRUE(resp);
    EXPECT_EQ(resp->status, 301);
    EXPECT_EQ(resp->get_header_value("Location"), "/inventory");
    stopServer(*ts);
}

TEST(HttpServer, UnauthedInventoryRedirectsToLogin)
{
    auto ts = startServer();
    auto cli = ts->client();
    auto resp = cli.Get("/inventory");
    ASSERT_TRUE(resp);
    EXPECT_EQ(resp->status, 302);
    const auto loc = resp->get_header_value("Location");
    EXPECT_NE(loc.find("/oidc/login"), std::string::npos);
    stopServer(*ts);
}

TEST(HttpServer, AuthedInventoryRendersListPage)
{
    auto ts = startServer();
    overseer::inventory::InventoryRepo repo(*ts->db);
    overseer::test::seedFamily(repo);
    auto sess = installFakeSession(*ts);

    auto cli = ts->client();
    httplib::Headers h{{"Cookie", "overseer_sid=" + sess.sid}};
    auto resp = cli.Get("/inventory", h);
    ASSERT_TRUE(resp);
    EXPECT_EQ(resp->status, 200);
    // The page lists the root storages; "Apartment" should appear.
    EXPECT_NE(resp->body.find("Apartment"), std::string::npos);
    stopServer(*ts);
}

TEST(HttpServer, CsrfRejectedPost)
{
    auto ts = startServer();
    overseer::inventory::InventoryRepo repo(*ts->db);
    overseer::test::seedFamily(repo);
    auto sess = installFakeSession(*ts);

    auto cli = ts->client();
    httplib::Headers h{
        {"Cookie", "overseer_sid=" + sess.sid},
        {"Origin", "http://127.0.0.1"},
    };
    // Wrong csrf_token in the form body.
    auto resp = cli.Post("/inventory/storage", h,
                         "name=NewStorage&csrf_token=wrong",
                         "application/x-www-form-urlencoded");
    ASSERT_TRUE(resp);
    EXPECT_EQ(resp->status, 403);
    stopServer(*ts);
}

TEST(HttpServer, StuffNameInlineUpdateReturnsFragment)
{
    auto ts = startServer();
    overseer::inventory::InventoryRepo repo(*ts->db);
    auto seeded = overseer::test::seedFamily(repo);
    auto sess = installFakeSession(*ts);

    auto cli = ts->client();
    httplib::Headers h{
        {"Cookie", "overseer_sid=" + sess.sid},
        {"Origin", "http://127.0.0.1"},
        {"HX-Request", "true"},
    };
    const std::string body =
        "name=Renamed&csrf_token=" + sess.csrf;
    auto resp = cli.Put(
        "/inventory/stuff/" + std::to_string(seeded.stuff_ids.front())
            + "/name",
        h, body, "application/x-www-form-urlencoded");
    ASSERT_TRUE(resp);
    EXPECT_EQ(resp->status, 200);
    // The fragment is an editable h1 containing the new name.
    EXPECT_NE(resp->body.find("Renamed"), std::string::npos);

    auto updated = repo.getStuff(seeded.stuff_ids.front());
    ASSERT_TRUE(updated.has_value());
    EXPECT_EQ(updated->name, "Renamed");
    stopServer(*ts);
}