oatpp-authkit/test/test_repository_decorators.cpp
Uwe Schuster 2e11408240 #16 (audit H-1..H-5): fix the five high-severity findings
- H-1 cert-DN spoofing: IRuntimeConfig::certAuthTrusted() now defaults to
  false (fail-closed). X-SSL-Client-DN is an ordinary request header; a
  loopback bind does not prove it came from a TLS-terminating proxy.
  Consumers must opt in explicitly behind a header-stripping proxy.

- H-3 scope reparenting: ScopeGuardRepository::save() now also checks the
  EXISTING row's scope (via a new required entity-id accessor), so an actor
  can't claim an out-of-scope row by relabelling it in the request body.

- H-2 IQueryable bypass: add ScopeGuardQueryable<T> — filters query()
  results through the same predicate so the queryable surface can't escape
  the scope guard.

- H-4 TemporalRepository TOCTOU: serialise the read-modify-write with a
  per-instance mutex (no more duplicate-live / lost-update under concurrent
  same-entity saves) and add an optional TxRunner so the close-then-insert
  pair can commit/rollback atomically.

- H-5 SMTP header injection: reject CR/LF/NUL in `to`/`fromAddress` before
  building the envelope and From:/To: header lines.

Tests: expand test_repository_decorators (reparenting + queryable filtering),
add curl-guarded test_smtp_transport (base64 vectors + CRLF guard). All 15
ctest targets pass. README updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:49:03 +02:00

435 lines
17 KiB
C++

// 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/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 <cstdio>
#include <map>
#include <memory>
#include <string>
#include <utility>
#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<MockTemporalDto> {
std::map<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.second->entity_id && std::string(*kv.second->entity_id) == 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[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<InMemoryAllRows>();
auto clock = std::make_shared<StepClock>();
auto ids = std::make_shared<SeqIdGen>();
TemporalRepository<MockTemporalDto> 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<MockTemporalDto>::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<MockTemporalDto>::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<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->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<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; },
[](const oatpp::Object<MockTemporalDto>& 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<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;
}
static oatpp::String entityIdOf(const oatpp::Object<MockTemporalDto>& 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<InMemoryAllRows>();
// 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<MockTemporalDto> 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<MockTemporalDto> {
std::map<std::string, oatpp::Object<MockTemporalDto>> rows;
public:
oatpp::Object<MockTemporalDto> 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<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[std::string(*dto->id)] = dto; }
void softDelete(const oatpp::String&) override {}
oatpp::Vector<oatpp::Object<MockTemporalDto>>
query(const oatpp_authkit::repo::Query<MockTemporalDto>&) 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<InMemoryQueryable>();
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<MockTemporalDto> guarded(
inner, &scopeAllows, [actor]{ return actor; }, &entityIdOf);
auto result = guarded.query(Query<MockTemporalDto>{});
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;
}