feat(user): magic_link_tokens table + repository (ADR-0028 Phase A.3)

Adds the persistence layer for the passwordless-auth flow. The token VALUE is
never stored — only its sha256 hex digest, mirroring ADR-0021 secret retention
via fingerprint. Plaintext is generated server-side, emailed once, and rehashed
on consume.

Repository methods : Create / GetByHash / MarkConsumed / DeleteExpired.
AutoMigrate wired in both PostgresRepository init paths (DSN-built + cfg-built).

Tests :
- 5 unit tests : token generation shape, URL-safety, uniqueness, hash stability
- 5 integration tests (build tag `integration`) : end-to-end against real Postgres,
  cover the happy path, missing-hash, consume idempotency, expired-cleanup,
  and the unique-index defensive check
This commit is contained in:
2026-05-05 11:21:05 +02:00
parent b3027d2669
commit c878619478
4 changed files with 425 additions and 3 deletions

View File

@@ -160,7 +160,7 @@ func NewPostgresRepositoryFromDSN(cfg *config.Config, dsn string) (*PostgresRepo
sqlDB.SetMaxIdleConns(cfg.GetDatabaseMaxIdleConns())
sqlDB.SetConnMaxLifetime(cfg.GetDatabaseConnMaxLifetime())
if err := db.AutoMigrate(&User{}); err != nil {
if err := db.AutoMigrate(&User{}, &MagicLinkToken{}); err != nil {
return nil, fmt.Errorf("failed to auto-migrate via custom DSN: %w", err)
}
@@ -264,8 +264,8 @@ func (r *PostgresRepository) initializeDatabase() error {
sqlDB.SetMaxIdleConns(r.config.GetDatabaseMaxIdleConns())
sqlDB.SetConnMaxLifetime(r.config.GetDatabaseConnMaxLifetime())
// Auto-migrate the User model
if err := r.db.AutoMigrate(&User{}); err != nil {
// Auto-migrate the User model + MagicLinkToken (ADR-0028 Phase A)
if err := r.db.AutoMigrate(&User{}, &MagicLinkToken{}); err != nil {
return fmt.Errorf("failed to auto-migrate: %w", err)
}