oatpp-authkit/test/test_user_schema.cpp
Uwe Schuster 9040a9ec48 #14 PR 4: relocate users with temporal shape (Option B)
Lifts the auth-essential users table from fewo-webapp into oatpp-authkit
in temporal form per Option B from the issue body. The previous shape
(id INTEGER autoinc + is_active flag) is replaced with the entity_id +
valid_from/valid_until triple; soft-delete via valid_until = now()
instead of toggling is_active.

New files (all in oatpp-authkit):
- dto/UserDto.hpp — auth-essential columns only: id, entity_id, username,
  password_hash, role, tls_cert_dn, valid_from, valid_until. Registered
  as temporal so TemporalRepository composes cleanly. Application-
  specific columns (email, profile data) belong on a consumer-side DTO
  + parallel SchemaContract that contributes additional columns to the
  same users table.
- db/UserDb.hpp — DbClient with login-path queries (findLiveByUsername,
  findLiveByTlsCertDn) plus generic CRUD. UserSchema declares the
  schema: TEXT id, entity_id, username, password_hash, role, tls_cert_dn,
  with natural-key UNIQUE on (username, valid_until) so no two live rows
  can share a username while historical rows for the same username are
  allowed.
- repo/ConcreteUserRepository.hpp — Repository<UserDto> adapter +
  makeUserRepository factory wrapping in TemporalRepository.
- test/test_user_schema.cpp — verifies SchemaBuilder<UserSchema,
  TemporalRepository<UserDto>>::create produces the expected 5 DDL
  statements; specifically asserts is_active and created_at are NOT
  present in the temporal shape (Option B replacement).

13 of 13 tests pass. Bumped 0.11.0 → 0.12.0.

Per owner directive on authkit#14: password_hash rides the temporal row.
A separate security follow-up issue tracks the redaction policy for
historical password hashes (likely blank the hash but keep the row so
change-history is auditable).

The migration of an existing non-temporal users table to this shape is
documented in db/UserDb.hpp: Atlas-generated migration handles the
structural conversion + backfill (each existing row becomes its own
entity with entity_id = CAST(id AS TEXT)). Sessions/certificates FKs
that referenced users.id (INTEGER) need rewiring to reference
users.entity_id — that's a consumer-side rewire, separate PR.

Closes #14 — the four migration sub-PRs (PR 1 role_templates, PRs 2+3
permissions, PR 4 users) are now landed; the umbrella issue can close.
Follow-ups (security hash redaction, fewo-webapp consumer migration,
Atlas CI integration) get their own issues.

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

88 lines
3.1 KiB
C++

// Tests for authkit#14 PR 4 — temporal users schema.
#include "oatpp-authkit/db/UserDb.hpp"
#include "oatpp-authkit/dto/UserDto.hpp"
#include "oatpp-authkit/repo/ConcreteUserRepository.hpp"
#include "oatpp-authkit/repo/SchemaContract.hpp"
#include "oatpp-authkit/repo/TemporalRepository.hpp"
#include <cassert>
#include <cstdio>
#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)
namespace {
bool contains(const std::string& haystack, const std::string& needle) {
return haystack.find(needle) != std::string::npos;
}
void test_users_temporal_create() {
using namespace oatpp_authkit::repo;
using namespace oatpp_authkit::db;
using namespace oatpp_authkit::dto;
std::vector<std::string> sqls;
SqlExec exec = [&](const std::string& sql) { sqls.push_back(sql); };
SchemaBuilder<
UserSchema,
TemporalRepository<UserDto>>::create("users", exec);
// 1 entity table + 3 schema-side indexes + 1 temporal composite index = 5
REQUIRE(sqls.size() == 5);
REQUIRE(contains(sqls[0], "CREATE TABLE IF NOT EXISTS users"));
REQUIRE(contains(sqls[0], "id TEXT PRIMARY KEY"));
REQUIRE(contains(sqls[0], "entity_id TEXT NOT NULL"));
REQUIRE(contains(sqls[0], "username TEXT NOT NULL"));
REQUIRE(contains(sqls[0], "password_hash TEXT"));
REQUIRE(contains(sqls[0], "role TEXT NOT NULL DEFAULT 'editor'"));
REQUIRE(contains(sqls[0], "valid_from TEXT NOT NULL DEFAULT ''"));
REQUIRE(contains(sqls[0], "valid_until TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"));
// is_active and created_at must NOT appear — those are dropped in
// the temporal shape (Option B).
REQUIRE(!contains(sqls[0], "is_active"));
REQUIRE(!contains(sqls[0], "created_at"));
REQUIRE(contains(sqls[1], "ix_users_entity_id"));
REQUIRE(contains(sqls[2], "ux_users_username_until"));
REQUIRE(contains(sqls[2], "(username, valid_until)"));
REQUIRE(contains(sqls[3], "ix_users_tls_cert_dn"));
REQUIRE(contains(sqls[4], "ux_users_entity_valid_until"));
REQUIRE(contains(sqls[4], "(entity_id, valid_until)"));
}
// ConcreteUserRepository contributes nothing; ensure SchemaBuilder is
// idempotent w.r.t. its presence in the parameter pack.
void test_concrete_user_repo_no_schema() {
using namespace oatpp_authkit::repo;
using namespace oatpp_authkit::db;
using namespace oatpp_authkit::dto;
std::vector<std::string> with_repo;
std::vector<std::string> without_repo;
SchemaBuilder<
UserSchema, ConcreteUserRepository, TemporalRepository<UserDto>>::create(
"users", [&](const std::string& s){ with_repo.push_back(s); });
SchemaBuilder<
UserSchema, TemporalRepository<UserDto>>::create(
"users", [&](const std::string& s){ without_repo.push_back(s); });
REQUIRE(with_repo == without_repo);
}
} // namespace
int main() {
test_users_temporal_create();
test_concrete_user_repo_no_schema();
std::printf("test_user_schema: OK\n");
return 0;
}