✨ feat(user): magic_link_tokens table + repository (ADR-0028 Phase A.3) (#61)
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:
194
pkg/user/magic_link_integration_test.go
Normal file
194
pkg/user/magic_link_integration_test.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user