oatpp-authkit/test/test_audit_log_repository.cpp
Uwe Schuster c6a2dba22b #11: AuditLogRepository<T> + IAuditSink — cross-cutting audit decorator
Closes #11

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:54:11 +02:00

334 lines
12 KiB
C++

// Tests for the oatpp-authkit#11 AuditLogRepository<T> 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 <cstdio>
#include <map>
#include <memory>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
#include OATPP_CODEGEN_BEGIN(DTO)
namespace {
class AuditDto : public oatpp::DTO {
DTO_INIT(AuditDto, DTO)
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, 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<AuditEvent> 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<AuditDto> {
std::map<std::string, oatpp::Object<AuditDto>> rows;
int nextId{1};
public:
int saveCalls{0}, deleteCalls{0}, findCalls{0};
oatpp::Object<AuditDto> findByEntityId(const oatpp::String& id) override {
++findCalls;
auto it = rows.find(std::string(*id));
return it == rows.end() ? nullptr : it->second;
}
oatpp::Vector<oatpp::Object<AuditDto>> list() override {
auto v = oatpp::Vector<oatpp::Object<AuditDto>>::createShared();
for (auto& kv : rows) v->push_back(kv.second);
return v;
}
void save(const oatpp::Object<AuditDto>& 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<InMemoryAdapter>();
auto sink = std::make_shared<VectorSink>();
auto clk = std::make_shared<StepClock>();
AuditLogRepository<AuditDto> 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<InMemoryAdapter>();
auto sink = std::make_shared<VectorSink>();
AuditLogRepository<AuditDto> 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<InMemoryAdapter>();
auto sink = std::make_shared<VectorSink>();
AuditLogRepository<AuditDto> 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<InMemoryAdapter>();
auto sink = std::make_shared<VectorSink>();
AuditLogRepository<AuditDto> 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<InMemoryAdapter>();
auto sink = std::make_shared<VectorSink>();
AuditLogRepository<AuditDto> 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<InMemoryAdapter>();
auto sink = std::make_shared<VectorSink>();
AuditLogRepository<AuditDto> 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<InMemoryAdapter>();
auto sink = std::make_shared<VectorSink>();
AuditLogRepository<AuditDto> 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<InMemoryAdapter>();
auto sink = std::make_shared<VectorSink>();
AuditLogRepository<AuditDto> 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<InMemoryAdapter>();
auto sink = std::make_shared<ThrowingSink>();
AuditLogRepository<AuditDto> 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<InMemoryAdapter>();
auto sink = std::make_shared<ThrowingSink>();
AuditLogRepository<AuditDto> 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<InMemoryAdapter>();
auto temporal = std::make_shared<TemporalRepository<AuditDto>>(adapter);
auto sink = std::make_shared<VectorSink>();
AuditLogRepository<AuditDto> 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;
}