Closes #11 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
334 lines
12 KiB
C++
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;
|
|
}
|