diff --git a/README.md b/README.md index 1af340b..e7b17d0 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ hardened auth / security stack. Header-only, oatpp 1.3+, C++17. | `util/RateLimiter.hpp` | In-memory token-bucket keyed on an arbitrary string (typically the client IP from `clientIpTrusted`). | | `util/TokenExtract.hpp` | `extractToken` (Cookie/Bearer), `isValidIp` (IPv4/IPv6 via `inet_pton`), `clientIpTrusted` (loopback-gated XFF). | | `startup/RequireEncryptionKey.hpp` | `requireEncryptionKey(envVarName, encryptionEnabled, allowPlaintext)` — refuse startup without a symmetric key unless a dev flag overrides. | +| `repo/Repository.hpp` + `IHistoryRepository.hpp` + `ITemporalEntity.hpp` + `TemporalAt.hpp` + `ActorContext.hpp` | Pure-abstract `Repository` interface set distilled from fewo-webapp's per-entity `*Db` clients. Mixed UUID allocation on `save`, separate `IHistoryRepository` for temporal versions, `ActorContext` placeholder for the upcoming scope-guard decorator. | ## Consume via CMake diff --git a/include/oatpp-authkit/repo/ActorContext.hpp b/include/oatpp-authkit/repo/ActorContext.hpp new file mode 100644 index 0000000..b4ecc6b --- /dev/null +++ b/include/oatpp-authkit/repo/ActorContext.hpp @@ -0,0 +1,27 @@ +#ifndef OATPP_AUTHKIT_REPO_ACTOR_CONTEXT_HPP +#define OATPP_AUTHKIT_REPO_ACTOR_CONTEXT_HPP + +#include +#include + +namespace oatpp_authkit::repo { + +/** + * @brief Who is performing a repository action, plus what they're scoped to. + * + * Passed to the scope-guard decorator predicate (added in oatpp-authkit#8) so + * resource-level authorisation can be evaluated outside the concrete repo. + * + * Kept deliberately minimal — consumers extend by composing this struct into + * a richer per-app context if needed. The fields here are the union of what + * the fewo-webapp property-scope guard needs (user id + a list of allowed + * resource ids) and nothing more. + */ +struct ActorContext { + std::string userId; + std::vector allowedScopes; ///< Opaque ids; consumer decides their meaning (property ids, tenant ids, …). +}; + +} // namespace oatpp_authkit::repo + +#endif diff --git a/include/oatpp-authkit/repo/IHistoryRepository.hpp b/include/oatpp-authkit/repo/IHistoryRepository.hpp new file mode 100644 index 0000000..0670352 --- /dev/null +++ b/include/oatpp-authkit/repo/IHistoryRepository.hpp @@ -0,0 +1,31 @@ +#ifndef OATPP_AUTHKIT_REPO_I_HISTORY_REPOSITORY_HPP +#define OATPP_AUTHKIT_REPO_I_HISTORY_REPOSITORY_HPP + +#include "oatpp/core/Types.hpp" + +namespace oatpp_authkit::repo { + +/** + * @brief All historical versions for a temporal entity. + * + * Kept separate from `Repository` deliberately — non-temporal repos + * (caches, lookup tables, anything without `valid_from` / `valid_until`) + * don't have a meaningful answer to `history()` and shouldn't be forced + * to implement a stub. The temporal decorator in oatpp-authkit#8 is the + * canonical implementer. + * + * Returns versions ordered ascending by `valid_from`, oldest first. An + * empty vector means the entity id was never seen. + */ +template +class IHistoryRepository { +public: + virtual ~IHistoryRepository() = default; + + virtual oatpp::Vector> + history(const oatpp::String& entityId) = 0; +}; + +} // namespace oatpp_authkit::repo + +#endif diff --git a/include/oatpp-authkit/repo/ITemporalEntity.hpp b/include/oatpp-authkit/repo/ITemporalEntity.hpp new file mode 100644 index 0000000..d82768e --- /dev/null +++ b/include/oatpp-authkit/repo/ITemporalEntity.hpp @@ -0,0 +1,32 @@ +#ifndef OATPP_AUTHKIT_REPO_I_TEMPORAL_ENTITY_HPP +#define OATPP_AUTHKIT_REPO_I_TEMPORAL_ENTITY_HPP + +namespace oatpp_authkit::repo { + +/** + * @brief Marker for DTOs that carry temporal-versioning columns. + * + * A DTO opts in by inheriting this empty marker, signalling to the temporal + * decorator (oatpp-authkit#8) that the DTO has the three required oatpp + * `String` fields: + * + * @code + * DTO_FIELD(String, entity_id); // stable across versions + * DTO_FIELD(String, valid_from); // ISO-8601 UTC + * DTO_FIELD(String, valid_until); // ISO-8601 UTC; '9999-12-31T23:59:59Z' = live + * @endcode + * + * The marker is intentionally a plain empty struct rather than a C++20 + * concept — the issue explicitly chose "pure abstract interface, not + * `requires` clause" for the wider Repository shape, and this matches. + * + * Because oatpp DTOs use macros (`DTO_INIT` / `DTO_FIELD`), the field-shape + * contract above is documentation-enforced, not compiler-enforced. The + * temporal decorator dynamic-casts and accesses fields by name; a missing + * field surfaces as a clean runtime error at decorator construction. + */ +struct ITemporalEntity {}; + +} // namespace oatpp_authkit::repo + +#endif diff --git a/include/oatpp-authkit/repo/Repository.hpp b/include/oatpp-authkit/repo/Repository.hpp new file mode 100644 index 0000000..e1b1a15 --- /dev/null +++ b/include/oatpp-authkit/repo/Repository.hpp @@ -0,0 +1,68 @@ +#ifndef OATPP_AUTHKIT_REPO_REPOSITORY_HPP +#define OATPP_AUTHKIT_REPO_REPOSITORY_HPP + +#include "oatpp/core/Types.hpp" + +namespace oatpp_authkit::repo { + +/** + * @brief Pure-abstract per-DTO repository interface. + * + * Generic on `TDto`, temporal-agnostic. Concrete adapters wrap an + * `oatpp::orm::DbClient` (or any other store) and implement the four + * methods below. Cross-cutting concerns (temporal versioning, scope + * authorisation) are added by stacking decorators from oatpp-authkit#8 + * around the concrete adapter at construction time. + * + * @section semantics Method semantics + * + * - `findByEntityId(entityId)` — Single live row matching `entity_id`. + * Returns null `oatpp::Object` when not found. The decorator that adds + * point-in-time reads exposes a different method (`findByEntityId(id, at)`) + * on its own surface; the abstract here stays narrow. + * + * - `list()` — All live rows for this entity type, no filtering. Filtered + * reads land in the optional `IQueryable` capability tracked by + * oatpp-authkit#9; do not bake filter predicates into this base interface. + * + * - `save(dto)` — Mixed `entity_id` allocation: + * - If `dto->entity_id` is null on entry, the implementation generates + * a fresh UUID and writes it back to the DTO before persisting. + * - If `dto->entity_id` is non-null, it is used as-is. + * No upsert semantics are implied at this layer — the temporal decorator + * in oatpp-authkit#8 turns "save" into a versioning insert; without that + * decorator the concrete repo decides whether `save` is insert-or-update + * on its own. + * + * - `softDelete(entityId)` — Marks the row removed without erasing history. + * Concrete repos typically set a `deleted_at` column or its equivalent. + * + * @section design Design decisions (all settled in the issue body) + * + * 1. `entity_id` allocation is mixed (caller may supply or leave null). + * 2. UnitOfWork / cross-repo transactions are explicitly out of scope. + * 3. `Repository` is a virtual-method interface, not a C++20 concept. + * 4. History queries live on a separate `IHistoryRepository` so non- + * temporal repos don't have to implement them. + */ +template +class Repository { +public: + virtual ~Repository() = default; + + /** @brief Single live row by stable entity id. Null oatpp::Object when not found. */ + virtual oatpp::Object findByEntityId(const oatpp::String& entityId) = 0; + + /** @brief All live rows for this entity type (no filtering at this layer). */ + virtual oatpp::Vector> list() = 0; + + /** @brief Persist DTO; allocate UUID for `entity_id` if null on entry. */ + virtual void save(const oatpp::Object& dto) = 0; + + /** @brief Mark the row removed without erasing it. */ + virtual void softDelete(const oatpp::String& entityId) = 0; +}; + +} // namespace oatpp_authkit::repo + +#endif diff --git a/include/oatpp-authkit/repo/TemporalAt.hpp b/include/oatpp-authkit/repo/TemporalAt.hpp new file mode 100644 index 0000000..0ec7b12 --- /dev/null +++ b/include/oatpp-authkit/repo/TemporalAt.hpp @@ -0,0 +1,33 @@ +#ifndef OATPP_AUTHKIT_REPO_TEMPORAL_AT_HPP +#define OATPP_AUTHKIT_REPO_TEMPORAL_AT_HPP + +#include + +namespace oatpp_authkit::repo { + +/** + * @brief Point-in-time selector for temporal reads. + * + * Concrete repositories implementing temporal versioning use this to + * choose between "live" (`valid_until = sentinel`) and "as-of a specific + * timestamp" reads. The interface is decoupled from any particular clock + * type; consumers pass milliseconds-since-epoch. + */ +struct TemporalAt { + enum class Kind { Live, At }; + + Kind kind{Kind::Live}; + int64_t timestamp{0}; ///< Milliseconds since epoch; only meaningful when kind == At. + + static TemporalAt live() { + return TemporalAt{Kind::Live, 0}; + } + + static TemporalAt at(int64_t ts) { + return TemporalAt{Kind::At, ts}; + } +}; + +} // namespace oatpp_authkit::repo + +#endif diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index f880486..615e9aa 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -21,3 +21,7 @@ add_test(NAME security_headers COMMAND test_security_headers) add_executable(test_json_serialization test_json_serialization.cpp) target_link_libraries(test_json_serialization PRIVATE oatpp::authkit oatpp::oatpp) add_test(NAME json_serialization COMMAND test_json_serialization) + +add_executable(test_repository_interface test_repository_interface.cpp) +target_link_libraries(test_repository_interface PRIVATE oatpp::authkit oatpp::oatpp) +add_test(NAME repository_interface COMMAND test_repository_interface) diff --git a/test/test_repository_interface.cpp b/test/test_repository_interface.cpp new file mode 100644 index 0000000..20ecdea --- /dev/null +++ b/test/test_repository_interface.cpp @@ -0,0 +1,200 @@ +// Tests for the oatpp-authkit#7 Repository interface set. Exercises the +// contract through a trivial in-memory fake — confirms the abstract methods +// compile against an oatpp DTO, the mixed-id allocation branch on save() is +// implementable, and findByEntityId/list/softDelete round-trip as documented. +// +// No SQL involvement — that's the concrete adapters' job (out of scope). + +#include "oatpp-authkit/repo/Repository.hpp" +#include "oatpp-authkit/repo/IHistoryRepository.hpp" +#include "oatpp-authkit/repo/ITemporalEntity.hpp" +#include "oatpp-authkit/repo/TemporalAt.hpp" +#include "oatpp-authkit/repo/ActorContext.hpp" + +#include "oatpp/core/macro/codegen.hpp" +#include "oatpp/core/Types.hpp" + +#include +#include +#include + +#include OATPP_CODEGEN_BEGIN(DTO) + +namespace { + +class MockDto : public oatpp::DTO { + DTO_INIT(MockDto, DTO) + DTO_FIELD(String, entity_id); + DTO_FIELD(String, name); +}; + +#include OATPP_CODEGEN_END(DTO) + +int g_failures = 0; + +#define REQUIRE(expr) do { \ + if (!(expr)) { \ + std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \ + ++g_failures; \ + } \ +} while (0) + +// Trivial UUID-ish generator — sufficient for the in-memory fake; concrete +// adapters can use libuuid or similar in production. +std::string generateId() { + static std::mt19937_64 rng{std::random_device{}()}; + char buf[33]; + std::snprintf(buf, sizeof(buf), "%016llx%016llx", + (unsigned long long)rng(), (unsigned long long)rng()); + return std::string(buf); +} + +class InMemoryRepo : public oatpp_authkit::repo::Repository { + std::unordered_map> live; + std::unordered_map> deleted; +public: + oatpp::Object findByEntityId(const oatpp::String& id) override { + auto it = live.find(*id); + return it == live.end() ? nullptr : it->second; + } + + oatpp::Vector> list() override { + auto v = oatpp::Vector>::createShared(); + for (auto& kv : live) v->push_back(kv.second); + return v; + } + + void save(const oatpp::Object& dto) override { + if (!dto->entity_id) { + dto->entity_id = generateId(); + } + live[*dto->entity_id] = dto; + } + + void softDelete(const oatpp::String& id) override { + auto it = live.find(*id); + if (it != live.end()) { + deleted[*id] = it->second; + live.erase(it); + } + } +}; + +void test_save_allocates_uuid_when_id_null() { + InMemoryRepo repo; + auto dto = MockDto::createShared(); + dto->name = oatpp::String("alice"); + REQUIRE(!dto->entity_id); // precondition: id is null + + repo.save(dto); + + REQUIRE(dto->entity_id); // id was filled in + REQUIRE(std::string(*dto->entity_id).size() > 0); +} + +void test_save_uses_supplied_id_when_present() { + InMemoryRepo repo; + auto dto = MockDto::createShared(); + dto->entity_id = oatpp::String("supplied-id-42"); + dto->name = oatpp::String("bob"); + + repo.save(dto); + + REQUIRE(std::string(*dto->entity_id) == "supplied-id-42"); + auto loaded = repo.findByEntityId(oatpp::String("supplied-id-42")); + REQUIRE(loaded); + REQUIRE(std::string(*loaded->name) == "bob"); +} + +void test_find_by_entity_id_round_trip() { + InMemoryRepo repo; + auto dto = MockDto::createShared(); + dto->entity_id = oatpp::String("abc"); + dto->name = oatpp::String("carol"); + repo.save(dto); + + auto found = repo.findByEntityId(oatpp::String("abc")); + REQUIRE(found); + REQUIRE(std::string(*found->name) == "carol"); + + auto missing = repo.findByEntityId(oatpp::String("does-not-exist")); + REQUIRE(!missing); +} + +void test_list_returns_all_live_rows() { + InMemoryRepo repo; + for (const char* n : {"a", "b", "c"}) { + auto dto = MockDto::createShared(); + dto->entity_id = oatpp::String(n); + dto->name = oatpp::String(n); + repo.save(dto); + } + auto all = repo.list(); + REQUIRE(all->size() == 3); +} + +void test_soft_delete_removes_from_live_view() { + InMemoryRepo repo; + auto dto = MockDto::createShared(); + dto->entity_id = oatpp::String("delete-me"); + dto->name = oatpp::String("doomed"); + repo.save(dto); + REQUIRE(repo.findByEntityId(oatpp::String("delete-me"))); + + repo.softDelete(oatpp::String("delete-me")); + + REQUIRE(!repo.findByEntityId(oatpp::String("delete-me"))); + REQUIRE(repo.list()->size() == 0); +} + +void test_temporal_at_value_type() { + using oatpp_authkit::repo::TemporalAt; + auto live = TemporalAt::live(); + REQUIRE(live.kind == TemporalAt::Kind::Live); + + auto pin = TemporalAt::at(1700000000000LL); + REQUIRE(pin.kind == TemporalAt::Kind::At); + REQUIRE(pin.timestamp == 1700000000000LL); +} + +void test_actor_context_minimal() { + oatpp_authkit::repo::ActorContext ctx; + ctx.userId = "user-1"; + ctx.allowedScopes = {"prop-A", "prop-B"}; + REQUIRE(ctx.userId == "user-1"); + REQUIRE(ctx.allowedScopes.size() == 2); +} + +// Compile-time check: ITemporalEntity is usable as a base marker. +#include OATPP_CODEGEN_BEGIN(DTO) + +class TemporalDto : public oatpp::DTO, public oatpp_authkit::repo::ITemporalEntity { + DTO_INIT(TemporalDto, DTO) + DTO_FIELD(String, entity_id); + DTO_FIELD(String, valid_from); + DTO_FIELD(String, valid_until); +}; + +#include OATPP_CODEGEN_END(DTO) + +void test_temporal_marker_compiles() { + auto dto = TemporalDto::createShared(); + dto->entity_id = oatpp::String("t"); + REQUIRE(std::string(*dto->entity_id) == "t"); +} + +} // namespace + +int main() { + test_save_allocates_uuid_when_id_null(); + test_save_uses_supplied_id_when_present(); + test_find_by_entity_id_round_trip(); + test_list_returns_all_live_rows(); + test_soft_delete_removes_from_live_view(); + test_temporal_at_value_type(); + test_actor_context_minimal(); + test_temporal_marker_compiles(); + + std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures); + return g_failures ? 1 : 0; +}