store_test.go 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. //go:build postgres
  2. // Integration tests for the authd Store. Run with:
  3. //
  4. // go test -tags=postgres ./internal/authd/
  5. //
  6. // Requires a running Postgres with the 009_auth migration applied
  7. // to the database. The TEST_AUTH_DSN env var must be set:
  8. //
  9. // TEST_AUTH_DSN=postgres://postgres:testing@localhost:5432/test_auth?sslmode=disable
  10. //
  11. // These tests use the 'auth' schema directly (no separate test
  12. // schema). Each test TRUNCATEs all the auth tables in setup so
  13. // they don't see each other's data.
  14. package authd
  15. import (
  16. "context"
  17. "errors"
  18. "os"
  19. "testing"
  20. "time"
  21. "github.com/jackc/pgx/v5/pgxpool"
  22. "golang.org/x/crypto/bcrypt"
  23. "git3.techno-world.net/lrosales/broad-announce/internal/postgres"
  24. )
  25. func setupTestDB(t *testing.T) *postgres.Pool {
  26. t.Helper()
  27. dsn := os.Getenv("TEST_AUTH_DSN")
  28. if dsn == "" {
  29. t.Skip("TEST_AUTH_DSN not set; skipping integration tests")
  30. }
  31. cfg, err := pgxpool.ParseConfig(dsn)
  32. if err != nil {
  33. t.Fatalf("parse dsn: %v", err)
  34. }
  35. pingCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
  36. defer cancel()
  37. pool, err := pgxpool.NewWithConfig(pingCtx, cfg)
  38. if err != nil {
  39. t.Fatalf("new: %v", err)
  40. }
  41. if err := pool.Ping(pingCtx); err != nil {
  42. pool.Close()
  43. t.Fatalf("ping: %v", err)
  44. }
  45. t.Cleanup(func() { pool.Close() })
  46. // Apply the migration if it hasn't been yet (idempotent — uses
  47. // CREATE TABLE IF NOT EXISTS).
  48. migPath := findMigration(t)
  49. migSQL, err := os.ReadFile(migPath)
  50. if err != nil {
  51. t.Fatalf("read migration: %v", err)
  52. }
  53. if _, err := pool.Exec(context.Background(), string(migSQL)); err != nil {
  54. t.Fatalf("apply migration: %v", err)
  55. }
  56. // Truncate all auth tables so each test starts fresh. CASCADE
  57. // handles FK dependencies.
  58. if _, err := pool.Exec(context.Background(),
  59. "TRUNCATE auth.audit_log, auth.sessions, auth.refresh_tokens, auth.magic_links, auth.users, auth.tenants RESTART IDENTITY CASCADE"); err != nil {
  60. t.Fatalf("truncate: %v", err)
  61. }
  62. return pool
  63. }
  64. func findMigration(t *testing.T) string {
  65. t.Helper()
  66. candidates := []string{
  67. "../../migrations/009_auth.up.sql",
  68. "migrations/009_auth.up.sql",
  69. }
  70. for _, p := range candidates {
  71. if _, err := os.Stat(p); err == nil {
  72. return p
  73. }
  74. }
  75. t.Fatal("cannot find migrations/009_auth.up.sql")
  76. return ""
  77. }
  78. func newTestAuthd(t *testing.T, pool *postgres.Pool) *Authd {
  79. t.Helper()
  80. cfg := DefaultConfig()
  81. cfg.JWTSecret = []byte("test-secret-with-32-bytes-min-len")
  82. // Use bcrypt MinCost for tests — production uses 12.
  83. cfg.BcryptCost = bcrypt.MinCost
  84. a, err := New(pool, cfg)
  85. if err != nil {
  86. t.Fatalf("new: %v", err)
  87. }
  88. return a
  89. }
  90. func TestStore_CreateAndGetUser(t *testing.T) {
  91. pool := setupTestDB(t)
  92. s := NewStore(pool)
  93. ctx := context.Background()
  94. id, err := s.CreateUser(ctx, "", "super@x.test", "super_admin")
  95. if err != nil {
  96. t.Fatalf("create: %v", err)
  97. }
  98. u, err := s.GetUserByID(ctx, id)
  99. if err != nil {
  100. t.Fatalf("get: %v", err)
  101. }
  102. if u.Email != "super@x.test" {
  103. t.Errorf("email = %q, want super@x.test", u.Email)
  104. }
  105. if u.Role != "super_admin" {
  106. t.Errorf("role = %q, want super_admin", u.Role)
  107. }
  108. if u.Status != "pending" {
  109. t.Errorf("status = %q, want pending", u.Status)
  110. }
  111. }
  112. func TestStore_DuplicateEmailRejected(t *testing.T) {
  113. pool := setupTestDB(t)
  114. s := NewStore(pool)
  115. ctx := context.Background()
  116. _, err := s.CreateUser(ctx, "", "dup@x.test", "super_admin")
  117. if err != nil {
  118. t.Fatalf("first create: %v", err)
  119. }
  120. _, err = s.CreateUser(ctx, "", "dup@x.test", "super_admin")
  121. if err == nil {
  122. t.Error("expected duplicate-email error, got nil")
  123. }
  124. }
  125. func TestAuthd_MagicLink_IssueConsume(t *testing.T) {
  126. pool := setupTestDB(t)
  127. a := newTestAuthd(t, pool)
  128. s := a.store
  129. ctx := context.Background()
  130. uid, _ := s.CreateUser(ctx, "", "m@x.test", "super_admin")
  131. token, exp, err := a.IssueMagicLink(ctx, uid, "invite")
  132. if err != nil {
  133. t.Fatalf("issue: %v", err)
  134. }
  135. if len(token) != 64 {
  136. t.Errorf("token length = %d, want 64", len(token))
  137. }
  138. if !exp.After(time.Now()) {
  139. t.Error("token expiry in the past")
  140. }
  141. gotUID, err := a.ConsumeMagicLink(ctx, token, "127.0.0.1", "test-ua")
  142. if err != nil {
  143. t.Fatalf("consume: %v", err)
  144. }
  145. if gotUID != uid {
  146. t.Errorf("user_id = %q, want %q", gotUID, uid)
  147. }
  148. // Re-consume must fail
  149. if _, err := a.ConsumeMagicLink(ctx, token, "127.0.0.1", "test-ua"); !errors.Is(err, ErrMagicLinkInvalid) {
  150. t.Errorf("re-consume: err = %v, want ErrMagicLinkInvalid", err)
  151. }
  152. }
  153. func TestAuthd_LoginAndRefreshAndLogout(t *testing.T) {
  154. pool := setupTestDB(t)
  155. a := newTestAuthd(t, pool)
  156. s := a.store
  157. ctx := context.Background()
  158. // Provision: tenant + user with password
  159. if _, err := pool.Exec(ctx, `
  160. INSERT INTO auth.tenants (slug, display_name, contact_email)
  161. VALUES ('acme', 'Acme', 'a@a.test')
  162. `); err != nil {
  163. t.Fatalf("insert tenant: %v", err)
  164. }
  165. var tenantID string
  166. if err := pool.QueryRow(ctx, `SELECT id::text FROM auth.tenants WHERE slug='acme'`).Scan(&tenantID); err != nil {
  167. t.Fatalf("get tenant: %v", err)
  168. }
  169. uid, err := s.CreateUser(ctx, tenantID, "admin@acme.test", "tenant_admin")
  170. if err != nil {
  171. t.Fatalf("create user: %v", err)
  172. }
  173. hash, _ := a.HashPassword(ctx, "correct-password")
  174. if err := s.SetUserPassword(ctx, uid, hash, "active"); err != nil {
  175. t.Fatalf("set password: %v", err)
  176. }
  177. // 1) Login wrong password
  178. if _, err := a.Login(ctx, "admin@acme.test", "wrong", "127.0.0.1", "ua"); !errors.Is(err, ErrInvalidCredentials) {
  179. t.Errorf("login wrong: err = %v, want ErrInvalidCredentials", err)
  180. }
  181. // 2) Login right password
  182. res, err := a.Login(ctx, "admin@acme.test", "correct-password", "127.0.0.1", "ua")
  183. if err != nil {
  184. t.Fatalf("login ok: %v", err)
  185. }
  186. if res.AccessToken == "" || res.RefreshToken == "" {
  187. t.Fatal("empty tokens in login result")
  188. }
  189. if res.TenantID != tenantID {
  190. t.Errorf("tenantID = %q, want %q", res.TenantID, tenantID)
  191. }
  192. if res.Role != "tenant_admin" {
  193. t.Errorf("role = %q, want tenant_admin", res.Role)
  194. }
  195. // Verify the access token
  196. claims, err := a.VerifyAccessToken(res.AccessToken)
  197. if err != nil {
  198. t.Fatalf("verify jwt: %v", err)
  199. }
  200. if claims.UserID != uid || claims.TenantID != tenantID {
  201. t.Errorf("claims mismatch: %+v", claims)
  202. }
  203. // 3) Refresh
  204. res2, err := a.Refresh(ctx, res.RefreshToken, "127.0.0.1", "ua")
  205. if err != nil {
  206. t.Fatalf("refresh: %v", err)
  207. }
  208. if res2.RefreshToken == res.RefreshToken {
  209. t.Error("refresh returned the same token (no rotation)")
  210. }
  211. if res2.AccessToken == res.AccessToken {
  212. t.Error("refresh returned the same access token (no JTI rotation)")
  213. }
  214. // 4) Re-use the OLD refresh token — should kill the family
  215. _, err = a.Refresh(ctx, res.RefreshToken, "127.0.0.1", "ua")
  216. if !errors.Is(err, ErrTokenReuse) {
  217. t.Errorf("reuse old refresh: err = %v, want ErrTokenReuse", err)
  218. }
  219. // The rotated (new) refresh should now ALSO be revoked
  220. _, err = a.Refresh(ctx, res2.RefreshToken, "127.0.0.1", "ua")
  221. if !errors.Is(err, ErrTokenReuse) {
  222. t.Errorf("use new refresh after kill: err = %v, want ErrTokenReuse", err)
  223. }
  224. // 5) Logout flow on a fresh session
  225. res3, err := a.Login(ctx, "admin@acme.test", "correct-password", "127.0.0.1", "ua")
  226. if err != nil {
  227. t.Fatalf("re-login: %v", err)
  228. }
  229. if err := a.Logout(ctx, res3.RefreshToken, uid, "127.0.0.1", "ua"); err != nil {
  230. t.Errorf("logout: %v", err)
  231. }
  232. // Refresh after logout should fail with reuse (since it was rotated once
  233. // internally? no — logout is a direct revoke, not a rotation. The
  234. // presented token is now revoked. Subsequent use = reuse detection.)
  235. _, err = a.Refresh(ctx, res3.RefreshToken, "127.0.0.1", "ua")
  236. if err == nil {
  237. t.Error("refresh after logout: expected error, got nil")
  238. }
  239. }
  240. func TestAuthd_InviteAndSetPassword(t *testing.T) {
  241. pool := setupTestDB(t)
  242. a := newTestAuthd(t, pool)
  243. ctx := context.Background()
  244. if _, err := pool.Exec(ctx, `
  245. INSERT INTO auth.tenants (slug, display_name, contact_email)
  246. VALUES ('beta', 'Beta', 'b@b.test')
  247. `); err != nil {
  248. t.Fatalf("insert tenant: %v", err)
  249. }
  250. var tenantID string
  251. if err := pool.QueryRow(ctx, `SELECT id::text FROM auth.tenants WHERE slug='beta'`).Scan(&tenantID); err != nil {
  252. t.Fatalf("get tenant: %v", err)
  253. }
  254. inviter := "00000000-0000-0000-0000-000000000001"
  255. magic, uid, err := a.InviteUser(ctx, tenantID, "new@beta.test", "viewer", inviter, "127.0.0.1", "ua")
  256. if err != nil {
  257. t.Fatalf("invite: %v", err)
  258. }
  259. if magic == "" || uid == "" {
  260. t.Fatal("invite returned empty token or id")
  261. }
  262. // Consume the magic link, then set the password
  263. gotUID, err := a.ConsumeMagicLink(ctx, magic, "127.0.0.1", "ua")
  264. if err != nil {
  265. t.Fatalf("consume: %v", err)
  266. }
  267. if gotUID != uid {
  268. t.Errorf("consume uid = %q, want %q", gotUID, uid)
  269. }
  270. if err := a.SetPassword(ctx, uid, "new-password"); err != nil {
  271. t.Fatalf("set password: %v", err)
  272. }
  273. // Login with the new password
  274. if _, err := a.Login(ctx, "new@beta.test", "new-password", "127.0.0.1", "ua"); err != nil {
  275. t.Errorf("login after invite+setpassword: %v", err)
  276. }
  277. }
  278. func TestStore_AuditLog(t *testing.T) {
  279. pool := setupTestDB(t)
  280. s := NewStore(pool)
  281. ctx := context.Background()
  282. if err := s.WriteAudit(ctx, "test.event", "", "", "", "", "", map[string]any{"x": 1}); err != nil {
  283. t.Fatalf("write audit: %v", err)
  284. }
  285. var count int
  286. if err := pool.QueryRow(ctx, `SELECT count(*) FROM auth.audit_log WHERE action='test.event'`).Scan(&count); err != nil {
  287. t.Fatalf("count: %v", err)
  288. }
  289. if count != 1 {
  290. t.Errorf("audit count = %d, want 1", count)
  291. }
  292. }