//go:build postgres // Integration tests for the authd Store. Run with: // // go test -tags=postgres ./internal/authd/ // // Requires a running Postgres with the 009_auth migration applied // to the database. The TEST_AUTH_DSN env var must be set: // // TEST_AUTH_DSN=postgres://postgres:testing@localhost:5432/test_auth?sslmode=disable // // These tests use the 'auth' schema directly (no separate test // schema). Each test TRUNCATEs all the auth tables in setup so // they don't see each other's data. package authd import ( "context" "errors" "os" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/crypto/bcrypt" "git3.techno-world.net/lrosales/broad-announce/internal/postgres" ) func setupTestDB(t *testing.T) *postgres.Pool { t.Helper() dsn := os.Getenv("TEST_AUTH_DSN") if dsn == "" { t.Skip("TEST_AUTH_DSN not set; skipping integration tests") } cfg, err := pgxpool.ParseConfig(dsn) if err != nil { t.Fatalf("parse dsn: %v", err) } pingCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() pool, err := pgxpool.NewWithConfig(pingCtx, cfg) if err != nil { t.Fatalf("new: %v", err) } if err := pool.Ping(pingCtx); err != nil { pool.Close() t.Fatalf("ping: %v", err) } t.Cleanup(func() { pool.Close() }) // Apply the migration if it hasn't been yet (idempotent — uses // CREATE TABLE IF NOT EXISTS). migPath := findMigration(t) migSQL, err := os.ReadFile(migPath) if err != nil { t.Fatalf("read migration: %v", err) } if _, err := pool.Exec(context.Background(), string(migSQL)); err != nil { t.Fatalf("apply migration: %v", err) } // Truncate all auth tables so each test starts fresh. CASCADE // handles FK dependencies. if _, err := pool.Exec(context.Background(), "TRUNCATE auth.audit_log, auth.sessions, auth.refresh_tokens, auth.magic_links, auth.users, auth.tenants RESTART IDENTITY CASCADE"); err != nil { t.Fatalf("truncate: %v", err) } return pool } func findMigration(t *testing.T) string { t.Helper() candidates := []string{ "../../migrations/009_auth.up.sql", "migrations/009_auth.up.sql", } for _, p := range candidates { if _, err := os.Stat(p); err == nil { return p } } t.Fatal("cannot find migrations/009_auth.up.sql") return "" } func newTestAuthd(t *testing.T, pool *postgres.Pool) *Authd { t.Helper() cfg := DefaultConfig() cfg.JWTSecret = []byte("test-secret-with-32-bytes-min-len") // Use bcrypt MinCost for tests — production uses 12. cfg.BcryptCost = bcrypt.MinCost a, err := New(pool, cfg) if err != nil { t.Fatalf("new: %v", err) } return a } func TestStore_CreateAndGetUser(t *testing.T) { pool := setupTestDB(t) s := NewStore(pool) ctx := context.Background() id, err := s.CreateUser(ctx, "", "super@x.test", "super_admin") if err != nil { t.Fatalf("create: %v", err) } u, err := s.GetUserByID(ctx, id) if err != nil { t.Fatalf("get: %v", err) } if u.Email != "super@x.test" { t.Errorf("email = %q, want super@x.test", u.Email) } if u.Role != "super_admin" { t.Errorf("role = %q, want super_admin", u.Role) } if u.Status != "pending" { t.Errorf("status = %q, want pending", u.Status) } } func TestStore_DuplicateEmailRejected(t *testing.T) { pool := setupTestDB(t) s := NewStore(pool) ctx := context.Background() _, err := s.CreateUser(ctx, "", "dup@x.test", "super_admin") if err != nil { t.Fatalf("first create: %v", err) } _, err = s.CreateUser(ctx, "", "dup@x.test", "super_admin") if err == nil { t.Error("expected duplicate-email error, got nil") } } func TestAuthd_MagicLink_IssueConsume(t *testing.T) { pool := setupTestDB(t) a := newTestAuthd(t, pool) s := a.store ctx := context.Background() uid, _ := s.CreateUser(ctx, "", "m@x.test", "super_admin") token, exp, err := a.IssueMagicLink(ctx, uid, "invite") if err != nil { t.Fatalf("issue: %v", err) } if len(token) != 64 { t.Errorf("token length = %d, want 64", len(token)) } if !exp.After(time.Now()) { t.Error("token expiry in the past") } gotUID, err := a.ConsumeMagicLink(ctx, token, "127.0.0.1", "test-ua") if err != nil { t.Fatalf("consume: %v", err) } if gotUID != uid { t.Errorf("user_id = %q, want %q", gotUID, uid) } // Re-consume must fail if _, err := a.ConsumeMagicLink(ctx, token, "127.0.0.1", "test-ua"); !errors.Is(err, ErrMagicLinkInvalid) { t.Errorf("re-consume: err = %v, want ErrMagicLinkInvalid", err) } } func TestAuthd_LoginAndRefreshAndLogout(t *testing.T) { pool := setupTestDB(t) a := newTestAuthd(t, pool) s := a.store ctx := context.Background() // Provision: tenant + user with password if _, err := pool.Exec(ctx, ` INSERT INTO auth.tenants (slug, display_name, contact_email) VALUES ('acme', 'Acme', 'a@a.test') `); err != nil { t.Fatalf("insert tenant: %v", err) } var tenantID string if err := pool.QueryRow(ctx, `SELECT id::text FROM auth.tenants WHERE slug='acme'`).Scan(&tenantID); err != nil { t.Fatalf("get tenant: %v", err) } uid, err := s.CreateUser(ctx, tenantID, "admin@acme.test", "tenant_admin") if err != nil { t.Fatalf("create user: %v", err) } hash, _ := a.HashPassword(ctx, "correct-password") if err := s.SetUserPassword(ctx, uid, hash, "active"); err != nil { t.Fatalf("set password: %v", err) } // 1) Login wrong password if _, err := a.Login(ctx, "admin@acme.test", "wrong", "127.0.0.1", "ua"); !errors.Is(err, ErrInvalidCredentials) { t.Errorf("login wrong: err = %v, want ErrInvalidCredentials", err) } // 2) Login right password res, err := a.Login(ctx, "admin@acme.test", "correct-password", "127.0.0.1", "ua") if err != nil { t.Fatalf("login ok: %v", err) } if res.AccessToken == "" || res.RefreshToken == "" { t.Fatal("empty tokens in login result") } if res.TenantID != tenantID { t.Errorf("tenantID = %q, want %q", res.TenantID, tenantID) } if res.Role != "tenant_admin" { t.Errorf("role = %q, want tenant_admin", res.Role) } // Verify the access token claims, err := a.VerifyAccessToken(res.AccessToken) if err != nil { t.Fatalf("verify jwt: %v", err) } if claims.UserID != uid || claims.TenantID != tenantID { t.Errorf("claims mismatch: %+v", claims) } // 3) Refresh res2, err := a.Refresh(ctx, res.RefreshToken, "127.0.0.1", "ua") if err != nil { t.Fatalf("refresh: %v", err) } if res2.RefreshToken == res.RefreshToken { t.Error("refresh returned the same token (no rotation)") } if res2.AccessToken == res.AccessToken { t.Error("refresh returned the same access token (no JTI rotation)") } // 4) Re-use the OLD refresh token — should kill the family _, err = a.Refresh(ctx, res.RefreshToken, "127.0.0.1", "ua") if !errors.Is(err, ErrTokenReuse) { t.Errorf("reuse old refresh: err = %v, want ErrTokenReuse", err) } // The rotated (new) refresh should now ALSO be revoked _, err = a.Refresh(ctx, res2.RefreshToken, "127.0.0.1", "ua") if !errors.Is(err, ErrTokenReuse) { t.Errorf("use new refresh after kill: err = %v, want ErrTokenReuse", err) } // 5) Logout flow on a fresh session res3, err := a.Login(ctx, "admin@acme.test", "correct-password", "127.0.0.1", "ua") if err != nil { t.Fatalf("re-login: %v", err) } if err := a.Logout(ctx, res3.RefreshToken, uid, "127.0.0.1", "ua"); err != nil { t.Errorf("logout: %v", err) } // Refresh after logout should fail with reuse (since it was rotated once // internally? no — logout is a direct revoke, not a rotation. The // presented token is now revoked. Subsequent use = reuse detection.) _, err = a.Refresh(ctx, res3.RefreshToken, "127.0.0.1", "ua") if err == nil { t.Error("refresh after logout: expected error, got nil") } } func TestAuthd_InviteAndSetPassword(t *testing.T) { pool := setupTestDB(t) a := newTestAuthd(t, pool) ctx := context.Background() if _, err := pool.Exec(ctx, ` INSERT INTO auth.tenants (slug, display_name, contact_email) VALUES ('beta', 'Beta', 'b@b.test') `); err != nil { t.Fatalf("insert tenant: %v", err) } var tenantID string if err := pool.QueryRow(ctx, `SELECT id::text FROM auth.tenants WHERE slug='beta'`).Scan(&tenantID); err != nil { t.Fatalf("get tenant: %v", err) } inviter := "00000000-0000-0000-0000-000000000001" magic, uid, err := a.InviteUser(ctx, tenantID, "new@beta.test", "viewer", inviter, "127.0.0.1", "ua") if err != nil { t.Fatalf("invite: %v", err) } if magic == "" || uid == "" { t.Fatal("invite returned empty token or id") } // Consume the magic link, then set the password gotUID, err := a.ConsumeMagicLink(ctx, magic, "127.0.0.1", "ua") if err != nil { t.Fatalf("consume: %v", err) } if gotUID != uid { t.Errorf("consume uid = %q, want %q", gotUID, uid) } if err := a.SetPassword(ctx, uid, "new-password"); err != nil { t.Fatalf("set password: %v", err) } // Login with the new password if _, err := a.Login(ctx, "new@beta.test", "new-password", "127.0.0.1", "ua"); err != nil { t.Errorf("login after invite+setpassword: %v", err) } } func TestStore_AuditLog(t *testing.T) { pool := setupTestDB(t) s := NewStore(pool) ctx := context.Background() if err := s.WriteAudit(ctx, "test.event", "", "", "", "", "", map[string]any{"x": 1}); err != nil { t.Fatalf("write audit: %v", err) } var count int if err := pool.QueryRow(ctx, `SELECT count(*) FROM auth.audit_log WHERE action='test.event'`).Scan(&count); err != nil { t.Fatalf("count: %v", err) } if count != 1 { t.Errorf("audit count = %d, want 1", count) } }