Co-authored-by: Gabriel Radureau <arcodange@gmail.com> Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
195 lines
6.3 KiB
Go
195 lines
6.3 KiB
Go
//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")
|
|
}
|