// 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/TemporalFieldTraits.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 { DTO_INIT(MockTemporalDto, DTO) DTO_FIELD(String, id); // per-row PK (version UUID) 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) } // namespace OATPP_AUTHKIT_REGISTER_TEMPORAL(MockTemporalDto, id, entity_id, valid_from, valid_until) namespace { 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 `id` PK (per-row UUID). save() upserts — // matches the new TemporalRepository inner contract (authkit#13). // Exposes ALL rows via list() — the temporal decorator filters to live. class InMemoryAllRows : public oatpp_authkit::repo::Repository { std::map> rows; public: oatpp::Object findByEntityId(const oatpp::String& id) override { // Not used by TemporalRepository — included for interface completeness. for (auto& kv : rows) if (kv.second->entity_id && std::string(*kv.second->entity_id) == 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[std::string(*dto->id)] = dto; } void softDelete(const oatpp::String& id) override { for (auto it = rows.begin(); it != rows.end(); ) { if (it->second->entity_id && std::string(*it->second->entity_id) == 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; } }; // Sequencing idgen so each call returns a fresh string — needed now that // the decorator allocates both entity_id and per-row PK. struct SeqIdGen { int n{0}; oatpp::String operator()() { char buf[16]; std::snprintf(buf, sizeof(buf), "id%04d", n++); return oatpp::String(buf); } }; void test_save_closes_prior_version_and_inserts_new() { using namespace oatpp_authkit::repo; auto inner = std::make_shared(); auto clock = std::make_shared(); auto ids = std::make_shared(); TemporalRepository repo(inner, [clock]{ return (*clock)(); }, [ids]{ return (*ids)(); }); // First save — entity_id + 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(v1->id); REQUIRE(std::string(*v1->valid_until) == TemporalRepository::SENTINEL); REQUIRE(inner->list()->size() == 1); const std::string livePkAfterFirst = std::string(*v1->id); // Second save — historical copy with new PK, live row updated in place. 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; std::string livePkAfterSecond, historicalPk; for (auto& row : *allAfter) { if (std::string(*row->valid_until) == TemporalRepository::SENTINEL) { ++liveCount; livePkAfterSecond = std::string(*row->id); } else { historicalPk = std::string(*row->id); } } REQUIRE(liveCount == 1); // exactly one live REQUIRE(livePkAfterSecond == livePkAfterFirst); // stable live PK REQUIRE(historicalPk != livePkAfterFirst); // historical has fresh PK REQUIRE(std::string(*v2->id) == livePkAfterFirst); // dto reflects preserved PK } 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->id = oatpp::String(std::string("pk-") + sc); 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; }, [](const oatpp::Object& d) { return d->entity_id; }); // 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); } // Scope predicate + entity-id accessor shared by the reparenting / queryable tests. static bool scopeAllows(const oatpp_authkit::repo::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; } static oatpp::String entityIdOf(const oatpp::Object& d) { return d->entity_id; } // An actor scoped to prop-A must NOT be able to reparent an existing prop-B row // into prop-A by setting scope=prop-A in the body. save() must reject because the // *existing* row is out of scope, even though the incoming dto looks in-scope. void test_scope_guard_blocks_reparenting() { using namespace oatpp_authkit::repo; auto inner = std::make_shared(); // Seed an entity currently owned by prop-B. auto seeded = MockTemporalDto::createShared(); seeded->id = oatpp::String("pk-ent1"); seeded->entity_id = oatpp::String("ent1"); seeded->valid_from = oatpp::String("2020-01-01T00:00:00Z"); seeded->valid_until = oatpp::String("9999-12-31T23:59:59Z"); seeded->scope = oatpp::String("prop-B"); inner->save(seeded); ActorContext actor; actor.userId = "u1"; actor.allowedScopes = {"prop-A"}; ScopeGuardRepository guarded( inner, &scopeAllows, [actor]{ return actor; }, &entityIdOf); // Attempt to claim ent1 by relabelling it prop-A. auto reparent = MockTemporalDto::createShared(); reparent->entity_id = oatpp::String("ent1"); reparent->scope = oatpp::String("prop-A"); // incoming looks in-scope... bool blocked = false; try { guarded.save(reparent); } catch (const ScopeDeniedException&) { blocked = true; } // ...but existing row is prop-B REQUIRE(blocked); // The stored row is untouched. auto still = inner->findByEntityId(oatpp::String("ent1")); REQUIRE(still); REQUIRE(std::string(*still->scope) == "prop-B"); // A genuine insert into the actor's own scope still works (no existing row). auto fresh = MockTemporalDto::createShared(); fresh->id = oatpp::String("pk-ent2"); fresh->entity_id = oatpp::String("ent2"); fresh->scope = oatpp::String("prop-A"); fresh->valid_until = oatpp::String("9999-12-31T23:59:59Z"); bool ok = true; try { guarded.save(fresh); } catch (const ScopeDeniedException&) { ok = false; } REQUIRE(ok); } // Minimal IQueryable inner whose query() returns every row, so the test can // verify ScopeGuardQueryable post-filters results through the predicate. class InMemoryQueryable : public oatpp_authkit::repo::IQueryable { std::map> rows; public: oatpp::Object findByEntityId(const oatpp::String& id) override { for (auto& kv : rows) if (kv.second->entity_id && std::string(*kv.second->entity_id) == 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[std::string(*dto->id)] = dto; } void softDelete(const oatpp::String&) override {} oatpp::Vector> query(const oatpp_authkit::repo::Query&) override { return list(); // pretend the filter ran; the point is the guard filters scope } }; // query() through ScopeGuardQueryable must drop rows outside the actor's scope — // otherwise the queryable surface bypasses the scope guard entirely. void test_scope_guard_queryable_filters_query() { using namespace oatpp_authkit::repo; auto inner = std::make_shared(); for (const char* sc : {"prop-A", "prop-B"}) { auto dto = MockTemporalDto::createShared(); dto->id = oatpp::String(std::string("pk-") + sc); dto->entity_id = oatpp::String(sc); dto->valid_until = oatpp::String("9999-12-31T23:59:59Z"); dto->scope = oatpp::String(sc); inner->save(dto); } ActorContext actor; actor.userId = "u1"; actor.allowedScopes = {"prop-A"}; ScopeGuardQueryable guarded( inner, &scopeAllows, [actor]{ return actor; }, &entityIdOf); auto result = guarded.query(Query{}); REQUIRE(result->size() == 1); // prop-B filtered out REQUIRE(std::string(*(*result)[0]->scope) == "prop-A"); } } // 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(); test_scope_guard_blocks_reparenting(); test_scope_guard_queryable_filters_query(); std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures); return g_failures ? 1 : 0; }