// 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);
}