diff --git a/CMakeLists.txt b/CMakeLists.txt index f53245f..b3f7577 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.14) -project(oatpp-authkit VERSION 0.6.1 LANGUAGES CXX) +project(oatpp-authkit VERSION 0.7.0 LANGUAGES CXX) # Header-only interface library — no compilation, just an include path and # a CMake config package so consumers do: diff --git a/README.md b/README.md index 8634f36..313c58e 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,32 @@ hardened auth / security stack. Header-only, oatpp 1.3+, C++17. | `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::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`. | | `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`. | +| `repo/Prereq.hpp` | Per-decorator migration kit. Each decorator that touches schema bundles its prereq SQL alongside its code: `DecoratorPrereq` for additive (`CREATE TABLE IF NOT EXISTS …`) and `ReshapeStep` for non-idempotent reshape with a `detectSql` probe. `applyDecoratorMigrations(table, probe, exec, recorder)` walks the listed decorators at startup, runs every PREREQ, runs every reshape step whose probe returns false, optionally records applied steps via `oatpp_authkit_schema_migrations`. Database-agnostic — consumer wires `probe`/`exec` to whatever DbClient they use. | + +## Decorator migrations + +| Decorator | `PREREQ` | `RESHAPE_STEPS` | +|-----------|----------|------------------| +| `TemporalRepository` | (none) | `add_valid_from`, `add_valid_until`, `drop_unique_entity_id` (consumer-overridable noop), `composite_unique` — composite `UNIQUE(entity_id, valid_until)` so close-then-insert can run inside a deferred-FK transaction. | +| `AuditLogRepository` | `CREATE TABLE IF NOT EXISTS audit_log (…)` — fixed shape, no `{table}` placeholder. | (none) | +| `ScopeGuardRepository` | (none) | (none) | + +Wiring it up: + +```cpp +#include "oatpp-authkit/repo/Prereq.hpp" + +// probe: returns true iff the SQL yields ≥1 row +auto probe = [&](const std::string& sql) { /* run SELECT, return bool */ }; +auto exec = [&](const std::string& sql) { /* run DDL */ }; + +oatpp_authkit::repo::applyDecoratorMigrations< + oatpp_authkit::repo::TemporalRepository, + oatpp_authkit::repo::AuditLogRepository>( + "persons", probe, exec); +``` + +Re-running on every startup is safe by construction. ## Consume via CMake diff --git a/include/oatpp-authkit/repo/AuditLogRepository.hpp b/include/oatpp-authkit/repo/AuditLogRepository.hpp index f6407ab..57f8523 100644 --- a/include/oatpp-authkit/repo/AuditLogRepository.hpp +++ b/include/oatpp-authkit/repo/AuditLogRepository.hpp @@ -11,6 +11,7 @@ #include "oatpp-authkit/repo/IAuditSink.hpp" #include "oatpp-authkit/repo/ActorContext.hpp" #include "oatpp-authkit/repo/TemporalFieldTraits.hpp" +#include "oatpp-authkit/repo/Prereq.hpp" #include "oatpp/core/Types.hpp" @@ -64,6 +65,21 @@ public: using Clock = std::function; using SinkErrorHandler = std::function; + /// Decorator-local migration kit (authkit#12). + /// `{table}` is unused — the audit_log table is fixed across consumers. + static constexpr const char* DECORATOR_NAME = "AuditLogRepository"; + static constexpr DecoratorPrereq PREREQ = { + "CREATE TABLE IF NOT EXISTS audit_log (" + " id INTEGER PRIMARY KEY AUTOINCREMENT," + " actor_user_id TEXT," + " entity_type TEXT NOT NULL," + " entity_id TEXT NOT NULL," + " op TEXT NOT NULL," + " timestamp_ms INTEGER NOT NULL" + ")" + }; + static constexpr std::array RESHAPE_STEPS = {}; + AuditLogRepository(std::shared_ptr> inner, std::shared_ptr sink, ActorAccess currentActor, diff --git a/include/oatpp-authkit/repo/Prereq.hpp b/include/oatpp-authkit/repo/Prereq.hpp new file mode 100644 index 0000000..2502bb6 --- /dev/null +++ b/include/oatpp-authkit/repo/Prereq.hpp @@ -0,0 +1,174 @@ +#ifndef OATPP_AUTHKIT_REPO_PREREQ_HPP +#define OATPP_AUTHKIT_REPO_PREREQ_HPP + +// Per-decorator migration kit (authkit#12). Each decorator that touches a +// schema bundles its prereq SQL alongside its code, so the consumer's +// startup wiring is "stack the decorators, run the migrations" — no hand- +// pasted DDL, no drift between header doc-comments and the actual schema. +// +// Two migration kinds, both decorator-local, both idempotent: +// +// 1. Additive prereqs — `PREREQ` (a `DecoratorPrereq`). For decorators +// that need extra tables/indexes alongside the entity table. Pure +// `CREATE TABLE IF NOT EXISTS …` style; re-running is a no-op by +// construction. +// +// 2. Reshape migrations — `RESHAPE_STEPS` (a `std::array`). +// For decorators that change the entity's own table shape. Naively +// non-idempotent (`ALTER TABLE … ADD COLUMN …` errors on second run), +// so each step ships a `detectSql` probe that returns true iff the +// step has already been applied. The runner skips applied steps. +// +// The runner is `applyDecoratorMigrations(table, probe, exec)`. +// It runs every decorator's PREREQ then its RESHAPE_STEPS in declaration +// order. Both `PREREQ.sql` and `ReshapeStep.{detectSql,applySql}` may +// contain `{table}` placeholders that the runner substitutes. + +#include +#include +#include +#include +#include + +namespace oatpp_authkit::repo { + +/** + * @brief Bookkeeping table for applied migration steps. Optional — + * the detect-probe is the source of truth, so the runner is safe even + * if this table is wiped between invocations. The schema is exposed + * for consumers that want observability ("which steps ran when"). + */ +constexpr const char* SCHEMA_MIGRATIONS_TABLE_SQL = + "CREATE TABLE IF NOT EXISTS oatpp_authkit_schema_migrations (" + " decorator TEXT NOT NULL," + " table_name TEXT NOT NULL," + " step TEXT NOT NULL," + " applied_at TEXT NOT NULL," + " PRIMARY KEY (decorator, table_name, step)" + ")"; + +/** + * @brief Additive schema prereq for one decorator. Empty `sql` means the + * decorator declares no additive prereq (it's still required to expose + * `PREREQ` so the runner doesn't need SFINAE). May contain `{table}` + * which the runner substitutes — most additive prereqs name fixed tables + * (e.g. `audit_log`) and won't use the placeholder. + */ +struct DecoratorPrereq { + const char* sql{""}; +}; + +/** + * @brief One step of a reshape migration. The runner calls `probe(detectSql)` + * first; if it returns true the step is treated as already applied and + * skipped. Otherwise `exec(applySql)` runs and the step is recorded. + * + * Both `detectSql` and `applySql` may contain `{table}` placeholders. + * + * `name` is a stable identifier — it's what gets recorded in + * `oatpp_authkit_schema_migrations` and what tests assert against. + */ +struct ReshapeStep { + const char* name{""}; + const char* detectSql{""}; + const char* applySql{""}; +}; + +/** + * @brief Substitutes `{table}` -> `tableName` in `sqlTemplate`. All + * occurrences are replaced. Pure string transform — no SQL parsing. + */ +inline std::string instantiate(std::string_view sqlTemplate, + std::string_view tableName) { + std::string out(sqlTemplate); + static constexpr std::string_view ph = "{table}"; + for (std::size_t pos = out.find(ph); pos != std::string::npos; + pos = out.find(ph, pos + tableName.size())) { + out.replace(pos, ph.size(), tableName); + } + return out; +} + +/** + * @brief Runs a single statement against the consumer's database. The + * consumer wires this to whatever DbClient / ORM they use — the kit stays + * database-agnostic by routing all DDL through these two callbacks. + */ +using SqlExec = std::function; + +/** + * @brief Returns true iff the given query yields at least one row. Used + * by the runner to skip already-applied reshape steps. + */ +using SqlProbe = std::function; + +/** + * @brief Optional callback invoked for every step the runner actually + * applies (skipped steps are not reported). Consumers wire this to an + * INSERT into `oatpp_authkit_schema_migrations` when they want + * observability. + */ +using StepRecorder = std::function; + +namespace detail { +inline bool nonEmpty(const char* s) { return s && s[0] != '\0'; } +} // namespace detail + +/** + * @brief Applies one decorator's migrations (PREREQ + RESHAPE_STEPS) to + * `tableName`. Idempotent: PREREQ is `CREATE … IF NOT EXISTS`-style by + * convention, RESHAPE_STEPS are gated on `probe(detectSql)`. + * + * @tparam Decorator Type exposing `static constexpr const char* DECORATOR_NAME`, + * `static constexpr DecoratorPrereq PREREQ`, and + * `static constexpr std::array RESHAPE_STEPS`. + */ +template +void applyDecoratorMigration(const std::string& tableName, + const SqlProbe& probe, + const SqlExec& exec, + const StepRecorder& record = {}) { + if (detail::nonEmpty(Decorator::PREREQ.sql)) { + exec(instantiate(Decorator::PREREQ.sql, tableName)); + if (record) record(Decorator::DECORATOR_NAME, tableName, "PREREQ_SQL"); + } + for (const auto& step : Decorator::RESHAPE_STEPS) { + if (!detail::nonEmpty(step.applySql)) continue; + if (detail::nonEmpty(step.detectSql) && + probe(instantiate(step.detectSql, tableName))) { + continue; // already applied + } + exec(instantiate(step.applySql, tableName)); + if (record) record(Decorator::DECORATOR_NAME, tableName, step.name); + } +} + +/** + * @brief Variadic convenience: walks every listed decorator and runs its + * migrations against the same `tableName`. Order matches the parameter + * pack. Typical use: + * + * @code + * applyDecoratorMigrations< + * TemporalRepository, + * AuditLogRepository>( + * "persons", probe, exec, recorder); + * @endcode + * + * `ScopeGuardRepository` and other decorators with no schema needs are + * fine to include — they expose an empty PREREQ and zero-length + * RESHAPE_STEPS, which the runner skips cleanly. + */ +template +void applyDecoratorMigrations(const std::string& tableName, + const SqlProbe& probe, + const SqlExec& exec, + const StepRecorder& record = {}) { + (applyDecoratorMigration(tableName, probe, exec, record), ...); +} + +} // namespace oatpp_authkit::repo + +#endif diff --git a/include/oatpp-authkit/repo/ScopeGuardRepository.hpp b/include/oatpp-authkit/repo/ScopeGuardRepository.hpp index e4c9abe..ca3f145 100644 --- a/include/oatpp-authkit/repo/ScopeGuardRepository.hpp +++ b/include/oatpp-authkit/repo/ScopeGuardRepository.hpp @@ -3,6 +3,7 @@ #include "oatpp-authkit/repo/Repository.hpp" #include "oatpp-authkit/repo/ActorContext.hpp" +#include "oatpp-authkit/repo/Prereq.hpp" #include "oatpp/core/Types.hpp" @@ -53,6 +54,14 @@ public: using Predicate = std::function&)>; using ActorAccess = std::function; + /// Decorator-local migration kit (authkit#12). ScopeGuard touches no + /// schema — both PREREQ and RESHAPE_STEPS are empty. Exposed so the + /// migration runner can list ScopeGuard alongside other decorators + /// without SFINAE. + static constexpr const char* DECORATOR_NAME = "ScopeGuardRepository"; + static constexpr DecoratorPrereq PREREQ = {}; + static constexpr std::array RESHAPE_STEPS = {}; + ScopeGuardRepository(std::shared_ptr> inner, Predicate isAllowed, ActorAccess currentActor) diff --git a/include/oatpp-authkit/repo/TemporalRepository.hpp b/include/oatpp-authkit/repo/TemporalRepository.hpp index f767895..740f414 100644 --- a/include/oatpp-authkit/repo/TemporalRepository.hpp +++ b/include/oatpp-authkit/repo/TemporalRepository.hpp @@ -5,6 +5,7 @@ #include "oatpp-authkit/repo/IHistoryRepository.hpp" #include "oatpp-authkit/repo/TemporalFieldTraits.hpp" #include "oatpp-authkit/repo/TemporalAt.hpp" +#include "oatpp-authkit/repo/Prereq.hpp" #include "oatpp/core/Types.hpp" @@ -71,6 +72,38 @@ public: */ static constexpr const char* SENTINEL = "9999-12-31T23:59:59Z"; + /// Decorator-local migration kit (authkit#12). + /// Composite-FK temporal schema: enforces uniqueness on (entity_id, + /// valid_until) so close-then-insert can run inside a transaction. + static constexpr const char* DECORATOR_NAME = "TemporalRepository"; + static constexpr DecoratorPrereq PREREQ = {}; + static constexpr std::array RESHAPE_STEPS = {{ + {"add_valid_from", + "SELECT 1 FROM pragma_table_info('{table}') WHERE name='valid_from'", + "ALTER TABLE {table} ADD COLUMN valid_from TEXT NOT NULL DEFAULT ''"}, + {"add_valid_until", + "SELECT 1 FROM pragma_table_info('{table}') WHERE name='valid_until'", + "ALTER TABLE {table} ADD COLUMN valid_until TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"}, + {"drop_unique_entity_id", + // Detect that no plain UNIQUE(entity_id) index remains. Whether + // one was ever there is consumer-specific — common case is the + // index was auto-named "sqlite_autoindex__1" by SQLite + // for an inline `entity_id TEXT UNIQUE`. Detect by checking that + // no index named `ux_{table}_entity_only` exists *and* that the + // composite index (next step) hasn't been created yet — once the + // composite is in place this step's detect probe must pass too. + "SELECT 1 FROM sqlite_master WHERE type='index' AND tbl_name='{table}' AND name='ux_{table}_entity_valid_until'", + // No-op apply; reshape is owned by the consumer's schema. The + // step exists as a hook for consumers that want to drop a + // legacy unique index before composite_unique runs. Override at + // schema-load time if needed; default is noop on systems where + // the original schema didn't carry a UNIQUE(entity_id). + "SELECT 1"}, + {"composite_unique", + "SELECT 1 FROM sqlite_master WHERE type='index' AND name='ux_{table}_entity_valid_until'", + "CREATE UNIQUE INDEX ux_{table}_entity_valid_until ON {table}(entity_id, valid_until)"} + }}; + using Clock = std::function; ///< Returns milliseconds since epoch. using IdGen = std::function; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index b57105a..a09d420 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -41,3 +41,7 @@ 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) + +add_executable(test_decorator_migrations test_decorator_migrations.cpp) +target_link_libraries(test_decorator_migrations PRIVATE oatpp::authkit oatpp::oatpp) +add_test(NAME decorator_migrations COMMAND test_decorator_migrations) diff --git a/test/test_decorator_migrations.cpp b/test/test_decorator_migrations.cpp new file mode 100644 index 0000000..d744ac8 --- /dev/null +++ b/test/test_decorator_migrations.cpp @@ -0,0 +1,238 @@ +// Tests for authkit#12 — per-decorator migration kit. +// +// Verifies the runner against a tiny in-memory "schema" (a set of applied +// step names + a boolean per "table-has-column" probe). No real SQL +// engine — the kit's contract is to call `probe`/`exec` in the right +// shape, and that's what we test. +// +// Coverage: +// - PREREQ runs once per applyDecoratorMigration call +// - RESHAPE_STEPS skip when probe returns true (already applied) +// - RESHAPE_STEPS apply when probe returns false (fresh) +// - Re-running on a fully-applied schema is a no-op +// - Partial-state recovery: probes mark some steps as applied, runner +// applies only the rest +// - {table} placeholder substitution +// - StepRecorder fires for every applied step, never for skipped +// - ScopeGuardRepository's empty migrations are a clean no-op + +#include "oatpp-authkit/repo/Prereq.hpp" +#include "oatpp-authkit/repo/TemporalRepository.hpp" +#include "oatpp-authkit/repo/AuditLogRepository.hpp" +#include "oatpp-authkit/repo/ScopeGuardRepository.hpp" +#include "oatpp-authkit/repo/TemporalFieldTraits.hpp" + +#include "oatpp/core/macro/codegen.hpp" +#include "oatpp/core/Types.hpp" + +#include +#include +#include +#include +#include + +#define REQUIRE(cond) do { \ + if (!(cond)) { std::fprintf(stderr, "REQUIRE failed: %s @ %s:%d\n", \ + #cond, __FILE__, __LINE__); std::abort(); } } while (0) + +#include OATPP_CODEGEN_BEGIN(DTO) + +namespace { + +class TestDto : public oatpp::DTO { + DTO_INIT(TestDto, DTO) + DTO_FIELD(String, entity_id); + DTO_FIELD(String, valid_from); + DTO_FIELD(String, valid_until); + DTO_FIELD(String, name); +}; + +} // namespace + +#include OATPP_CODEGEN_END(DTO) + +OATPP_AUTHKIT_REGISTER_TEMPORAL(TestDto, entity_id, valid_from, valid_until) + +namespace { + +using namespace oatpp_authkit::repo; + +// Tiny fake schema for the runner to talk to. +struct FakeDb { + std::set appliedDdl; // every exec(sql) lands here + std::set knownTrue; // probe(sql) returns true iff sql is in this set + std::vector execLog; // ordered exec history + + SqlExec exec() { + return [this](const std::string& sql) { + appliedDdl.insert(sql); + execLog.push_back(sql); + }; + } + SqlProbe probe() { + return [this](const std::string& sql) { + return knownTrue.count(sql) > 0; + }; + } +}; + +struct RecordedStep { + std::string decorator; + std::string table; + std::string step; +}; + +StepRecorder makeRecorder(std::vector& out) { + return [&out](const char* d, const std::string& t, const char* s) { + out.push_back({d, t, s}); + }; +} + +// Test 1: instantiate substitutes {table} everywhere. +void test_instantiate() { + REQUIRE(instantiate("SELECT * FROM {table}", "persons") == "SELECT * FROM persons"); + REQUIRE(instantiate("ALTER TABLE {table} ADD COLUMN x; CREATE INDEX i ON {table}(y)", + "addr") == "ALTER TABLE addr ADD COLUMN x; CREATE INDEX i ON addr(y)"); + REQUIRE(instantiate("no placeholder here", "tbl") == "no placeholder here"); + REQUIRE(instantiate("", "tbl") == ""); +} + +// Test 2: AuditLog has PREREQ, no RESHAPE — fresh apply records exactly one step. +void test_audit_fresh_apply() { + FakeDb db; + std::vector recorded; + applyDecoratorMigration>( + "ignored", db.probe(), db.exec(), makeRecorder(recorded)); + REQUIRE(db.execLog.size() == 1); + REQUIRE(db.execLog[0].find("CREATE TABLE IF NOT EXISTS audit_log") != std::string::npos); + REQUIRE(recorded.size() == 1); + REQUIRE(recorded[0].decorator == std::string("AuditLogRepository")); + REQUIRE(recorded[0].step == std::string("PREREQ_SQL")); +} + +// Test 3: ScopeGuard has neither — runner is a no-op. +void test_scopeguard_noop() { + FakeDb db; + std::vector recorded; + applyDecoratorMigration>( + "persons", db.probe(), db.exec(), makeRecorder(recorded)); + REQUIRE(db.execLog.empty()); + REQUIRE(recorded.empty()); +} + +// Test 4: TemporalRepository fresh apply — probe returns false for every +// detect, every reshape step runs, recorder fires per step. +void test_temporal_fresh_apply() { + FakeDb db; + std::vector recorded; + applyDecoratorMigration>( + "persons", db.probe(), db.exec(), makeRecorder(recorded)); + // Four reshape steps, all executed (drop_unique_entity_id has a noop + // apply but still counts as executed). + REQUIRE(db.execLog.size() == 4); + REQUIRE(recorded.size() == 4); + REQUIRE(recorded[0].step == std::string("add_valid_from")); + REQUIRE(recorded[1].step == std::string("add_valid_until")); + REQUIRE(recorded[2].step == std::string("drop_unique_entity_id")); + REQUIRE(recorded[3].step == std::string("composite_unique")); + // {table} substituted in apply SQL. + REQUIRE(db.execLog[0].find("ALTER TABLE persons") != std::string::npos); + REQUIRE(db.execLog[3].find("ON persons(entity_id, valid_until)") != std::string::npos); +} + +// Test 5: re-apply on already-shaped table is fully no-op. +void test_temporal_reapply_noop() { + FakeDb db; + // Pre-populate probe truth set with every detect query — every step + // is "already applied". + db.knownTrue.insert(instantiate( + "SELECT 1 FROM pragma_table_info('{table}') WHERE name='valid_from'", "persons")); + db.knownTrue.insert(instantiate( + "SELECT 1 FROM pragma_table_info('{table}') WHERE name='valid_until'", "persons")); + db.knownTrue.insert(instantiate( + "SELECT 1 FROM sqlite_master WHERE type='index' AND tbl_name='{table}' AND name='ux_{table}_entity_valid_until'", "persons")); + db.knownTrue.insert(instantiate( + "SELECT 1 FROM sqlite_master WHERE type='index' AND name='ux_{table}_entity_valid_until'", "persons")); + + std::vector recorded; + applyDecoratorMigration>( + "persons", db.probe(), db.exec(), makeRecorder(recorded)); + REQUIRE(db.execLog.empty()); + REQUIRE(recorded.empty()); +} + +// Test 6: partial state recovery — only the missing step runs. +void test_temporal_partial_recovery() { + FakeDb db; + // valid_from already exists, valid_until doesn't, composite index doesn't. + db.knownTrue.insert(instantiate( + "SELECT 1 FROM pragma_table_info('{table}') WHERE name='valid_from'", "persons")); + + std::vector recorded; + applyDecoratorMigration>( + "persons", db.probe(), db.exec(), makeRecorder(recorded)); + REQUIRE(db.execLog.size() == 3); // skipped add_valid_from, ran the rest + REQUIRE(recorded.size() == 3); + REQUIRE(recorded[0].step == std::string("add_valid_until")); + REQUIRE(recorded[1].step == std::string("drop_unique_entity_id")); + REQUIRE(recorded[2].step == std::string("composite_unique")); +} + +// Test 7: variadic stack runs every decorator's migrations in order. +void test_stack_runs_in_order() { + FakeDb db; + std::vector recorded; + applyDecoratorMigrations< + AuditLogRepository, + TemporalRepository, + ScopeGuardRepository>( + "persons", db.probe(), db.exec(), makeRecorder(recorded)); + // Audit (1) + Temporal (4) + ScopeGuard (0) = 5 + REQUIRE(recorded.size() == 5); + REQUIRE(recorded[0].decorator == std::string("AuditLogRepository")); + REQUIRE(recorded[1].decorator == std::string("TemporalRepository")); + REQUIRE(recorded[4].decorator == std::string("TemporalRepository")); + // ScopeGuard contributed zero — never appears in the log. + for (const auto& r : recorded) { + REQUIRE(r.decorator != std::string("ScopeGuardRepository")); + } +} + +// Test 8: schema_migrations table SQL is a CREATE IF NOT EXISTS (idempotent +// by construction). Asserting the literal so consumers can rely on the +// column shape. +void test_schema_migrations_table_sql() { + std::string sql(SCHEMA_MIGRATIONS_TABLE_SQL); + REQUIRE(sql.find("CREATE TABLE IF NOT EXISTS oatpp_authkit_schema_migrations") != std::string::npos); + REQUIRE(sql.find("decorator") != std::string::npos); + REQUIRE(sql.find("table_name") != std::string::npos); + REQUIRE(sql.find("step") != std::string::npos); + REQUIRE(sql.find("applied_at") != std::string::npos); + REQUIRE(sql.find("PRIMARY KEY (decorator, table_name, step)") != std::string::npos); +} + +// Test 9: missing recorder is fine (default-constructed callable is nullable). +void test_recorder_optional() { + FakeDb db; + applyDecoratorMigrations< + AuditLogRepository, + TemporalRepository>( + "persons", db.probe(), db.exec()); // no recorder + REQUIRE(db.execLog.size() == 5); +} + +} // namespace + +int main() { + test_instantiate(); + test_audit_fresh_apply(); + test_scopeguard_noop(); + test_temporal_fresh_apply(); + test_temporal_reapply_noop(); + test_temporal_partial_recovery(); + test_stack_runs_in_order(); + test_schema_migrations_table_sql(); + test_recorder_optional(); + std::printf("test_decorator_migrations: OK\n"); + return 0; +}