feat(user): magic_link_tokens table + repository (ADR-0028 Phase A.3) (#61)
All checks were successful
CI/CD Pipeline / Build Docker Cache (push) Successful in 8s
CI/CD Pipeline / CI Pipeline (push) Successful in 5m11s
CI/CD Pipeline / Trigger Docker Push (push) Successful in 6s

Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
This commit was merged in pull request #61.
This commit is contained in:
2026-05-05 11:24:06 +02:00
committed by arcodange
parent b3027d2669
commit c9ab876dfe
4 changed files with 425 additions and 3 deletions

View File

@@ -0,0 +1,194 @@
//go:build integration
// Integration tests for the magic-link repository methods. Run with:
//
// go test -tags integration ./pkg/user/...
//
// Requires a running Postgres reachable via the same env vars / defaults
// the BDD suite already uses (DLC_DATABASE_HOST, etc., default
// localhost:5432 from docker-compose).
package user
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"testing"
"time"
"dance-lessons-coach/pkg/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// freshRepo connects to the local Postgres, creates a uniquely-named
// schema for THIS test, and returns a repository scoped to it.
// On test end, the schema is dropped (cleanup is best-effort).
func freshRepo(t *testing.T) *PostgresRepository {
t.Helper()
cfg, err := config.LoadConfig()
require.NoError(t, err)
var raw [6]byte
_, err = rand.Read(raw[:])
require.NoError(t, err)
schema := "ml_test_" + hex.EncodeToString(raw[:])
// Bootstrap schema via a default-DSN repo (no search_path).
bootDSN := fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
cfg.GetDatabaseHost(),
cfg.GetDatabasePort(),
cfg.GetDatabaseUser(),
cfg.GetDatabasePassword(),
cfg.GetDatabaseName(),
cfg.GetDatabaseSSLMode(),
)
bootRepo, err := NewPostgresRepositoryFromDSN(cfg, bootDSN)
require.NoError(t, err)
require.NoError(t, bootRepo.Exec(fmt.Sprintf(`CREATE SCHEMA "%s"`, schema)))
t.Cleanup(func() {
_ = bootRepo.Exec(fmt.Sprintf(`DROP SCHEMA "%s" CASCADE`, schema))
})
dsn := BuildSchemaIsolatedDSN(cfg, schema)
repo, err := NewPostgresRepositoryFromDSN(cfg, dsn)
require.NoError(t, err)
return repo
}
// TestMagicLinkRepo_CreateAndGetByHash is the end-to-end happy path :
// store a token, look it up by hash, get the row back.
func TestMagicLinkRepo_CreateAndGetByHash(t *testing.T) {
repo := freshRepo(t)
ctx := context.Background()
plain, hashHex, err := GenerateMagicLinkToken()
require.NoError(t, err)
tok := &MagicLinkToken{
Email: "alice@example.com",
TokenHash: hashHex,
ExpiresAt: time.Now().Add(15 * time.Minute),
}
require.NoError(t, repo.CreateMagicLinkToken(ctx, tok))
assert.NotZero(t, tok.ID, "ID should be populated by GORM after Create")
got, err := repo.GetMagicLinkTokenByHash(ctx, hashHex)
require.NoError(t, err)
require.NotNil(t, got, "fresh token must be retrievable")
assert.Equal(t, "alice@example.com", got.Email)
assert.Nil(t, got.ConsumedAt, "fresh token is not yet consumed")
// Lookup by the plaintext (which the consume handler does NOT receive
// directly — it must hash first). This confirms the hashing direction
// is consistent.
got2, err := repo.GetMagicLinkTokenByHash(ctx, HashMagicLinkToken(plain))
require.NoError(t, err)
require.NotNil(t, got2)
assert.Equal(t, tok.ID, got2.ID)
}
// TestMagicLinkRepo_GetByHash_Missing returns (nil, nil) for a hash that
// never existed. Callers must NOT distinguish "missing" from "expired"
// or "consumed" — they all collapse to a single generic error to the user.
func TestMagicLinkRepo_GetByHash_Missing(t *testing.T) {
repo := freshRepo(t)
got, err := repo.GetMagicLinkTokenByHash(context.Background(), HashMagicLinkToken("never-issued"))
require.NoError(t, err)
assert.Nil(t, got)
}
// TestMagicLinkRepo_MarkConsumed flips consumed_at and refuses to act
// on a non-existent ID.
func TestMagicLinkRepo_MarkConsumed(t *testing.T) {
repo := freshRepo(t)
ctx := context.Background()
_, hashHex, err := GenerateMagicLinkToken()
require.NoError(t, err)
tok := &MagicLinkToken{
Email: "bob@example.com",
TokenHash: hashHex,
ExpiresAt: time.Now().Add(15 * time.Minute),
}
require.NoError(t, repo.CreateMagicLinkToken(ctx, tok))
now := time.Now().UTC().Truncate(time.Second)
require.NoError(t, repo.MarkMagicLinkTokenConsumed(ctx, tok.ID, now))
got, err := repo.GetMagicLinkTokenByHash(ctx, hashHex)
require.NoError(t, err)
require.NotNil(t, got)
require.NotNil(t, got.ConsumedAt, "consumed_at must be set")
assert.WithinDuration(t, now, got.ConsumedAt.UTC(), time.Second)
// Marking a non-existent ID returns an error (defensive — the consume
// handler should never call us with a fake ID, but if it does we want
// the failure to be loud).
err = repo.MarkMagicLinkTokenConsumed(ctx, 999999, time.Now())
require.Error(t, err)
}
// TestMagicLinkRepo_DeleteExpired confirms the cleanup pass deletes
// strictly-before-cutoff rows and leaves future ones alone.
func TestMagicLinkRepo_DeleteExpired(t *testing.T) {
repo := freshRepo(t)
ctx := context.Background()
now := time.Now()
expired := &MagicLinkToken{
Email: "expired@example.com",
TokenHash: HashMagicLinkToken("expired-token"),
ExpiresAt: now.Add(-1 * time.Hour),
}
fresh := &MagicLinkToken{
Email: "fresh@example.com",
TokenHash: HashMagicLinkToken("fresh-token"),
ExpiresAt: now.Add(1 * time.Hour),
}
require.NoError(t, repo.CreateMagicLinkToken(ctx, expired))
require.NoError(t, repo.CreateMagicLinkToken(ctx, fresh))
deleted, err := repo.DeleteExpiredMagicLinkTokens(ctx, now)
require.NoError(t, err)
assert.EqualValues(t, 1, deleted, "exactly one row was past the cutoff")
// Expired row is gone, fresh row is still there.
got, err := repo.GetMagicLinkTokenByHash(ctx, HashMagicLinkToken("expired-token"))
require.NoError(t, err)
assert.Nil(t, got, "expired token must be gone")
got, err = repo.GetMagicLinkTokenByHash(ctx, HashMagicLinkToken("fresh-token"))
require.NoError(t, err)
require.NotNil(t, got, "fresh token must remain")
}
// TestMagicLinkRepo_HashUniqueness is a defensive check that the unique
// index on token_hash actually rejects duplicates. If the index is ever
// dropped from the schema, this test catches it before security does.
func TestMagicLinkRepo_HashUniqueness(t *testing.T) {
repo := freshRepo(t)
ctx := context.Background()
_, hashHex, err := GenerateMagicLinkToken()
require.NoError(t, err)
first := &MagicLinkToken{
Email: "a@example.com",
TokenHash: hashHex,
ExpiresAt: time.Now().Add(15 * time.Minute),
}
require.NoError(t, repo.CreateMagicLinkToken(ctx, first))
dup := &MagicLinkToken{
Email: "b@example.com",
TokenHash: hashHex, // same hash as `first`
ExpiresAt: time.Now().Add(15 * time.Minute),
}
err = repo.CreateMagicLinkToken(ctx, dup)
require.Error(t, err, "second insert with same hash must violate the unique index")
}