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