//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") }