// Tests for the oatpp-authkit#11 AuditLogRepository decorator. // // Verifies the audit decorator emits events with the right shape, picks // Create vs Update via a pre-write lookup, swallows sink failures by // default, respects the configurable enabled-op set, and stacks correctly // with TemporalRepository. #include "oatpp-authkit/repo/AuditLogRepository.hpp" #include "oatpp-authkit/repo/Repository.hpp" #include "oatpp-authkit/repo/IAuditSink.hpp" #include "oatpp-authkit/repo/ActorContext.hpp" #include "oatpp-authkit/repo/TemporalFieldTraits.hpp" #include "oatpp-authkit/repo/TemporalRepository.hpp" #include "oatpp/core/macro/codegen.hpp" #include "oatpp/core/Types.hpp" #include #include #include #include #include #include #include #include OATPP_CODEGEN_BEGIN(DTO) namespace { class AuditDto : public oatpp::DTO { DTO_INIT(AuditDto, DTO) DTO_FIELD(String, id); // per-row PK DTO_FIELD(String, entity_id); DTO_FIELD(String, valid_from); DTO_FIELD(String, valid_until); DTO_FIELD(String, name); }; #include OATPP_CODEGEN_END(DTO) } // namespace OATPP_AUTHKIT_REGISTER_TEMPORAL(AuditDto, 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) using namespace oatpp_authkit::repo; // ─── Helpers ──────────────────────────────────────────────────────────────── struct VectorSink : public IAuditSink { std::vector events; void record(const AuditEvent& ev) override { events.push_back(ev); } }; struct ThrowingSink : public IAuditSink { int calls{0}; void record(const AuditEvent&) override { ++calls; throw std::runtime_error("sink down"); } }; class InMemoryAdapter : public Repository { std::map> rows; int nextId{1}; public: int saveCalls{0}, deleteCalls{0}, findCalls{0}; oatpp::Object findByEntityId(const oatpp::String& id) override { ++findCalls; auto it = rows.find(std::string(*id)); return it == rows.end() ? nullptr : it->second; } oatpp::Vector> list() override { auto v = oatpp::Vector>::createShared(); for (auto& kv : rows) v->push_back(kv.second); return v; } void save(const oatpp::Object& dto) override { ++saveCalls; if (!dto->entity_id) { dto->entity_id = oatpp::String("auto-" + std::to_string(nextId++)); } rows[std::string(*dto->entity_id)] = dto; } void softDelete(const oatpp::String& id) override { ++deleteCalls; rows.erase(std::string(*id)); } }; ActorContext alice() { ActorContext a; a.userId = "alice"; return a; } struct StepClock { std::int64_t ms{1700000000000LL}; std::int64_t operator()() { auto v = ms; ms += 1000; return v; } }; // ─── Tests ────────────────────────────────────────────────────────────────── void test_save_with_null_id_emits_create() { auto inner = std::make_shared(); auto sink = std::make_shared(); auto clk = std::make_shared(); AuditLogRepository audit(inner, sink, alice, "AuditDto", {AuditOp::Create, AuditOp::Update, AuditOp::Delete}, [clk]{ return (*clk)(); }); auto dto = AuditDto::createShared(); dto->name = oatpp::String("first"); audit.save(dto); REQUIRE(sink->events.size() == 1); REQUIRE(sink->events[0].op == AuditOp::Create); REQUIRE(sink->events[0].actorUserId == "alice"); REQUIRE(sink->events[0].entityType == "AuditDto"); REQUIRE(!sink->events[0].entityId.empty()); // inner allocated id REQUIRE(sink->events[0].timestampMs > 0); // No pre-write lookup when id was null on entry. REQUIRE(inner->findCalls == 0); } void test_save_with_existing_id_emits_update() { auto inner = std::make_shared(); auto sink = std::make_shared(); AuditLogRepository audit(inner, sink, alice, "AuditDto"); auto v1 = AuditDto::createShared(); v1->entity_id = oatpp::String("abc"); v1->name = oatpp::String("v1"); audit.save(v1); REQUIRE(sink->events.back().op == AuditOp::Create); auto v2 = AuditDto::createShared(); v2->entity_id = oatpp::String("abc"); v2->name = oatpp::String("v2"); audit.save(v2); REQUIRE(sink->events.back().op == AuditOp::Update); REQUIRE(sink->events.back().entityId == "abc"); } void test_save_with_caller_id_but_no_row_is_create() { auto inner = std::make_shared(); auto sink = std::make_shared(); AuditLogRepository audit(inner, sink, alice, "AuditDto"); auto dto = AuditDto::createShared(); dto->entity_id = oatpp::String("brand-new"); dto->name = oatpp::String("first"); audit.save(dto); REQUIRE(sink->events.size() == 1); REQUIRE(sink->events[0].op == AuditOp::Create); REQUIRE(sink->events[0].entityId == "brand-new"); } void test_soft_delete_emits_delete() { auto inner = std::make_shared(); auto sink = std::make_shared(); AuditLogRepository audit(inner, sink, alice, "AuditDto"); auto dto = AuditDto::createShared(); dto->entity_id = oatpp::String("xyz"); audit.save(dto); audit.softDelete(oatpp::String("xyz")); REQUIRE(sink->events.size() == 2); REQUIRE(sink->events[1].op == AuditOp::Delete); REQUIRE(sink->events[1].entityId == "xyz"); } void test_read_only_audited_when_enabled() { auto inner = std::make_shared(); auto sink = std::make_shared(); AuditLogRepository audit(inner, sink, alice, "AuditDto", {AuditOp::Create, AuditOp::Read}); // explicit opt-in for Read auto dto = AuditDto::createShared(); dto->entity_id = oatpp::String("rid"); audit.save(dto); REQUIRE(sink->events.size() == 1); REQUIRE(sink->events[0].op == AuditOp::Create); (void)audit.findByEntityId(oatpp::String("rid")); REQUIRE(sink->events.size() == 2); REQUIRE(sink->events[1].op == AuditOp::Read); REQUIRE(sink->events[1].entityId == "rid"); // Read on a miss still emits, with the requested id. (void)audit.findByEntityId(oatpp::String("missing")); REQUIRE(sink->events.size() == 3); REQUIRE(sink->events[2].op == AuditOp::Read); REQUIRE(sink->events[2].entityId == "missing"); } void test_default_does_not_audit_reads() { auto inner = std::make_shared(); auto sink = std::make_shared(); AuditLogRepository audit(inner, sink, alice, "AuditDto"); auto dto = AuditDto::createShared(); dto->entity_id = oatpp::String("rid"); audit.save(dto); (void)audit.findByEntityId(oatpp::String("rid")); REQUIRE(sink->events.size() == 1); // only the save REQUIRE(sink->events[0].op == AuditOp::Create); } void test_list_is_never_audited() { auto inner = std::make_shared(); auto sink = std::make_shared(); AuditLogRepository audit(inner, sink, alice, "AuditDto", {AuditOp::Create, AuditOp::Update, AuditOp::Delete, AuditOp::Read}); for (int i = 0; i < 3; ++i) { auto d = AuditDto::createShared(); d->entity_id = oatpp::String("id-" + std::to_string(i)); audit.save(d); } sink->events.clear(); auto rows = audit.list(); REQUIRE(rows->size() == 3); REQUIRE(sink->events.empty()); // list is opaque to audit } void test_filter_skips_disabled_ops() { auto inner = std::make_shared(); auto sink = std::make_shared(); AuditLogRepository audit(inner, sink, alice, "AuditDto", {AuditOp::Delete}); // only deletes auto dto = AuditDto::createShared(); dto->entity_id = oatpp::String("id"); audit.save(dto); REQUIRE(sink->events.empty()); // Create filtered out audit.softDelete(oatpp::String("id")); REQUIRE(sink->events.size() == 1); REQUIRE(sink->events[0].op == AuditOp::Delete); } void test_sink_throw_swallowed_by_default() { auto inner = std::make_shared(); auto sink = std::make_shared(); AuditLogRepository audit(inner, sink, alice, "AuditDto"); auto dto = AuditDto::createShared(); dto->entity_id = oatpp::String("id"); bool threw = false; try { audit.save(dto); } catch (...) { threw = true; } REQUIRE(!threw); REQUIRE(sink->calls == 1); REQUIRE(inner->saveCalls == 1); // inner write still happened } void test_sink_throw_rethrows_when_handler_says_so() { auto inner = std::make_shared(); auto sink = std::make_shared(); AuditLogRepository audit( inner, sink, alice, "AuditDto", {AuditOp::Create, AuditOp::Update, AuditOp::Delete}, {}, [](const std::exception&) { return true; }); auto dto = AuditDto::createShared(); dto->entity_id = oatpp::String("id"); bool threw = false; try { audit.save(dto); } catch (const std::runtime_error&) { threw = true; } REQUIRE(threw); } void test_stacks_with_temporal_repository() { // Audit ↔ Temporal: the audit decorator wraps the temporal one. Each // logical save the consumer issues should produce exactly one audit // event, even though the temporal layer may insert/update multiple // physical rows beneath it. auto adapter = std::make_shared(); auto temporal = std::make_shared>(adapter); auto sink = std::make_shared(); AuditLogRepository audit(temporal, sink, alice, "AuditDto"); auto v1 = AuditDto::createShared(); v1->name = oatpp::String("first"); audit.save(v1); REQUIRE(sink->events.size() == 1); REQUIRE(sink->events[0].op == AuditOp::Create); auto v2 = AuditDto::createShared(); v2->entity_id = v1->entity_id; v2->name = oatpp::String("second"); audit.save(v2); REQUIRE(sink->events.size() == 2); REQUIRE(sink->events[1].op == AuditOp::Update); REQUIRE(sink->events[1].entityId == std::string(*v1->entity_id)); audit.softDelete(v1->entity_id); REQUIRE(sink->events.size() == 3); REQUIRE(sink->events[2].op == AuditOp::Delete); } } // namespace int main() { test_save_with_null_id_emits_create(); test_save_with_existing_id_emits_update(); test_save_with_caller_id_but_no_row_is_create(); test_soft_delete_emits_delete(); test_read_only_audited_when_enabled(); test_default_does_not_audit_reads(); test_list_is_never_audited(); test_filter_skips_disabled_ops(); test_sink_throw_swallowed_by_default(); test_sink_throw_rethrows_when_handler_says_so(); test_stacks_with_temporal_repository(); std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures); return g_failures ? 1 : 0; }