package auth_test import ( "context" "os" "strings" "testing" "github.com/jackc/pgx/v5" "archivmail/internal/auth" "archivmail/internal/userstore" ) // newTestAuth builds an auth.Manager on top of a schema-isolated PostgreSQL // userstore. userstore.New expects a PostgreSQL DSN (pgxpool) — the former // SQLite file path ("users.db") made pgx fail with "cannot parse ...". func newTestAuth(t *testing.T) (*auth.Manager, *userstore.Store) { t.Helper() dsn := os.Getenv("TEST_DATABASE_URL") if dsn == "" { t.Skip("TEST_DATABASE_URL not set — skipping (needs PostgreSQL)") } schema := "authtest_" + strings.ToLower(strings.ReplaceAll(t.Name(), "/", "_")) if len(schema) > 63 { schema = schema[:63] } ctx := context.Background() conn, err := pgx.Connect(ctx, dsn) if err != nil { t.Fatalf("connect: %v", err) } if _, err := conn.Exec(ctx, "CREATE SCHEMA IF NOT EXISTS "+schema); err != nil { conn.Close(ctx) t.Fatalf("create schema: %v", err) } conn.Close(ctx) sep := "?" if strings.Contains(dsn, "?") { sep = "&" } schemaDSN := dsn + sep + "search_path=" + schema store, err := userstore.New(schemaDSN) if err != nil { t.Fatalf("userstore.New: %v", err) } t.Cleanup(func() { store.Close() conn2, _ := pgx.Connect(context.Background(), dsn) if conn2 != nil { conn2.Exec(context.Background(), "DROP SCHEMA "+schema+" CASCADE") conn2.Close(context.Background()) } }) // Seed a test user store.Create(userstore.CreateUserRequest{ Username: "testadmin", Email: "admin@example.com", Password: "adminpass", Role: userstore.RoleAdmin, }) store.Create(userstore.CreateUserRequest{ Username: "regularuser", Email: "user@example.com", Password: "userpass", Role: userstore.RoleUser, }) mgr := auth.New(store, nil, "test-jwt-secret-32chars-long-enough", "0000000000000000000000000000000000000000000000000000000000000000") return mgr, store } func TestLoginSuccess(t *testing.T) { mgr, _ := newTestAuth(t) token, user, totpRequired, err := mgr.Login("testadmin", "adminpass") if err != nil { t.Fatalf("Login: %v", err) } if totpRequired { t.Error("expected totpRequired=false for user without TOTP") } if token == "" { t.Error("expected non-empty token") } if user.Username != "testadmin" { t.Errorf("Username = %q", user.Username) } if user.Role != userstore.RoleAdmin { t.Errorf("Role = %q", user.Role) } } func TestLoginWrongPassword(t *testing.T) { mgr, _ := newTestAuth(t) if _, _, _, err := mgr.Login("testadmin", "wrongpass"); err == nil { t.Error("expected error for wrong password") } } func TestLoginUnknownUser(t *testing.T) { mgr, _ := newTestAuth(t) if _, _, _, err := mgr.Login("nobody", "pw"); err == nil { t.Error("expected error for unknown user") } } func TestTokenValidation(t *testing.T) { mgr, _ := newTestAuth(t) token, _, _, _ := mgr.Login("testadmin", "adminpass") sess, err := mgr.ValidateToken(token) if err != nil { t.Fatalf("ValidateToken: %v", err) } if sess.Username != "testadmin" { t.Errorf("Session Username = %q", sess.Username) } if sess.Role != userstore.RoleAdmin { t.Errorf("Session Role = %q", sess.Role) } if sess.JTI == "" { t.Error("Session JTI should not be empty") } } func TestTokenTampering(t *testing.T) { mgr, _ := newTestAuth(t) token, _, _, _ := mgr.Login("testadmin", "adminpass") tampered := token + "x" if _, err := mgr.ValidateToken(tampered); err == nil { t.Error("tampered token should fail validation") } } func TestLogout(t *testing.T) { mgr, _ := newTestAuth(t) token, _, _, _ := mgr.Login("testadmin", "adminpass") // Token valid before logout if _, err := mgr.ValidateToken(token); err != nil { t.Fatalf("token should be valid before logout: %v", err) } if err := mgr.Logout(token); err != nil { t.Fatalf("Logout: %v", err) } // Token invalid after logout if _, err := mgr.ValidateToken(token); err == nil { t.Error("token should be invalid after logout") } } func TestHasRole(t *testing.T) { tests := []struct { userRole string required string want bool }{ {userstore.RoleAdmin, userstore.RoleAdmin, true}, {userstore.RoleAdmin, userstore.RoleAuditor, true}, {userstore.RoleAdmin, userstore.RoleUser, true}, {userstore.RoleAuditor, userstore.RoleAdmin, false}, {userstore.RoleAuditor, userstore.RoleAuditor, true}, {userstore.RoleAuditor, userstore.RoleUser, true}, {userstore.RoleUser, userstore.RoleAdmin, false}, {userstore.RoleUser, userstore.RoleAuditor, false}, {userstore.RoleUser, userstore.RoleUser, true}, } for _, tt := range tests { got := auth.HasRole(tt.userRole, tt.required) if got != tt.want { t.Errorf("HasRole(%q, %q) = %v, want %v", tt.userRole, tt.required, got, tt.want) } } } func TestMultipleSessionsIndependent(t *testing.T) { mgr, _ := newTestAuth(t) token1, _, _, _ := mgr.Login("testadmin", "adminpass") token2, _, _, _ := mgr.Login("testadmin", "adminpass") if token1 == token2 { t.Error("two logins should produce different tokens (different JTIs)") } // Logout session 1, session 2 should still work mgr.Logout(token1) if _, err := mgr.ValidateToken(token2); err != nil { t.Errorf("session 2 should still be valid after session 1 logout: %v", err) } }