From 08cd32446f1f61b9f9c5ae09451cf21f50558207 Mon Sep 17 00:00:00 2001 From: Uwe Schuster Date: Mon, 27 Apr 2026 22:51:39 +0200 Subject: [PATCH] #8: TemporalRepository + ScopeGuardRepository decorators Two cross-cutting decorators that wrap any Repository from #7. TemporalRepository: - Requires TDto : ITemporalEntity (compile-time static_assert). - save() finds the existing live version, closes its valid_until, and inserts a new row at valid_until = '9999-12-31T23:59:59Z' sentinel. - findByEntityId() returns the live row; findByEntityIdAt(id, at) does the [valid_from, valid_until) point-in-time read. - list() returns live rows only; history(id) returns all versions ordered by valid_from. Implements IHistoryRepository. - softDelete closes the live row without inserting a new version. - Clock and id-generator are constructor-injected (defaults: system_clock + 32-char hex from mt19937_64) so the unit tests are deterministic. The decorator's contract on the inner repository: list() must expose all rows including historical, and save() must be upsert keyed by (entity_id, valid_from). Documented on the class. ScopeGuardRepository: - Generic; knows nothing about "property"/"tenant"/etc. Constructor takes a std::function predicate plus a std::function accessor (so a single instance can serve many requests with different actors). - list() filters; findByEntityId/save/softDelete throw ScopeDeniedException on deny. Tests cover the five acceptance criteria from the issue body: - Temporal save closes the prior version - Live read returns only the row with valid_until = sentinel - Point-in-time read returns the version live at that time - History returns all versions in order - Scope guard short-circuits when the predicate returns false ctest: 6/6 green (4 prior + repository_interface + repository_decorators). Closes #8 Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 + .../repo/ScopeGuardRepository.hpp | 107 +++++++ .../oatpp-authkit/repo/TemporalRepository.hpp | 224 +++++++++++++ test/CMakeLists.txt | 4 + test/test_repository_decorators.cpp | 295 ++++++++++++++++++ 5 files changed, 632 insertions(+) create mode 100644 include/oatpp-authkit/repo/ScopeGuardRepository.hpp create mode 100644 include/oatpp-authkit/repo/TemporalRepository.hpp create mode 100644 test/test_repository_decorators.cpp diff --git a/README.md b/README.md index e7b17d0..c7530b7 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ hardened auth / security stack. Header-only, oatpp 1.3+, C++17. | `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. | +| `repo/TemporalRepository.hpp` | Decorator that wraps any `Repository` and turns it into a temporally-versioned one. `save` closes the prior live version and inserts a new one; `findByEntityIdAt(id, at)` returns the version live at a point in time; implements `IHistoryRepository`. Inner adapter is expected to expose all rows (live + historical) and treat `save` as upsert keyed by `(entity_id, valid_from)`. | +| `repo/ScopeGuardRepository.hpp` | Generic resource-scope decorator. Takes a `bool(ActorContext, TDto)` predicate at construction; gates every method on it. Throws `ScopeDeniedException` on deny (catchers translate to 403). Knows nothing about consumer-specific concepts like "property" or "tenant" — the predicate decides. | ## Consume via CMake diff --git a/include/oatpp-authkit/repo/ScopeGuardRepository.hpp b/include/oatpp-authkit/repo/ScopeGuardRepository.hpp new file mode 100644 index 0000000..e4c9abe --- /dev/null +++ b/include/oatpp-authkit/repo/ScopeGuardRepository.hpp @@ -0,0 +1,107 @@ +#ifndef OATPP_AUTHKIT_REPO_SCOPE_GUARD_REPOSITORY_HPP +#define OATPP_AUTHKIT_REPO_SCOPE_GUARD_REPOSITORY_HPP + +#include "oatpp-authkit/repo/Repository.hpp" +#include "oatpp-authkit/repo/ActorContext.hpp" + +#include "oatpp/core/Types.hpp" + +#include +#include +#include +#include + +namespace oatpp_authkit::repo { + +/** + * @brief Thrown when the scope guard predicate denies an operation. + * + * Catchers (typically the controller layer) translate this into the + * appropriate HTTP error — 403 Forbidden in fewo-webapp's case. The + * decorator stays library-portable by throwing a plain exception rather + * than coupling to oatpp's `OatppException` hierarchy. + */ +class ScopeDeniedException : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; + +/** + * @brief Decorator that gates every repository operation on a predicate. + * + * Generic — knows nothing about "property" / "tenant" / any consumer- + * specific scope concept. The predicate decides; this class just calls it. + * + * @section semantics Per-method behaviour + * + * - `findByEntityId(id)`: load from inner; if non-null and predicate + * denies, throw `ScopeDeniedException`. (Information-leak vs. clean + * error tradeoff: throwing is the safer default — callers that want to + * silently 404 instead can catch and translate.) + * - `list()`: load from inner; filter out rows the predicate denies. + * - `save(dto)`: predicate evaluated on the incoming dto; deny ⇒ throw. + * - `softDelete(id)`: load from inner; if denied, throw; otherwise delegate. + * + * The actor is provided via a constructor-injected accessor so a single + * `ScopeGuardRepository` instance can serve many requests with different + * actors (typically the accessor reads from the per-request authenticated + * principal — fewo-webapp's `AuthInterceptor` populates one). + */ +template +class ScopeGuardRepository : public Repository { +public: + using Predicate = std::function&)>; + using ActorAccess = std::function; + + ScopeGuardRepository(std::shared_ptr> inner, + Predicate isAllowed, + ActorAccess currentActor) + : m_inner(std::move(inner)) + , m_isAllowed(std::move(isAllowed)) + , m_currentActor(std::move(currentActor)) + {} + + oatpp::Object findByEntityId(const oatpp::String& entityId) override { + auto row = m_inner->findByEntityId(entityId); + if (!row) return row; + if (!m_isAllowed(m_currentActor(), row)) { + throw ScopeDeniedException("scope guard denied findByEntityId"); + } + return row; + } + + oatpp::Vector> list() override { + auto inAll = m_inner->list(); + auto out = oatpp::Vector>::createShared(); + const ActorContext actor = m_currentActor(); + for (auto& row : *inAll) { + if (m_isAllowed(actor, row)) out->push_back(row); + } + return out; + } + + void save(const oatpp::Object& dto) override { + if (!m_isAllowed(m_currentActor(), dto)) { + throw ScopeDeniedException("scope guard denied save"); + } + m_inner->save(dto); + } + + void softDelete(const oatpp::String& entityId) override { + auto row = m_inner->findByEntityId(entityId); + if (!row) return; // Nothing to delete; matches Repository::softDelete being a no-op for unknown ids. + if (!m_isAllowed(m_currentActor(), row)) { + throw ScopeDeniedException("scope guard denied softDelete"); + } + m_inner->softDelete(entityId); + } + +private: + std::shared_ptr> m_inner; + Predicate m_isAllowed; + ActorAccess m_currentActor; +}; + +} // namespace oatpp_authkit::repo + +#endif diff --git a/include/oatpp-authkit/repo/TemporalRepository.hpp b/include/oatpp-authkit/repo/TemporalRepository.hpp new file mode 100644 index 0000000..5665c1c --- /dev/null +++ b/include/oatpp-authkit/repo/TemporalRepository.hpp @@ -0,0 +1,224 @@ +#ifndef OATPP_AUTHKIT_REPO_TEMPORAL_REPOSITORY_HPP +#define OATPP_AUTHKIT_REPO_TEMPORAL_REPOSITORY_HPP + +#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/core/Types.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace oatpp_authkit::repo { + +/** + * @brief Decorator that turns any `Repository` into a temporally-versioned one. + * + * `TDto` must inherit `ITemporalEntity` and expose three oatpp `String` fields + * declared via `DTO_FIELD`: `entity_id`, `valid_from`, `valid_until`. The + * marker is asserted at compile time; the field shape is documentation- + * enforced (oatpp DTOs don't expose a static field-list mechanism the + * decorator could verify via `static_assert`). + * + * @section contract Inner repository contract + * + * The wrapped inner `Repository` is expected to: + * + * - Treat `save(dto)` as **upsert keyed by (entity_id, valid_from)**. New + * `valid_from` ⇒ insert a new row. Existing `valid_from` ⇒ update the row + * (this is how `save(closedPrior)` closes a prior version). + * - Treat `list()` as **all rows including historical ones** — no filtering + * by `valid_until`. This decorator does the live-vs-historical filtering + * itself. + * - `findByEntityId` and `softDelete` on the inner are **not used by the + * decorator**; the decorator overrides them with temporal-aware + * implementations. + * + * @section semantics Decorator semantics + * + * - `save(dto)`: if `dto->entity_id` is null, allocate one. Look up the + * currently live row for that entity id; if present, copy it, set its + * `valid_until = now`, and `save` it (closes the old version). Then set + * the new dto's `valid_from = now`, `valid_until = SENTINEL`, and `save` it. + * - `findByEntityId(id)` returns the row whose `valid_until == SENTINEL`. + * - `findByEntityIdAt(id, at)` returns the version live at that timestamp. + * - `list()` returns only live rows. + * - `history(id)` returns all versions ordered ascending by `valid_from`. + * - `softDelete(id)` closes the live row (sets its `valid_until = now`) but + * does not insert a new version. + */ +template +class TemporalRepository + : public Repository + , public IHistoryRepository +{ +public: + /** + * Sentinel valid_until value indicating the row is currently live. + * ISO-8601 UTC, lexically greater than any plausible real timestamp, + * matches the convention used by fewo-webapp's existing temporal tables. + */ + static constexpr const char* SENTINEL = "9999-12-31T23:59:59Z"; + + using Clock = std::function; ///< Returns milliseconds since epoch. + using IdGen = std::function; + + /** + * @param inner Concrete adapter that exposes all-rows-including-historical. + * @param clock Optional injected clock for tests; default uses system_clock. + * @param idgen Optional injected id generator for tests; default is a 32-char hex from mt19937_64. + */ + explicit TemporalRepository(std::shared_ptr> inner, + Clock clock = {}, + IdGen idgen = {}) + : m_inner(std::move(inner)) + , m_clock(clock ? std::move(clock) : defaultClock()) + , m_idgen(idgen ? std::move(idgen) : defaultIdGen()) + { + static_assert(std::is_base_of_v, + "TemporalRepository requires TDto to inherit ITemporalEntity"); + } + + /** @brief Live row for the given entity_id, or null. */ + oatpp::Object findByEntityId(const oatpp::String& entityId) override { + auto all = m_inner->list(); + for (auto& row : *all) { + if (row->entity_id && row->valid_until + && std::string(*row->entity_id) == std::string(*entityId) + && std::string(*row->valid_until) == SENTINEL) { + return row; + } + } + return nullptr; + } + + /** @brief Version of `entityId` live at the given point in time. */ + oatpp::Object findByEntityIdAt(const oatpp::String& entityId, const TemporalAt& at) { + if (at.kind == TemporalAt::Kind::Live) { + return findByEntityId(entityId); + } + const std::string atIso = isoFromMillis(at.timestamp); + auto all = m_inner->list(); + for (auto& row : *all) { + if (!row->entity_id || std::string(*row->entity_id) != std::string(*entityId)) continue; + const std::string from = row->valid_from ? std::string(*row->valid_from) : std::string(); + const std::string until = row->valid_until ? std::string(*row->valid_until) : std::string(); + if (from <= atIso && atIso < until) return row; + } + return nullptr; + } + + /** @brief All currently-live rows. */ + oatpp::Vector> list() override { + auto out = oatpp::Vector>::createShared(); + auto all = m_inner->list(); + for (auto& row : *all) { + if (row->valid_until && std::string(*row->valid_until) == SENTINEL) { + out->push_back(row); + } + } + return out; + } + + /** + * Close the previous live version (if any) and insert a new live row. + * Mutates `dto` in place to fill in `entity_id` (if null), `valid_from`, + * and `valid_until`. + */ + void save(const oatpp::Object& dto) override { + if (!dto->entity_id) dto->entity_id = m_idgen(); + + const int64_t nowMs = m_clock(); + const std::string nowIso = isoFromMillis(nowMs); + + // Close the existing live version (if any). + auto live = findByEntityId(dto->entity_id); + if (live) { + live->valid_until = oatpp::String(nowIso); + m_inner->save(live); + } + + // Insert the new live version. + dto->valid_from = oatpp::String(nowIso); + dto->valid_until = oatpp::String(SENTINEL); + m_inner->save(dto); + } + + /** @brief Close the live row without inserting a new version. */ + void softDelete(const oatpp::String& entityId) override { + auto live = findByEntityId(entityId); + if (!live) return; + live->valid_until = oatpp::String(isoFromMillis(m_clock())); + m_inner->save(live); + } + + /** @brief All versions for `entityId`, oldest first. */ + oatpp::Vector> + history(const oatpp::String& entityId) override + { + std::vector> bucket; + auto all = m_inner->list(); + for (auto& row : *all) { + if (row->entity_id && std::string(*row->entity_id) == std::string(*entityId)) { + bucket.push_back(row); + } + } + std::sort(bucket.begin(), bucket.end(), + [](const oatpp::Object& a, const oatpp::Object& b) { + const std::string af = a->valid_from ? std::string(*a->valid_from) : std::string(); + const std::string bf = b->valid_from ? std::string(*b->valid_from) : std::string(); + return af < bf; + }); + auto out = oatpp::Vector>::createShared(); + for (auto& r : bucket) out->push_back(r); + return out; + } + +private: + static Clock defaultClock() { + return [] { + using namespace std::chrono; + return duration_cast(system_clock::now().time_since_epoch()).count(); + }; + } + + static IdGen defaultIdGen() { + return [] { + static thread_local 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 oatpp::String(buf); + }; + } + + static std::string isoFromMillis(int64_t ms) { + std::time_t secs = static_cast(ms / 1000); + std::tm tmv{}; + gmtime_r(&secs, &tmv); + char buf[32]; + std::snprintf(buf, sizeof(buf), "%04d-%02d-%02dT%02d:%02d:%02d.%03lldZ", + tmv.tm_year + 1900, tmv.tm_mon + 1, tmv.tm_mday, + tmv.tm_hour, tmv.tm_min, tmv.tm_sec, + (long long)(ms % 1000)); + return std::string(buf); + } + + std::shared_ptr> m_inner; + Clock m_clock; + IdGen m_idgen; +}; + +} // namespace oatpp_authkit::repo + +#endif diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 615e9aa..94d686b 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -25,3 +25,7 @@ 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) + +add_executable(test_repository_decorators test_repository_decorators.cpp) +target_link_libraries(test_repository_decorators PRIVATE oatpp::authkit oatpp::oatpp) +add_test(NAME repository_decorators COMMAND test_repository_decorators) diff --git a/test/test_repository_decorators.cpp b/test/test_repository_decorators.cpp new file mode 100644 index 0000000..2714827 --- /dev/null +++ b/test/test_repository_decorators.cpp @@ -0,0 +1,295 @@ +// Tests for the oatpp-authkit#8 repository decorators (TemporalRepository, +// ScopeGuardRepository). Validates the acceptance criteria from the issue: +// - Temporal save closes the prior version +// - Live read returns only the row with valid_until = sentinel +// - Point-in-time read returns the version live at that time +// - History returns all versions in order +// - Scope guard short-circuits when the predicate returns false +// +// The in-memory backing store keys rows by (entity_id, valid_from), matching +// the upsert contract documented on TemporalRepository. + +#include "oatpp-authkit/repo/TemporalRepository.hpp" +#include "oatpp-authkit/repo/ScopeGuardRepository.hpp" +#include "oatpp-authkit/repo/Repository.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 +#include + +#include OATPP_CODEGEN_BEGIN(DTO) + +namespace { + +class MockTemporalDto : public oatpp::DTO, public oatpp_authkit::repo::ITemporalEntity { + DTO_INIT(MockTemporalDto, DTO) + DTO_FIELD(String, entity_id); + DTO_FIELD(String, valid_from); + DTO_FIELD(String, valid_until); + DTO_FIELD(String, name); + DTO_FIELD(String, scope); // For ScopeGuardRepository — emulates a property_id-style field. +}; + +#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) + +// In-memory adapter: rows keyed by (entity_id, valid_from). save() upserts. +// Exposes ALL rows via list() — the temporal decorator filters to live. +class InMemoryAllRows : public oatpp_authkit::repo::Repository { + std::map, oatpp::Object> rows; +public: + oatpp::Object findByEntityId(const oatpp::String& id) override { + // Not used by TemporalRepository — included for interface completeness. + for (auto& kv : rows) if (kv.first.first == std::string(*id)) return kv.second; + return nullptr; + } + oatpp::Vector> list() override { + auto v = oatpp::Vector>::createShared(); + for (auto& kv : rows) v->push_back(kv.second); + return v; + } + void save(const oatpp::Object& dto) override { + rows[{*dto->entity_id, *dto->valid_from}] = dto; + } + void softDelete(const oatpp::String& id) override { + for (auto it = rows.begin(); it != rows.end(); ) { + if (it->first.first == std::string(*id)) it = rows.erase(it); else ++it; + } + } +}; + +// Fixed-time clock for deterministic tests. Returns successive timestamps +// 1000ms apart so point-in-time reads can pick a value strictly between +// version boundaries. +struct StepClock { + int64_t ms{1700000000000LL}; + int64_t operator()() { int64_t v = ms; ms += 1000; return v; } +}; + +void test_save_closes_prior_version_and_inserts_new() { + using namespace oatpp_authkit::repo; + auto inner = std::make_shared(); + auto clock = std::make_shared(); + TemporalRepository repo(inner, + [clock]{ return (*clock)(); }, + []{ return oatpp::String("alice"); }); + + // First save — entity_id auto-allocated, valid_from = now1, valid_until = SENTINEL. + auto v1 = MockTemporalDto::createShared(); + v1->name = oatpp::String("alice v1"); + repo.save(v1); + + REQUIRE(v1->entity_id); + REQUIRE(std::string(*v1->valid_until) + == TemporalRepository::SENTINEL); + REQUIRE(inner->list()->size() == 1); + + // Second save — old version's valid_until is closed; new live row inserted. + auto v2 = MockTemporalDto::createShared(); + v2->entity_id = v1->entity_id; + v2->name = oatpp::String("alice v2"); + repo.save(v2); + + auto allAfter = inner->list(); + REQUIRE(allAfter->size() == 2); + int liveCount = 0; + for (auto& row : *allAfter) { + if (std::string(*row->valid_until) + == TemporalRepository::SENTINEL) ++liveCount; + } + REQUIRE(liveCount == 1); // Only one row is live. +} + +void test_live_read_returns_only_sentinel_row() { + using namespace oatpp_authkit::repo; + auto inner = std::make_shared(); + auto clock = std::make_shared(); + TemporalRepository repo(inner, + [clock]{ return (*clock)(); }); + + auto v1 = MockTemporalDto::createShared(); + v1->entity_id = oatpp::String("abc"); + v1->name = oatpp::String("v1"); + repo.save(v1); + auto v2 = MockTemporalDto::createShared(); + v2->entity_id = oatpp::String("abc"); + v2->name = oatpp::String("v2"); + repo.save(v2); + + auto live = repo.findByEntityId(oatpp::String("abc")); + REQUIRE(live); + REQUIRE(std::string(*live->name) == "v2"); + + auto liveList = repo.list(); + REQUIRE(liveList->size() == 1); + REQUIRE(std::string(*(*liveList)[0]->name) == "v2"); +} + +void test_point_in_time_read_returns_version_live_at_t() { + using namespace oatpp_authkit::repo; + auto inner = std::make_shared(); + StepClock clock; + int64_t t1 = clock.ms; + auto repo = std::make_shared>( + inner, [&clock]{ return clock(); }); + + auto v1 = MockTemporalDto::createShared(); + v1->entity_id = oatpp::String("abc"); + v1->name = oatpp::String("v1"); + repo->save(v1); // valid_from = t1, valid_until = SENTINEL → t2 after save 2 + + // Pick a point strictly inside v1's interval [t1, t2). + int64_t betweenSaves = t1 + 500; // t2 = t1 + 1000 with our StepClock + + auto v2 = MockTemporalDto::createShared(); + v2->entity_id = oatpp::String("abc"); + v2->name = oatpp::String("v2"); + repo->save(v2); // closes v1 at t2; v2 valid_from = t2 + + // Read as-of betweenSaves — should still see v1. + auto atT2 = repo->findByEntityIdAt(oatpp::String("abc"), + TemporalAt::at(betweenSaves)); + REQUIRE(atT2); + REQUIRE(std::string(*atT2->name) == "v1"); + + // Read as-of t1 — should also see v1. + auto atT1 = repo->findByEntityIdAt(oatpp::String("abc"), TemporalAt::at(t1)); + REQUIRE(atT1); + REQUIRE(std::string(*atT1->name) == "v1"); +} + +void test_history_returns_versions_in_order() { + using namespace oatpp_authkit::repo; + auto inner = std::make_shared(); + auto clock = std::make_shared(); + TemporalRepository repo(inner, + [clock]{ return (*clock)(); }); + + for (const char* n : {"v1", "v2", "v3"}) { + auto dto = MockTemporalDto::createShared(); + dto->entity_id = oatpp::String("abc"); + dto->name = oatpp::String(n); + repo.save(dto); + } + + auto h = repo.history(oatpp::String("abc")); + REQUIRE(h->size() == 3); + REQUIRE(std::string(*(*h)[0]->name) == "v1"); + REQUIRE(std::string(*(*h)[1]->name) == "v2"); + REQUIRE(std::string(*(*h)[2]->name) == "v3"); +} + +void test_soft_delete_closes_live_without_new_version() { + using namespace oatpp_authkit::repo; + auto inner = std::make_shared(); + auto clock = std::make_shared(); + TemporalRepository repo(inner, + [clock]{ return (*clock)(); }); + + auto v = MockTemporalDto::createShared(); + v->entity_id = oatpp::String("abc"); + v->name = oatpp::String("dead"); + repo.save(v); + + repo.softDelete(oatpp::String("abc")); + + REQUIRE(!repo.findByEntityId(oatpp::String("abc"))); + auto remaining = inner->list(); + REQUIRE(remaining->size() == 1); // historical row still exists + REQUIRE(std::string(*(*remaining)[0]->valid_until) + != TemporalRepository::SENTINEL); +} + +// ---- ScopeGuardRepository ---- + +void test_scope_guard_denies_when_predicate_false() { + using namespace oatpp_authkit::repo; + auto inner = std::make_shared(); + + // Seed inner with two rows in different scopes. + for (const char* sc : {"prop-A", "prop-B"}) { + auto dto = MockTemporalDto::createShared(); + dto->entity_id = oatpp::String(sc); // reuse scope as id for simplicity + dto->valid_from = oatpp::String("2020-01-01T00:00:00Z"); + dto->valid_until = oatpp::String("9999-12-31T23:59:59Z"); + dto->name = oatpp::String(sc); + dto->scope = oatpp::String(sc); + inner->save(dto); + } + + ActorContext actor; + actor.userId = "u1"; + actor.allowedScopes = {"prop-A"}; + + ScopeGuardRepository guarded(inner, + // Predicate: only allow rows whose scope is in the actor's allowedScopes. + [](const ActorContext& a, const oatpp::Object& d) { + if (!d || !d->scope) return false; + const std::string s = std::string(*d->scope); + for (auto& as : a.allowedScopes) if (as == s) return true; + return false; + }, + [actor]{ return actor; }); + + // list filters to allowed rows only. + auto allowed = guarded.list(); + REQUIRE(allowed->size() == 1); + REQUIRE(std::string(*(*allowed)[0]->scope) == "prop-A"); + + // findByEntityId on a denied row throws. + bool threwOnFind = false; + try { (void)guarded.findByEntityId(oatpp::String("prop-B")); } + catch (const ScopeDeniedException&) { threwOnFind = true; } + REQUIRE(threwOnFind); + + // findByEntityId on an allowed row returns it. + auto okRow = guarded.findByEntityId(oatpp::String("prop-A")); + REQUIRE(okRow); + + // save of a denied scope throws. + auto evil = MockTemporalDto::createShared(); + evil->entity_id = oatpp::String("xxx"); + evil->valid_from = oatpp::String("2020-01-01T00:00:00Z"); + evil->valid_until = oatpp::String("9999-12-31T23:59:59Z"); + evil->scope = oatpp::String("prop-B"); + bool threwOnSave = false; + try { guarded.save(evil); } + catch (const ScopeDeniedException&) { threwOnSave = true; } + REQUIRE(threwOnSave); + + // softDelete of a denied row throws. + bool threwOnDelete = false; + try { guarded.softDelete(oatpp::String("prop-B")); } + catch (const ScopeDeniedException&) { threwOnDelete = true; } + REQUIRE(threwOnDelete); +} + +} // namespace + +int main() { + test_save_closes_prior_version_and_inserts_new(); + test_live_read_returns_only_sentinel_row(); + test_point_in_time_read_returns_version_live_at_t(); + test_history_returns_versions_in_order(); + test_soft_delete_closes_live_without_new_version(); + test_scope_guard_denies_when_predicate_false(); + + std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures); + return g_failures ? 1 : 0; +}