diff --git a/backend/CMakeLists.txt b/backend/CMakeLists.txt index 3bbac23..c9e5d12 100644 --- a/backend/CMakeLists.txt +++ b/backend/CMakeLists.txt @@ -25,9 +25,22 @@ target_link_libraries(csp_core PUBLIC OpenSSL::Crypto ) +add_library(csp_web + src/controllers/auth_controller.cc + src/health_controller.cc +) + +target_include_directories(csp_web PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +target_link_libraries(csp_web PRIVATE + Drogon::Drogon + csp_core +) + add_executable(csp_server src/main.cc - src/health_controller.cc ) target_include_directories(csp_server PRIVATE @@ -37,6 +50,7 @@ target_include_directories(csp_server PRIVATE target_link_libraries(csp_server PRIVATE Drogon::Drogon csp_core + csp_web ) enable_testing() @@ -45,11 +59,18 @@ add_executable(csp_tests tests/version_test.cc tests/sqlite_db_test.cc tests/auth_service_test.cc + tests/auth_http_test.cc +) + +target_include_directories(csp_tests PRIVATE + /usr/include/jsoncpp ) target_link_libraries(csp_tests PRIVATE Catch2::Catch2WithMain + Drogon::Drogon csp_core + csp_web ) include(CTest) diff --git a/backend/include/csp/controllers/auth_controller.h b/backend/include/csp/controllers/auth_controller.h new file mode 100644 index 0000000..3fdb402 --- /dev/null +++ b/backend/include/csp/controllers/auth_controller.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace csp::controllers { + +class AuthController : public drogon::HttpController { + public: + METHOD_LIST_BEGIN + ADD_METHOD_TO(AuthController::registerUser, "/api/v1/auth/register", drogon::Post); + ADD_METHOD_TO(AuthController::login, "/api/v1/auth/login", drogon::Post); + METHOD_LIST_END + + void registerUser(const drogon::HttpRequestPtr& req, + std::function&& cb); + + void login(const drogon::HttpRequestPtr& req, + std::function&& cb); +}; + +} // namespace csp::controllers diff --git a/backend/src/app_state.cc b/backend/src/app_state.cc index 4a19579..b305205 100644 --- a/backend/src/app_state.cc +++ b/backend/src/app_state.cc @@ -10,7 +10,11 @@ AppState& AppState::Instance() { } void AppState::Init(const std::string& sqlite_path) { - db_ = std::make_unique(db::SqliteDb::OpenFile(sqlite_path)); + if (sqlite_path == ":memory:") { + db_ = std::make_unique(db::SqliteDb::OpenMemory()); + } else { + db_ = std::make_unique(db::SqliteDb::OpenFile(sqlite_path)); + } csp::db::ApplyMigrations(*db_); } diff --git a/backend/src/controllers/auth_controller.cc b/backend/src/controllers/auth_controller.cc new file mode 100644 index 0000000..c5cfb6d --- /dev/null +++ b/backend/src/controllers/auth_controller.cc @@ -0,0 +1,75 @@ +#include "csp/controllers/auth_controller.h" + +#include "csp/app_state.h" +#include "csp/services/auth_service.h" + +#include + +namespace csp::controllers { + +namespace { + +drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code, + const std::string& msg) { + Json::Value j; + j["ok"] = false; + j["error"] = msg; + auto resp = drogon::HttpResponse::newHttpJsonResponse(j); + resp->setStatusCode(code); + return resp; +} + +drogon::HttpResponsePtr JsonOk(const services::AuthResult& r) { + Json::Value j; + j["ok"] = true; + j["user_id"] = r.user_id; + j["token"] = r.token; + j["expires_at"] = Json::Int64(r.expires_at); + auto resp = drogon::HttpResponse::newHttpJsonResponse(j); + resp->setStatusCode(drogon::k200OK); + return resp; +} + +std::pair ParseUsernamePassword( + const drogon::HttpRequestPtr& req) { + const auto json = req->getJsonObject(); + if (!json) throw std::runtime_error("body must be json"); + + const auto username = (*json).get("username", "").asString(); + const auto password = (*json).get("password", "").asString(); + + if (username.empty() || password.empty()) { + throw std::runtime_error("username/password required"); + } + return {username, password}; +} + +} // namespace + +void AuthController::registerUser( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + const auto [username, password] = ParseUsernamePassword(req); + services::AuthService auth(AppState::Instance().db()); + const auto r = auth.Register(username, password); + cb(JsonOk(r)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k400BadRequest, e.what())); + } +} + +void AuthController::login( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + const auto [username, password] = ParseUsernamePassword(req); + services::AuthService auth(AppState::Instance().db()); + const auto r = auth.Login(username, password); + cb(JsonOk(r)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k400BadRequest, e.what())); + } +} + +} // namespace csp::controllers diff --git a/backend/src/main.cc b/backend/src/main.cc index 693e1c1..fee8f46 100644 --- a/backend/src/main.cc +++ b/backend/src/main.cc @@ -6,7 +6,8 @@ int main(int argc, char** argv) { const std::string db_path = (argc >= 2) ? argv[1] : std::string("data/csp.db"); - std::filesystem::create_directories(std::filesystem::path(db_path).parent_path()); + const auto parent = std::filesystem::path(db_path).parent_path(); + if (!parent.empty()) std::filesystem::create_directories(parent); csp::AppState::Instance().Init(db_path); diff --git a/backend/tests/auth_http_test.cc b/backend/tests/auth_http_test.cc new file mode 100644 index 0000000..c0e9d77 --- /dev/null +++ b/backend/tests/auth_http_test.cc @@ -0,0 +1,53 @@ +#include + +#include "csp/app_state.h" +#include "csp/controllers/auth_controller.h" + +#include + +#include + +namespace { + +drogon::HttpResponsePtr Call( + void (csp::controllers::AuthController::*fn)( + const drogon::HttpRequestPtr&, + std::function&&), + const Json::Value& body) { + auto req = drogon::HttpRequest::newHttpJsonRequest(body); + req->setMethod(drogon::Post); + + std::promise p; + csp::controllers::AuthController ctl; + (ctl.*fn)(req, [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); }); + return p.get_future().get(); +} + +} // namespace + +TEST_CASE("auth controller: register + login") { + csp::AppState::Instance().Init(":memory:"); + + Json::Value reg; + reg["username"] = "bob"; + reg["password"] = "password123"; + + auto regResp = Call(&csp::controllers::AuthController::registerUser, reg); + REQUIRE(regResp != nullptr); + REQUIRE(regResp->statusCode() == drogon::k200OK); + auto j1 = regResp->jsonObject(); + REQUIRE(j1 != nullptr); + REQUIRE((*j1)["ok"].asBool() == true); + REQUIRE((*j1)["token"].asString().size() > 10); + + Json::Value login; + login["username"] = "bob"; + login["password"] = "password123"; + auto loginResp = Call(&csp::controllers::AuthController::login, login); + REQUIRE(loginResp != nullptr); + REQUIRE(loginResp->statusCode() == drogon::k200OK); + auto j2 = loginResp->jsonObject(); + REQUIRE(j2 != nullptr); + REQUIRE((*j2)["ok"].asBool() == true); + REQUIRE((*j2)["token"].asString().size() > 10); +} diff --git a/scripts/bootstrap_ubuntu.sh b/scripts/bootstrap_ubuntu.sh old mode 100755 new mode 100644