#11: AuditLogRepository<T> + IAuditSink — cross-cutting audit decorator
Closes #11 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1baff07b71
commit
c6a2dba22b
6 changed files with 576 additions and 1 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
cmake_minimum_required(VERSION 3.14)
|
cmake_minimum_required(VERSION 3.14)
|
||||||
project(oatpp-authkit VERSION 0.5.0 LANGUAGES CXX)
|
project(oatpp-authkit VERSION 0.6.0 LANGUAGES CXX)
|
||||||
|
|
||||||
# Header-only interface library — no compilation, just an include path and
|
# Header-only interface library — no compilation, just an include path and
|
||||||
# a CMake config package so consumers do:
|
# a CMake config package so consumers do:
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ hardened auth / security stack. Header-only, oatpp 1.3+, C++17.
|
||||||
| `repo/TemporalRepository.hpp` | Decorator that wraps any `Repository<TDto>` and turns it into a temporally-versioned one. `save` closes the prior live version and inserts a new one; `findByEntityIdAt(id, at)` returns the version live at a point in time; implements `IHistoryRepository<T>`. Inner adapter is expected to expose all rows (live + historical) and treat `save` as upsert keyed by `(entity_id, valid_from)`. DTOs register their three temporal columns via `OATPP_AUTHKIT_REGISTER_TEMPORAL`. |
|
| `repo/TemporalRepository.hpp` | Decorator that wraps any `Repository<TDto>` and turns it into a temporally-versioned one. `save` closes the prior live version and inserts a new one; `findByEntityIdAt(id, at)` returns the version live at a point in time; implements `IHistoryRepository<T>`. Inner adapter is expected to expose all rows (live + historical) and treat `save` as upsert keyed by `(entity_id, valid_from)`. DTOs register their three temporal columns via `OATPP_AUTHKIT_REGISTER_TEMPORAL`. |
|
||||||
| `repo/ScopeGuardRepository.hpp` | Generic resource-scope decorator. Takes a `bool(ActorContext, TDto)` predicate at construction; gates every method on it. Throws `ScopeDeniedException` on deny (catchers translate to 403). Knows nothing about consumer-specific concepts like "property" or "tenant" — the predicate decides. |
|
| `repo/ScopeGuardRepository.hpp` | Generic resource-scope decorator. Takes a `bool(ActorContext, TDto)` predicate at construction; gates every method on it. Throws `ScopeDeniedException` on deny (catchers translate to 403). Knows nothing about consumer-specific concepts like "property" or "tenant" — the predicate decides. |
|
||||||
| `repo/IQueryable.hpp` | Optional capability for repos that resolve a typed query AST. `field<&Dto::col>().eq(...)` style DSL composes via `&&` / `||` / `!`; `Query<TDto>::toSql()` emits parameterised SQL plus a bind bag. Bounded surface — equality, range, IN, LIKE, NULL, ORDER BY, LIMIT/OFFSET. No joins, subqueries, or aggregates. Concrete repos opt in by deriving `IQueryable<TDto>`. |
|
| `repo/IQueryable.hpp` | Optional capability for repos that resolve a typed query AST. `field<&Dto::col>().eq(...)` style DSL composes via `&&` / `||` / `!`; `Query<TDto>::toSql()` emits parameterised SQL plus a bind bag. Bounded surface — equality, range, IN, LIKE, NULL, ORDER BY, LIMIT/OFFSET. No joins, subqueries, or aggregates. Concrete repos opt in by deriving `IQueryable<TDto>`. |
|
||||||
|
| `repo/IAuditSink.hpp` + `repo/AuditLogRepository.hpp` | Cross-cutting audit-trail decorator. Emits an `AuditEvent` (actor, entity type/id, op, timestamp) per mutation through a consumer-supplied `IAuditSink`. Ops are `Create` / `Update` / `Delete` / `Read`; pre-write `findByEntityId` lookup distinguishes Create from Update. Configurable enabled-op set (default `{Create,Update,Delete}` — `Read` is opt-in, `list()` never audited). Sink failures are caught and swallowed unless a `bool(const std::exception&)` handler asks to rethrow. Stacks with `TemporalRepository` and `ScopeGuardRepository`. |
|
||||||
|
|
||||||
## Consume via CMake
|
## Consume via CMake
|
||||||
|
|
||||||
|
|
|
||||||
171
include/oatpp-authkit/repo/AuditLogRepository.hpp
Normal file
171
include/oatpp-authkit/repo/AuditLogRepository.hpp
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
#ifndef OATPP_AUTHKIT_REPO_AUDIT_LOG_REPOSITORY_HPP
|
||||||
|
#define OATPP_AUTHKIT_REPO_AUDIT_LOG_REPOSITORY_HPP
|
||||||
|
|
||||||
|
// Cross-cutting audit-trail decorator (authkit#11). Emits an `AuditEvent`
|
||||||
|
// through a consumer-supplied `IAuditSink` on every audited operation.
|
||||||
|
// Composes naturally with `ScopeGuardRepository` and `TemporalRepository`
|
||||||
|
// — all three accept an `ActorContext` accessor and stack via the same
|
||||||
|
// `Repository<T>` interface.
|
||||||
|
|
||||||
|
#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/core/Types.hpp"
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <exception>
|
||||||
|
#include <functional>
|
||||||
|
#include <memory>
|
||||||
|
#include <set>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
namespace oatpp_authkit::repo {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Decorator that audits every mutation flowing through a repository.
|
||||||
|
*
|
||||||
|
* @section semantics Per-method behaviour
|
||||||
|
*
|
||||||
|
* - `save(dto)`: if `entity_id` is null, the inner is about to allocate
|
||||||
|
* one — operation is `Create`. If `entity_id` is non-null, the decorator
|
||||||
|
* performs a one-shot `findByEntityId` on the inner *before* delegating;
|
||||||
|
* a hit means `Update`, a miss means `Create` (caller-supplied id, no
|
||||||
|
* row yet). Inner is then called; on success a single event is recorded.
|
||||||
|
* - `softDelete(id)`: delegated first; on success a single `Delete` event
|
||||||
|
* is recorded.
|
||||||
|
* - `findByEntityId(id)`: delegated first; if `AuditOp::Read` is in the
|
||||||
|
* enabled set, a single `Read` event is recorded with the entity id of
|
||||||
|
* the row that came back (or the requested id on miss — both are
|
||||||
|
* useful for compliance traces).
|
||||||
|
* - `list()`: passed through unchanged. Lists are scans; emitting one
|
||||||
|
* event per row is noisy and emitting a single event with no entity id
|
||||||
|
* is half-information. Out of scope for this decorator.
|
||||||
|
*
|
||||||
|
* @section robustness Sink failures
|
||||||
|
*
|
||||||
|
* `IAuditSink::record` is called inside a `try/catch`. By default the
|
||||||
|
* exception is swallowed — audit logging is best-effort and must not
|
||||||
|
* break the user's write path. Pass `sinkErrorHandler(...)` (or supply
|
||||||
|
* the optional last constructor arg) to override; the handler returns
|
||||||
|
* `true` to rethrow, `false` to swallow.
|
||||||
|
*
|
||||||
|
* `entityId` for `save` events is read through
|
||||||
|
* `TemporalFieldTraits<TDto>::entityId`, so the decorator works for any
|
||||||
|
* DTO that registered the trait via `OATPP_AUTHKIT_REGISTER_TEMPORAL`.
|
||||||
|
*/
|
||||||
|
template <typename TDto>
|
||||||
|
class AuditLogRepository : public Repository<TDto> {
|
||||||
|
public:
|
||||||
|
using ActorAccess = std::function<ActorContext()>;
|
||||||
|
using Clock = std::function<std::int64_t()>;
|
||||||
|
using SinkErrorHandler = std::function<bool(const std::exception&)>;
|
||||||
|
|
||||||
|
AuditLogRepository(std::shared_ptr<Repository<TDto>> inner,
|
||||||
|
std::shared_ptr<IAuditSink> sink,
|
||||||
|
ActorAccess currentActor,
|
||||||
|
std::string entityType,
|
||||||
|
std::set<AuditOp> enabledOps =
|
||||||
|
{AuditOp::Create, AuditOp::Update, AuditOp::Delete},
|
||||||
|
Clock clock = {},
|
||||||
|
SinkErrorHandler onSinkError = {})
|
||||||
|
: m_inner(std::move(inner))
|
||||||
|
, m_sink(std::move(sink))
|
||||||
|
, m_currentActor(std::move(currentActor))
|
||||||
|
, m_entityType(std::move(entityType))
|
||||||
|
, m_enabledOps(std::move(enabledOps))
|
||||||
|
, m_clock(clock ? std::move(clock) : defaultClock())
|
||||||
|
, m_onSinkError(std::move(onSinkError))
|
||||||
|
{}
|
||||||
|
|
||||||
|
oatpp::Object<TDto> findByEntityId(const oatpp::String& entityId) override {
|
||||||
|
auto row = m_inner->findByEntityId(entityId);
|
||||||
|
if (m_enabledOps.count(AuditOp::Read)) {
|
||||||
|
// On miss, fall back to the requested id — still useful for
|
||||||
|
// compliance. On hit, prefer the id stored on the row.
|
||||||
|
std::string id = entityId ? std::string(*entityId) : std::string();
|
||||||
|
if (row) {
|
||||||
|
auto& rowId = TemporalFieldTraits<TDto>::entityId(row);
|
||||||
|
if (rowId) id = std::string(*rowId);
|
||||||
|
}
|
||||||
|
emit(AuditOp::Read, id);
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
oatpp::Vector<oatpp::Object<TDto>> list() override {
|
||||||
|
return m_inner->list(); // intentionally unaudited — see header doc
|
||||||
|
}
|
||||||
|
|
||||||
|
void save(const oatpp::Object<TDto>& dto) override {
|
||||||
|
const AuditOp op = classifySave(dto);
|
||||||
|
m_inner->save(dto);
|
||||||
|
if (m_enabledOps.count(op)) {
|
||||||
|
std::string id;
|
||||||
|
auto& field = TemporalFieldTraits<TDto>::entityId(dto);
|
||||||
|
if (field) id = std::string(*field);
|
||||||
|
emit(op, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void softDelete(const oatpp::String& entityId) override {
|
||||||
|
m_inner->softDelete(entityId);
|
||||||
|
if (m_enabledOps.count(AuditOp::Delete)) {
|
||||||
|
emit(AuditOp::Delete, entityId ? std::string(*entityId) : std::string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
AuditOp classifySave(const oatpp::Object<TDto>& dto) {
|
||||||
|
auto& id = TemporalFieldTraits<TDto>::entityId(dto);
|
||||||
|
if (!id) return AuditOp::Create; // inner will allocate the id
|
||||||
|
// Caller-supplied id: distinguish Create-with-id vs Update.
|
||||||
|
return m_inner->findByEntityId(id) ? AuditOp::Update : AuditOp::Create;
|
||||||
|
}
|
||||||
|
|
||||||
|
void emit(AuditOp op, std::string entityId) {
|
||||||
|
AuditEvent ev;
|
||||||
|
ev.entityType = m_entityType;
|
||||||
|
ev.entityId = std::move(entityId);
|
||||||
|
ev.op = op;
|
||||||
|
ev.timestampMs = m_clock();
|
||||||
|
try {
|
||||||
|
ev.actorUserId = m_currentActor().userId;
|
||||||
|
} catch (...) {
|
||||||
|
// Actor accessor failure shouldn't break the write path either.
|
||||||
|
ev.actorUserId.clear();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
m_sink->record(ev);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
if (m_onSinkError && m_onSinkError(e)) throw;
|
||||||
|
// else: swallow — audit logging is best-effort.
|
||||||
|
} catch (...) {
|
||||||
|
// Non-std::exception — always swallow; the handler signature
|
||||||
|
// takes std::exception&, so we cannot route it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Clock defaultClock() {
|
||||||
|
return [] {
|
||||||
|
using namespace std::chrono;
|
||||||
|
return duration_cast<milliseconds>(
|
||||||
|
system_clock::now().time_since_epoch()).count();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::shared_ptr<Repository<TDto>> m_inner;
|
||||||
|
std::shared_ptr<IAuditSink> m_sink;
|
||||||
|
ActorAccess m_currentActor;
|
||||||
|
std::string m_entityType;
|
||||||
|
std::set<AuditOp> m_enabledOps;
|
||||||
|
Clock m_clock;
|
||||||
|
SinkErrorHandler m_onSinkError;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace oatpp_authkit::repo
|
||||||
|
|
||||||
|
#endif
|
||||||
65
include/oatpp-authkit/repo/IAuditSink.hpp
Normal file
65
include/oatpp-authkit/repo/IAuditSink.hpp
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
#ifndef OATPP_AUTHKIT_REPO_I_AUDIT_SINK_HPP
|
||||||
|
#define OATPP_AUTHKIT_REPO_I_AUDIT_SINK_HPP
|
||||||
|
|
||||||
|
// Cross-cutting audit primitive used by `AuditLogRepository<T>` (authkit#11).
|
||||||
|
// The decorator emits an `AuditEvent` per mutation (and optionally per
|
||||||
|
// single-entity read) through an `IAuditSink` the consumer supplies.
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace oatpp_authkit::repo {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief What kind of operation produced the audit event.
|
||||||
|
*
|
||||||
|
* Reflects intent, not the inner method name — `softDelete` and a
|
||||||
|
* hypothetical hard delete both surface as `Delete`. `Read` covers
|
||||||
|
* single-entity lookups (`findByEntityId`) only; `list()` is intentionally
|
||||||
|
* not audited because it is a scan, not a per-entity access.
|
||||||
|
*/
|
||||||
|
enum class AuditOp { Create, Update, Delete, Read };
|
||||||
|
|
||||||
|
inline const char* toString(AuditOp op) {
|
||||||
|
switch (op) {
|
||||||
|
case AuditOp::Create: return "Create";
|
||||||
|
case AuditOp::Update: return "Update";
|
||||||
|
case AuditOp::Delete: return "Delete";
|
||||||
|
case AuditOp::Read: return "Read";
|
||||||
|
}
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Audit record emitted on every audited operation.
|
||||||
|
*
|
||||||
|
* `entityType` is supplied by the decorator's owner at construction time
|
||||||
|
* (typeid is unportable, and consumers usually have a stable string they
|
||||||
|
* already use elsewhere — table name, DTO name, etc.).
|
||||||
|
*/
|
||||||
|
struct AuditEvent {
|
||||||
|
std::string actorUserId;
|
||||||
|
std::string entityType;
|
||||||
|
std::string entityId;
|
||||||
|
AuditOp op{AuditOp::Read};
|
||||||
|
std::int64_t timestampMs{0};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Where audit events go. Consumer-supplied.
|
||||||
|
*
|
||||||
|
* Implementations are typically a database insert (fewo-webapp's plan: an
|
||||||
|
* `audit_log` table behind a sqlite-backed sink) or, in tests, a vector
|
||||||
|
* append. Sink failures should not break the user's write path —
|
||||||
|
* `AuditLogRepository<T>` catches exceptions thrown from `record` and
|
||||||
|
* routes them through a configurable error callback.
|
||||||
|
*/
|
||||||
|
class IAuditSink {
|
||||||
|
public:
|
||||||
|
virtual ~IAuditSink() = default;
|
||||||
|
virtual void record(const AuditEvent& ev) = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace oatpp_authkit::repo
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
@ -37,3 +37,7 @@ add_test(NAME queryable COMMAND test_queryable)
|
||||||
add_executable(test_temporal_field_traits test_temporal_field_traits.cpp)
|
add_executable(test_temporal_field_traits test_temporal_field_traits.cpp)
|
||||||
target_link_libraries(test_temporal_field_traits PRIVATE oatpp::authkit oatpp::oatpp)
|
target_link_libraries(test_temporal_field_traits PRIVATE oatpp::authkit oatpp::oatpp)
|
||||||
add_test(NAME temporal_field_traits COMMAND test_temporal_field_traits)
|
add_test(NAME temporal_field_traits COMMAND test_temporal_field_traits)
|
||||||
|
|
||||||
|
add_executable(test_audit_log_repository test_audit_log_repository.cpp)
|
||||||
|
target_link_libraries(test_audit_log_repository PRIVATE oatpp::authkit oatpp::oatpp)
|
||||||
|
add_test(NAME audit_log_repository COMMAND test_audit_log_repository)
|
||||||
|
|
|
||||||
334
test/test_audit_log_repository.cpp
Normal file
334
test/test_audit_log_repository.cpp
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue