#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:
parent
a0c61b3d94
commit
08cd32446f
5 changed files with 632 additions and 0 deletions
|
|
@ -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). |
|
| `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. |
|
| `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/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
|
## Consume via CMake
|
||||||
|
|
||||||
|
|
|
||||||
107
include/oatpp-authkit/repo/ScopeGuardRepository.hpp
Normal file
107
include/oatpp-authkit/repo/ScopeGuardRepository.hpp
Normal 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
|
||||||
224
include/oatpp-authkit/repo/TemporalRepository.hpp
Normal file
224
include/oatpp-authkit/repo/TemporalRepository.hpp
Normal 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
|
||||||
|
|
@ -25,3 +25,7 @@ add_test(NAME json_serialization COMMAND test_json_serialization)
|
||||||
add_executable(test_repository_interface test_repository_interface.cpp)
|
add_executable(test_repository_interface test_repository_interface.cpp)
|
||||||
target_link_libraries(test_repository_interface PRIVATE oatpp::authkit oatpp::oatpp)
|
target_link_libraries(test_repository_interface PRIVATE oatpp::authkit oatpp::oatpp)
|
||||||
add_test(NAME repository_interface COMMAND test_repository_interface)
|
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)
|
||||||
|
|
|
||||||
295
test/test_repository_decorators.cpp
Normal file
295
test/test_repository_decorators.cpp
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue