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>
169 lines
5.4 KiB
C++
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;
|
|
}
|