#8: TemporalRepository<T> + ScopeGuardRepository<T> decorators

Two cross-cutting decorators that wrap any Repository<TDto> from #7.

TemporalRepository<TDto>:
- 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<TDto>.
- 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<TDto>:
- Generic; knows nothing about "property"/"tenant"/etc. Constructor
  takes a std::function<bool(ActorContext, TDto)> predicate plus a
  std::function<ActorContext()> 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) <noreply@anthropic.com>
This commit is contained in:
Uwe Schuster 2026-04-27 22:51:39 +02:00
parent a0c61b3d94
commit 08cd32446f
5 changed files with 632 additions and 0 deletions

View file

@ -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<TDto>` interface set distilled from fewo-webapp's per-entity `*Db` clients. Mixed UUID allocation on `save`, separate `IHistoryRepository<T>` for temporal versions, `ActorContext` placeholder for the upcoming scope-guard decorator. |
| `repo/TemporalRepository.hpp` | Decorator that wraps any `Repository<TDto : ITemporalEntity>` 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<T>`. 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

View file

@ -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 <functional>
#include <memory>
#include <stdexcept>
#include <utility>
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 TDto>
class ScopeGuardRepository : public Repository<TDto> {
public:
using Predicate = std::function<bool(const ActorContext&, const oatpp::Object<TDto>&)>;
using ActorAccess = std::function<ActorContext()>;
ScopeGuardRepository(std::shared_ptr<Repository<TDto>> inner,
Predicate isAllowed,
ActorAccess currentActor)
: m_inner(std::move(inner))
, m_isAllowed(std::move(isAllowed))
, m_currentActor(std::move(currentActor))
{}
oatpp::Object<TDto> 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<oatpp::Object<TDto>> list() override {
auto inAll = m_inner->list();
auto out = oatpp::Vector<oatpp::Object<TDto>>::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<TDto>& 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<T>::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<Repository<TDto>> m_inner;
Predicate m_isAllowed;
ActorAccess m_currentActor;
};
} // namespace oatpp_authkit::repo
#endif

View file

@ -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 <chrono>
#include <ctime>
#include <cstdio>
#include <functional>
#include <memory>
#include <random>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>
namespace oatpp_authkit::repo {
/**
* @brief Decorator that turns any `Repository<TDto>` 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<TDto>` 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 TDto>
class TemporalRepository
: public Repository<TDto>
, public IHistoryRepository<TDto>
{
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<int64_t()>; ///< Returns milliseconds since epoch.
using IdGen = std::function<oatpp::String()>;
/**
* @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<Repository<TDto>> 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<ITemporalEntity, TDto>,
"TemporalRepository<TDto> requires TDto to inherit ITemporalEntity");
}
/** @brief Live row for the given entity_id, or null. */
oatpp::Object<TDto> 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<TDto> 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<oatpp::Object<TDto>> list() override {
auto out = oatpp::Vector<oatpp::Object<TDto>>::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<TDto>& 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<oatpp::Object<TDto>>
history(const oatpp::String& entityId) override
{
std::vector<oatpp::Object<TDto>> 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<TDto>& a, const oatpp::Object<TDto>& 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<oatpp::Object<TDto>>::createShared();
for (auto& r : bucket) out->push_back(r);
return out;
}
private:
static Clock defaultClock() {
return [] {
using namespace std::chrono;
return duration_cast<milliseconds>(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<std::time_t>(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<Repository<TDto>> m_inner;
Clock m_clock;
IdGen m_idgen;
};
} // namespace oatpp_authkit::repo
#endif

View file

@ -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)

View file

@ -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<TDto>.
#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 <cstdio>
#include <map>
#include <memory>
#include <string>
#include <utility>
#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<MockTemporalDto> {
std::map<std::pair<std::string, std::string>, oatpp::Object<MockTemporalDto>> rows;
public:
oatpp::Object<MockTemporalDto> 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<oatpp::Object<MockTemporalDto>> list() override {
auto v = oatpp::Vector<oatpp::Object<MockTemporalDto>>::createShared();
for (auto& kv : rows) v->push_back(kv.second);
return v;
}
void save(const oatpp::Object<MockTemporalDto>& 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<InMemoryAllRows>();
auto clock = std::make_shared<StepClock>();
TemporalRepository<MockTemporalDto> 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<MockTemporalDto>::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<MockTemporalDto>::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<InMemoryAllRows>();
auto clock = std::make_shared<StepClock>();
TemporalRepository<MockTemporalDto> 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<InMemoryAllRows>();
StepClock clock;
int64_t t1 = clock.ms;
auto repo = std::make_shared<TemporalRepository<MockTemporalDto>>(
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<InMemoryAllRows>();
auto clock = std::make_shared<StepClock>();
TemporalRepository<MockTemporalDto> 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<InMemoryAllRows>();
auto clock = std::make_shared<StepClock>();
TemporalRepository<MockTemporalDto> 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<MockTemporalDto>::SENTINEL);
}
// ---- ScopeGuardRepository ----
void test_scope_guard_denies_when_predicate_false() {
using namespace oatpp_authkit::repo;
auto inner = std::make_shared<InMemoryAllRows>();
// 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<MockTemporalDto> guarded(inner,
// Predicate: only allow rows whose scope is in the actor's allowedScopes.
[](const ActorContext& a, const oatpp::Object<MockTemporalDto>& 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;
}