- 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>
435 lines
17 KiB
C++
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;
|
|
}
|