oatpp-authkit/test/test_redacted_field_repository.cpp
Uwe Schuster 52449e4159 #15: RedactedFieldRepository — null credentials on historical rows
Adds a decorator that sits below TemporalRepository and redacts
configured fields whenever it sees a save with valid_until != SENTINEL
(i.e., a historical row being closed by the temporal close-then-update
flow). The live row keeps its values intact.

Per Option B from the issue thread: by default the user-repo factory
redacts both passwordHash and tlsCertDn. Empty redaction list passes
everything through unchanged, so non-user temporal stacks compose the
decorator without surprise behaviour.

Files:
- repo/RedactedFieldRepository.hpp — new decorator. Schema contribution
  is empty (purely a save-time transform). Field-name matching uses
  oatpp's reflective property dispatcher and matches against the C++
  identifier name (first DTO_FIELD argument).
- repo/ConcreteUserRepository.hpp — makeUserRepository now wraps the
  concrete repo in RedactedFieldRepository<UserDto>{"passwordHash",
  "tlsCertDn"} before passing to TemporalRepository. Optional second
  argument lets consumers override the redaction list.
- test/test_redacted_field_repository.cpp — five tests cover live-row
  pass-through, historical-row redaction (both fields), partial
  redaction list, empty list, and null-valid_until treated as live.
- README.md — adds RedactedFieldRepository to the header inventory.

14 of 14 tests pass. Bumped 0.12.0 → 0.13.0.

Closes #15

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:52:02 +02:00

169 lines
5.4 KiB
C++

// Tests for authkit#15 — RedactedFieldRepository decorator.
#include "oatpp-authkit/repo/RedactedFieldRepository.hpp"
#include "oatpp-authkit/repo/Repository.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 <cassert>
#include <cstdio>
#include <memory>
#include <string>
#include <vector>
#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 CredDto : public oatpp::DTO {
DTO_INIT(CredDto, DTO)
DTO_FIELD(String, id);
DTO_FIELD(String, entity_id);
DTO_FIELD(String, valid_from);
DTO_FIELD(String, valid_until);
DTO_FIELD(String, username);
DTO_FIELD(String, passwordHash);
DTO_FIELD(String, tlsCertDn);
};
} // namespace
#include OATPP_CODEGEN_END(DTO)
OATPP_AUTHKIT_REGISTER_TEMPORAL(CredDto, id, entity_id, valid_from, valid_until)
namespace {
using namespace oatpp_authkit::repo;
// In-memory inner that just records what got saved, for inspection.
class FakeInner : public Repository<CredDto> {
public:
std::vector<oatpp::Object<CredDto>> saved;
oatpp::Object<CredDto> findByEntityId(const oatpp::String&) override { return nullptr; }
oatpp::Vector<oatpp::Object<CredDto>> list() override {
return oatpp::Vector<oatpp::Object<CredDto>>::createShared();
}
void save(const oatpp::Object<CredDto>& dto) override { saved.push_back(dto); }
void softDelete(const oatpp::String&) override {}
};
oatpp::Object<CredDto> makeRow(const std::string& vu,
const std::string& password,
const std::string& certDn) {
auto d = CredDto::createShared();
d->id = "id1";
d->entity_id = "ent1";
d->valid_from = "2026-01-01T00:00:00Z";
d->valid_until = vu;
d->username = "alice";
d->passwordHash = password;
d->tlsCertDn = certDn;
return d;
}
void test_live_row_passes_through_unchanged() {
auto inner = std::make_shared<FakeInner>();
RedactedFieldRepository<CredDto> redacted(
inner, {"passwordHash", "tlsCertDn"});
// Live row: valid_until == SENTINEL.
auto live = makeRow(TemporalRepository<CredDto>::SENTINEL,
"$bcrypt$secret", "CN=alice");
redacted.save(live);
REQUIRE(inner->saved.size() == 1);
auto& got = inner->saved[0];
REQUIRE(got->passwordHash);
REQUIRE(std::string(*got->passwordHash) == "$bcrypt$secret");
REQUIRE(got->tlsCertDn);
REQUIRE(std::string(*got->tlsCertDn) == "CN=alice");
}
void test_historical_row_redacts_named_fields() {
auto inner = std::make_shared<FakeInner>();
RedactedFieldRepository<CredDto> redacted(
inner, {"passwordHash", "tlsCertDn"});
// Historical row: valid_until is a real timestamp, not the sentinel.
auto historical = makeRow("2026-05-06T12:00:00Z",
"$bcrypt$secret", "CN=alice");
redacted.save(historical);
REQUIRE(inner->saved.size() == 1);
auto& got = inner->saved[0];
REQUIRE(!got->passwordHash); // redacted to null
REQUIRE(!got->tlsCertDn); // redacted to null
// Non-redacted fields survive.
REQUIRE(got->username);
REQUIRE(std::string(*got->username) == "alice");
REQUIRE(got->valid_until);
REQUIRE(std::string(*got->valid_until) == "2026-05-06T12:00:00Z");
}
void test_partial_redaction_list() {
auto inner = std::make_shared<FakeInner>();
RedactedFieldRepository<CredDto> redacted(inner, {"passwordHash"});
auto historical = makeRow("2026-05-06T12:00:00Z",
"$bcrypt$secret", "CN=alice");
redacted.save(historical);
auto& got = inner->saved[0];
REQUIRE(!got->passwordHash); // redacted
REQUIRE(got->tlsCertDn); // NOT redacted (not in list)
REQUIRE(std::string(*got->tlsCertDn) == "CN=alice");
}
void test_empty_redaction_list_passes_everything_through() {
auto inner = std::make_shared<FakeInner>();
RedactedFieldRepository<CredDto> redacted(inner, {});
auto historical = makeRow("2026-05-06T12:00:00Z",
"$bcrypt$secret", "CN=alice");
redacted.save(historical);
auto& got = inner->saved[0];
REQUIRE(got->passwordHash);
REQUIRE(std::string(*got->passwordHash) == "$bcrypt$secret");
REQUIRE(got->tlsCertDn);
}
void test_null_valid_until_treated_as_live() {
auto inner = std::make_shared<FakeInner>();
RedactedFieldRepository<CredDto> redacted(
inner, {"passwordHash", "tlsCertDn"});
// valid_until null — treat as live (the temporal decorator hasn't
// set it yet on a fresh insert, before deciding sentinel).
auto fresh = CredDto::createShared();
fresh->id = "id2";
fresh->entity_id = "ent2";
fresh->passwordHash = "$bcrypt$fresh";
fresh->tlsCertDn = "CN=bob";
redacted.save(fresh);
auto& got = inner->saved[0];
REQUIRE(got->passwordHash); // not redacted
REQUIRE(got->tlsCertDn);
}
} // namespace
int main() {
test_live_row_passes_through_unchanged();
test_historical_row_redacts_named_fields();
test_partial_redaction_list();
test_empty_redaction_list_passes_everything_through();
test_null_valid_until_treated_as_live();
std::printf("test_redacted_field_repository: OK\n");
return 0;
}