From e9686b6ab684785d5f9acbc98942beae94817562 Mon Sep 17 00:00:00 2001 From: MetroWind Date: Sun, 21 Sep 2025 21:34:34 -0700 Subject: Implement dir handler. Unit test WIP. --- src/app.cpp | 150 +++++++++++++++++++++++++++++++++----------- src/app.hpp | 11 ++-- src/app_test.cpp | 77 +++++++++++------------ src/data.cpp | 182 +++++++++++++++++++++++++++++++++++++++++++++++++----- src/data.hpp | 27 ++++++-- src/data_mock.hpp | 14 +++++ src/data_test.cpp | 46 +++++++++++++- 7 files changed, 407 insertions(+), 100 deletions(-) (limited to 'src') diff --git a/src/app.cpp b/src/app.cpp index 7cfcf0d..472c266 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -75,7 +75,7 @@ void setTokenCookies(const mw::Tokens& tokens, App::Response& res) expire_sec = expire.count(); } res.set_header("Set-Cookie", std::format( - "shrt-access-token={}; Max-Age={}", + "webdir-access-token={}; Max-Age={}", mw::urlEncode(tokens.access_token), expire_sec)); // Add refresh token to cookie, with one month expiration. if(tokens.refresh_token.has_value()) @@ -89,7 +89,7 @@ void setTokenCookies(const mw::Tokens& tokens, App::Response& res) } res.set_header("Set-Cookie", std::format( - "shrt-refresh-token={}; Max-Age={}", + "webdir-refresh-token={}; Max-Age={}", mw::urlEncode(*tokens.refresh_token), expire_sec)); } } @@ -111,6 +111,21 @@ mw::HTTPServer::ListenAddress listenAddrFromConfig(const Configuration& config) return sock; } +nlohmann::json jsonFromItem(const LinkItem& item) +{ + return { + {"id", item.id}, + {"owner_id", item.owner_id}, + {"parent_id", item.parent_id}, + {"name", item.name}, + {"url", item.url}, + {"description", item.description}, + {"visibility", LinkItem::visibilityToStr(item.visibility)}, + {"time", mw::timeToSeconds(item.time)}, + {"time_str", mw::timeToStr(item.time)}, + }; +} + } // namespace App::App(const Configuration& conf, @@ -151,18 +166,6 @@ std::string App::urlFor(const std::string& name, const std::string& arg) const { return mw::URL(base_url).appendPath("_/statics").appendPath(arg).str(); } - if(name == "index") - { - return base_url.str(); - } - if(name == "shortcut") - { - return mw::URL(base_url).appendPath(arg).str(); - } - if(name == "links") - { - return mw::URL(base_url).appendPath("_/links").str(); - } if(name == "login") { return mw::URL(base_url).appendPath("_/login").str(); @@ -171,22 +174,13 @@ std::string App::urlFor(const std::string& name, const std::string& arg) const { return mw::URL(base_url).appendPath("_/openid-redirect").str(); } - if(name == "new-link") - { - return mw::URL(base_url).appendPath("_/new-link").str(); - } - if(name == "create-link") - { - return mw::URL(base_url).appendPath("_/create-link").str(); - } - if(name == "delete-link-dialog") + if(name == "index") { - return mw::URL(base_url).appendPath("_/delete-link").appendPath(arg) - .str(); + return base_url.str(); } - if(name == "delete-link") + if(name == "dir") // /dir/ { - return mw::URL(base_url).appendPath("_/delete-link").str(); + return mw::URL(base_url).appendPath("dir").appendPath(arg).str(); } return ""; @@ -194,7 +188,86 @@ std::string App::urlFor(const std::string& name, const std::string& arg) const void App::handleIndex(Response& res) const { - res.set_redirect(urlFor("links"), 301); + res.set_redirect(urlFor("dir", "mw"), 301); +} + +void App::handleDir(const Request& req, Response& res) +{ + auto session = prepareSession(req, res, true); + if(!req.has_param("owner_or_id")) + { + res.status = 400; + res.set_content("Need parameter", "text/plain"); + return; + } + std::string owner_or_id = req.get_param_value("owner_or_id"); + std::string owner; + // If owner_or_id is an integer, it’s the ID of an item. Otherwise + // it’s a username. + auto item_id_maybe = mw::strToNumber(owner_or_id); + std::vector items; + if(item_id_maybe.has_value()) + { + ASSIGN_OR_RESPOND_ERROR(std::optional item, + data->itemByID(*item_id_maybe), res); + if(!item.has_value()) + { + res.status = 400; + res.set_content("Item not found", "text/plain"); + return; + } + ASSIGN_OR_RESPOND_ERROR(std::optional user, + data->userByID(item->owner_id), res); + if(!user.has_value()) + { + res.status = 500; + res.set_content("Owner of item not found", "text/plain"); + return; + } + owner = user->name; + ASSIGN_OR_RESPOND_ERROR(items, data->itemsByParent(*item_id_maybe), + res); + } + else + { + ASSIGN_OR_RESPOND_ERROR(std::optional user, + data->userByName(owner_or_id), res); + if(!user.has_value()) + { + res.status = 404; + res.set_content(std::string("Unknown user: ") + owner_or_id, + "text/plain"); + return; + } + owner = owner_or_id; + ASSIGN_OR_RESPOND_ERROR(items, data->itemsTopLevelByUser(user->id), + res); + } + + nlohmann::json items_data = nlohmann::json::array(); + for(const LinkItem& item: items) + { + if(item.visibility == LinkItem::PRIVATE) + { + if(session->status == SessionValidation::INVALID) + { + continue; + } + if(session->user.name != owner) + { + continue; + } + } + items_data.push_back(jsonFromItem(item)); + } + nlohmann::json data = { + {"session_user", session->user.name}, + {"owner", owner}, + {"this_url", req.target}, + {"items", std::move(items_data)}, + }; + std::string result = templates.render_file("dir.html", std::move(data)); + res.set_content(result, "text/html"); } void App::handleLogin(Response& res) const @@ -253,11 +326,8 @@ void App::setup() } } - server.Get(getPath("index"), [&]([[maybe_unused]] const Request& req, Response& res) - { - handleIndex(res); - }); - server.Get(getPath("login"), [&]([[maybe_unused]] const Request& req, Response& res) + server.Get(getPath("login"), [&]([[maybe_unused]] const Request& req, + Response& res) { handleLogin(res); }); @@ -265,6 +335,16 @@ void App::setup() { handleOpenIDRedirect(req, res); }); + server.Get(getPath("index"), [&]([[maybe_unused]] const Request& req, + Response& res) + { + handleIndex(res); + }); + server.Get(getPath("dir", "owner_or_id"), + [&]([[maybe_unused]] const Request& req, Response& res) + { + handleDir(req, res); + }); } mw::E App::validateSession(const Request& req) const @@ -276,7 +356,7 @@ mw::E App::validateSession(const Request& req) const } auto cookies = parseCookies(req.get_header_value("Cookie")); - if(auto it = cookies.find("shrt-access-token"); + if(auto it = cookies.find("webdir-access-token"); it != std::end(cookies)) { spdlog::debug("Cookie has access token."); @@ -289,7 +369,7 @@ mw::E App::validateSession(const Request& req) const } } // No access token or access token expired - if(auto it = cookies.find("shrt-refresh-token"); + if(auto it = cookies.find("webdir-refresh-token"); it != std::end(cookies)) { spdlog::debug("Cookie has refresh token."); diff --git a/src/app.hpp b/src/app.hpp index 09c7143..02a1206 100644 --- a/src/app.hpp +++ b/src/app.hpp @@ -27,9 +27,10 @@ public: std::string urlFor(const std::string& name, const std::string& arg="") const; - void handleIndex(Response& res) const; void handleLogin(Response& res) const; void handleOpenIDRedirect(const Request& req, Response& res) const; + void handleIndex(Response& res) const; + void handleDir(const Request& req, Response& res); private: void setup() override; @@ -63,10 +64,10 @@ private: // this case if this function does return a value, it would never // be an invalid session. // - // If “allow_error_and_invalid” is true, failure to query and - // invalid session are considered ok, and no status and body would - // be set in “res”. In this case this function just returns an - // invalid session. + // If “allow_error_and_invalid” is true, this function will never + // return nullopt. Failure to query and invalid session are + // considered ok, and no status and body would be set in “res”. In + // this case this function just returns an invalid session. std::optional prepareSession( const Request& req, Response& res, bool allow_error_and_invalid=false) const; diff --git a/src/app_test.cpp b/src/app_test.cpp index 06f2084..d1f41a5 100644 --- a/src/app_test.cpp +++ b/src/app_test.cpp @@ -19,46 +19,47 @@ using ::testing::HasSubstr; using ::testing::FieldsAre; using ::testing::ContainsRegex; -// class UserAppTest : public testing::Test -// { -// protected: -// UserAppTest() -// { -// config.base_url = "http://localhost:8080/"; -// config.listen_address = "localhost"; -// config.listen_port = 8080; -// config.data_dir = "."; +class UserAppTest : public testing::Test +{ +protected: + UserAppTest() + { + config.base_url = "http://localhost:8080/"; + config.listen_address = "localhost"; + config.listen_port = 8080; + config.data_dir = "."; -// auto auth = std::make_unique(); + auto auth = std::make_unique(); -// mw::UserInfo expected_user; -// expected_user.name = "mw"; -// expected_user.id = "mw"; -// mw::Tokens token; -// token.access_token = "aaa"; -// EXPECT_CALL(*auth, getUser(std::move(token))) -// .Times(::testing::AtLeast(0)) -// .WillRepeatedly(Return(expected_user)); -// auto data = std::make_unique(); -// data_source = data.get(); + mw::UserInfo expected_user; + expected_user.name = "mw"; + expected_user.id = "mw"; + mw::Tokens token; + token.access_token = "aaa"; + EXPECT_CALL(*auth, getUser(std::move(token))) + .Times(::testing::AtLeast(0)) + .WillRepeatedly(Return(expected_user)); + auto data = std::make_unique(); + data_source = data.get(); -// app = std::make_unique(config, std::move(data), std::move(auth)); -// } + app = std::make_unique(config, std::move(data), std::move(auth)); + } -// Configuration config; -// std::unique_ptr app; -// const DataSourceMock* data_source; -// }; + Configuration config; + std::unique_ptr app; + const DataSourceMock* data_source; +}; -// TEST_F(UserAppTest, CanDenyAccessToLinkList) -// { -// EXPECT_TRUE(mw::isExpected(app->start())); -// { -// mw::HTTPSession client; -// ASSIGN_OR_FAIL(const mw::HTTPResponse* res, client.get( -// mw::HTTPRequest("http://localhost:8080/_/links"))); -// EXPECT_EQ(res->status, 401); -// } -// app->stop(); -// app->wait(); -// } +TEST_F(UserAppTest, CanShowItemsOfDefaultUser) +{ + EXPECT_TRUE(mw::isExpected(app->start())); + { + mw::HTTPSession client; + ASSIGN_OR_FAIL(const mw::HTTPResponse* res, client.get( + mw::HTTPRequest("http://localhost:8080/"))); + EXPECT_EQ(res->status, 301); + EXPECT_EQ(res->header.at("Location"), "http://localhost:8080/mw"); + } + app->stop(); + app->wait(); +} diff --git a/src/data.cpp b/src/data.cpp index d6c4c5f..4960afc 100644 --- a/src/data.cpp +++ b/src/data.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include "data.hpp" @@ -39,6 +40,18 @@ LinkItem linkFromTuple(const LinkTuple& t) } // namespace +std::string LinkItem::visibilityToStr(Visibility v) +{ + switch(v) + { + case PUBLIC: + return "public"; + case PRIVATE: + return "private"; + } + std::unreachable(); +} + mw::E> DataSourceSQLite::fromFile(const std::string& db_file) { @@ -53,7 +66,7 @@ DataSourceSQLite::fromFile(const std::string& db_file) DO_OR_RETURN(data_source->setSchemaVersion(1)); DO_OR_RETURN(data_source->db->execute( "CREATE TABLE IF NOT EXISTS Users " - "(id INTEGER PRIMARY KEY, openid_uid TEXT, name TEXT);")); + "(id INTEGER PRIMARY KEY, openid_uid TEXT UNIQUE, name TEXT UNIQUE);")); DO_OR_RETURN(data_source->db->execute( "CREATE TABLE IF NOT EXISTS LinkItems " "(id INTEGER PRIMARY KEY, owner_id INTEGER NOT NULL," @@ -80,25 +93,138 @@ mw::E DataSourceSQLite::setSchemaVersion(int64_t v) const return db->execute(std::format("PRAGMA user_version = {};", v)); } -mw::E> -DataSourceSQLite::items(std::optional parent) +mw::E> +DataSourceSQLite::userByOpenIDUID(const std::string& uid) const +{ + if(db == nullptr) + { + return std::unexpected(mw::runtimeError("Database is not connected.")); + } + ASSIGN_OR_RETURN(mw::SQLiteStatement sql, db->statementFromStr( + "SELECT id, openid_uid, name FROM Users WHERE openid_uid = ?;")); + DO_OR_RETURN(sql.bind(uid)); + ASSIGN_OR_RETURN(auto users, (db->eval( + std::move(sql)))); + if(users.empty()) + { + return std::nullopt; + } + if(users.size() > 1) + { + return std::unexpected(mw::runtimeError("Found duplicated users")); + } + User u; + u.id = std::get<0>(users[0]); + u.openid_uid = std::get<1>(users[0]); + u.name = std::get<2>(users[0]); + return u; +} + +mw::E DataSourceSQLite::addUser(User&& u) const { + if(db == nullptr) + { + return std::unexpected(mw::runtimeError("Database is not connected.")); + } + ASSIGN_OR_RETURN(mw::SQLiteStatement sql, db->statementFromStr( + "INSERT INTO Users (id, openid_uid, name) VALUES (NULL, ?, ?)")); + DO_OR_RETURN(sql.bind(u.openid_uid, u.name)); + DO_OR_RETURN(db->execute(std::move(sql))); + return db->lastInsertRowID(); +} + +mw::E> DataSourceSQLite::userByID(const int64_t id) const +{ + if(db == nullptr) + { + return std::unexpected(mw::runtimeError("Database is not connected.")); + } + ASSIGN_OR_RETURN(mw::SQLiteStatement sql, db->statementFromStr( + "SELECT id, openid_uid, name FROM Users WHERE id = ?;")); + DO_OR_RETURN(sql.bind(id)); + ASSIGN_OR_RETURN(auto users, (db->eval( + std::move(sql)))); + if(users.empty()) + { + return std::nullopt; + } + if(users.size() > 1) + { + return std::unexpected(mw::runtimeError("Found duplicated users")); + } + User u; + u.id = std::get<0>(users[0]); + u.openid_uid = std::get<1>(users[0]); + u.name = std::get<2>(users[0]); + return u; +} + +mw::E> DataSourceSQLite::userByName(const std::string& name) + const +{ + if(db == nullptr) + { + return std::unexpected(mw::runtimeError("Database is not connected.")); + } + ASSIGN_OR_RETURN(mw::SQLiteStatement sql, db->statementFromStr( + "SELECT id, openid_uid, name FROM Users WHERE name = ?;")); + DO_OR_RETURN(sql.bind(name)); + ASSIGN_OR_RETURN(auto users, (db->eval( + std::move(sql)))); + if(users.empty()) + { + return std::nullopt; + } + if(users.size() > 1) + { + return std::unexpected(mw::runtimeError("Found duplicated users")); + } + User u; + u.id = std::get<0>(users[0]); + u.openid_uid = std::get<1>(users[0]); + u.name = std::get<2>(users[0]); + return u; +} + +mw::E> DataSourceSQLite::itemByID(int64_t id) const +{ + if(db == nullptr) + { + return std::unexpected(mw::runtimeError("Database is not connected.")); + } std::vector links; - if(!parent.has_value()) + // Querying root-level links. + ASSIGN_OR_RETURN(mw::SQLiteStatement stat, db->statementFromStr( + "SELECT id, owner_id, parent_id, name, url, description, visibility," + " time from LinkItems WHERE id = ?;")); + DO_OR_RETURN(stat.bind(id)); + ASSIGN_OR_RETURN(links, (db->eval(std::move(stat)))); + if(links.empty()) { - // Querying root-level links. - ASSIGN_OR_RETURN(links, (db->eval( - "SELECT id, owner_id, parent_id, name, url, description, visibility," - " time from LinkItems WHERE parent_id IS NULL;"))); + return std::nullopt; } - else + if(links.size() > 1) + { + return std::unexpected(mw::runtimeError("Duplicate item ID")); + } + + return linkFromTuple(links[0]); +} + +mw::E> DataSourceSQLite::itemsByParent(int64_t parent) + const +{ + if(db == nullptr) { - ASSIGN_OR_RETURN(mw::SQLiteStatement stat, db->statementFromStr( - "SELECT id, owner_id, parent_id, name, url, description, visibility," - " time from LinkItems WHERE parent_id = ?;")); - DO_OR_RETURN(stat.bind(*parent)); - ASSIGN_OR_RETURN(links, (db->eval(std::move(stat)))); + return std::unexpected(mw::runtimeError("Database is not connected.")); } + std::vector links; + // Querying root-level links. + ASSIGN_OR_RETURN(mw::SQLiteStatement stat, db->statementFromStr( + "SELECT id, owner_id, parent_id, name, url, description, visibility," + " time from LinkItems WHERE parent_id = ?;")); + DO_OR_RETURN(stat.bind(parent)); + ASSIGN_OR_RETURN(links, (db->eval(std::move(stat)))); std::vector result; for(const LinkTuple& t : links) { @@ -107,8 +233,34 @@ DataSourceSQLite::items(std::optional parent) return result; } -mw::E DataSourceSQLite::addLink(LinkItem&& link) +mw::E> +DataSourceSQLite::itemsTopLevelByUser(int64_t user_id) const { + if(db == nullptr) + { + return std::unexpected(mw::runtimeError("Database is not connected.")); + } + std::vector links; + // Querying root-level links. + ASSIGN_OR_RETURN(mw::SQLiteStatement stat, db->statementFromStr( + "SELECT id, owner_id, parent_id, name, url, description, visibility," + " time from LinkItems WHERE parent_id IS NULL AND owner_id = ?;")); + DO_OR_RETURN(stat.bind(user_id)); + ASSIGN_OR_RETURN(links, (db->eval(std::move(stat)))); + std::vector result; + for(const LinkTuple& t : links) + { + result.push_back(linkFromTuple(t)); + } + return result; +} + +mw::E DataSourceSQLite::addLink(LinkItem&& link) const +{ + if(db == nullptr) + { + return std::unexpected(mw::runtimeError("Database is not connected.")); + } ASSIGN_OR_RETURN(mw::SQLiteStatement sql, db->statementFromStr( "INSERT INTO LinkItems (id, owner_id, parent_id, name, url," " description, visibility, time) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")); diff --git a/src/data.hpp b/src/data.hpp index 006f1f2..45e867a 100644 --- a/src/data.hpp +++ b/src/data.hpp @@ -12,6 +12,7 @@ struct LinkItem { enum Visibility {PUBLIC, PRIVATE}; + static std::string visibilityToStr(Visibility v); int64_t id; int64_t owner_id; @@ -43,8 +44,18 @@ public: // help with database migration. virtual mw::E getSchemaVersion() const = 0; - virtual mw::E> items(std::optional parent) = 0; - virtual mw::E addLink(LinkItem&& link) = 0; + virtual mw::E> + userByOpenIDUID(const std::string& uid) const = 0; + virtual mw::E addUser(User&& u) const = 0; + virtual mw::E> userByID(const int64_t) const = 0; + virtual mw::E> userByName(const std::string& name) const = 0; + virtual mw::E> itemByID(int64_t id) const = 0; + // Get all children of “parent”. + virtual mw::E> itemsByParent(int64_t parent) const = 0; + // Get all top-level items owned by “username”. + virtual mw::E> + itemsTopLevelByUser(int64_t user_id) const = 0; + virtual mw::E addLink(LinkItem&& link) const = 0; protected: virtual mw::E setSchemaVersion(int64_t v) const = 0; @@ -63,8 +74,16 @@ public: mw::E getSchemaVersion() const override; - mw::E> items(std::optional parent) override; - mw::E addLink(LinkItem&& link) override; + mw::E> + userByOpenIDUID(const std::string& uid) const override; + mw::E addUser(User&& u) const override; + mw::E> userByID(const int64_t) const override; + mw::E> userByName(const std::string& name) const override; + mw::E> itemByID(int64_t id) const override; + mw::E> itemsByParent(int64_t parent) const override; + mw::E> itemsTopLevelByUser(int64_t user_id) const override; + + mw::E addLink(LinkItem&& link) const override; // Do not use. DataSourceSQLite() = default; diff --git a/src/data_mock.hpp b/src/data_mock.hpp index 363eb9e..2740165 100644 --- a/src/data_mock.hpp +++ b/src/data_mock.hpp @@ -15,6 +15,20 @@ public: ~DataSourceMock() override = default; MOCK_METHOD(mw::E, getSchemaVersion, (), (const override)); + MOCK_METHOD(mw::E>, userByOpenIDUID, + (const std::string& uid), (const override)); + MOCK_METHOD(mw::E, addUser, (User&& u), (const override)); + MOCK_METHOD(mw::E>, userByID, (const int64_t), + (const override)); + MOCK_METHOD(mw::E>, userByName, + (const std::string& name), (const override)); + MOCK_METHOD(mw::E>, itemByID, (int64_t id), + (const override)); + MOCK_METHOD(mw::E>, itemsByParent, (int64_t parent), + (const override)); + MOCK_METHOD(mw::E>, itemsTopLevelByUser, + (int64_t user_id), (const override)); + MOCK_METHOD(mw::E, addLink, (LinkItem&& link), (const override)); protected: mw::E setSchemaVersion([[maybe_unused]] int64_t v) const override diff --git a/src/data_test.cpp b/src/data_test.cpp index 633a102..309d44f 100644 --- a/src/data_test.cpp +++ b/src/data_test.cpp @@ -10,10 +10,48 @@ using ::testing::IsEmpty; +TEST(DataSource, CanGetUser) +{ + ASSIGN_OR_FAIL(std::unique_ptr data, + DataSourceSQLite::newFromMemory()); + User u; + u.openid_uid = "aaa"; + u.name = "bbb"; + EXPECT_TRUE(data->addUser(std::move(u)).has_value()); + ASSIGN_OR_FAIL(auto user_maybe, data->userByOpenIDUID("aaa")); + EXPECT_TRUE(user_maybe.has_value()); + EXPECT_EQ(user_maybe->name, "bbb"); + + ASSIGN_OR_FAIL(user_maybe, data->userByOpenIDUID("bbb")); + EXPECT_FALSE(user_maybe.has_value()); +} + +TEST(DataSource, WontAddDuplicateUser) +{ + ASSIGN_OR_FAIL(std::unique_ptr data, + DataSourceSQLite::newFromMemory()); + User u0; + u0.openid_uid = "aaa"; + u0.name = "bbb"; + User u1; + u1.openid_uid = "aaa"; + u1.name = "ccc"; + User u2; + u2.openid_uid = "ddd"; + u2.name = "bbb"; + EXPECT_TRUE(data->addUser(std::move(u0)).has_value()); + EXPECT_FALSE(data->addUser(std::move(u1)).has_value()); + EXPECT_FALSE(data->addUser(std::move(u2)).has_value()); +} + TEST(DataSource, CanAddLink) { ASSIGN_OR_FAIL(std::unique_ptr data, DataSourceSQLite::newFromMemory()); + User u; + u.openid_uid = "aaa"; + u.name = "bbb"; + EXPECT_TRUE(data->addUser(std::move(u)).has_value()); LinkItem l0; l0.owner_id = 1; l0.parent_id = std::nullopt; @@ -34,8 +72,10 @@ TEST(DataSource, CanAddLink) EXPECT_EQ(l0id, 1); ASSIGN_OR_FAIL(int64_t l1id, data->addLink(std::move(l1))); EXPECT_EQ(l1id, 2); - ASSIGN_OR_FAIL(auto ls, data->items(1)); + ASSIGN_OR_FAIL(auto ls, data->itemsTopLevelByUser(1)); + ASSERT_EQ(ls.size(), 1); + EXPECT_EQ(ls[0].name, "aaa"); + ASSIGN_OR_FAIL(ls, data->itemsByParent(1)); ASSERT_EQ(ls.size(), 1); - EXPECT_EQ(ls[0].parent_id, 1); - EXPECT_EQ(ls[0].visibility, LinkItem::PRIVATE); + EXPECT_EQ(ls[0].name, "ddd"); } -- cgit v1.2.3-70-g09d2