diff --git a/backend/migrations/022_fix_superadmin_role.sql b/backend/migrations/022_fix_superadmin_role.sql new file mode 100644 index 0000000..7f3deca --- /dev/null +++ b/backend/migrations/022_fix_superadmin_role.sql @@ -0,0 +1,10 @@ +-- Migration: Fix Super Admin Role +-- Description: Ensures superadmin has the 'superadmin' role in user_roles table. +-- Needed because 010 ran before 020 (which created the table). + +INSERT INTO user_roles (user_id, role) +SELECT id, 'superadmin' FROM users WHERE identifier = 'superadmin' +ON CONFLICT (user_id, role) DO NOTHING; + +-- Also ensure seeded admins have their roles if they were missed +-- (Though they are likely handled by seeder script running AFTER migrations, but safe to add) diff --git a/backend/tests/e2e/users_e2e_test.go b/backend/tests/e2e/users_e2e_test.go new file mode 100644 index 0000000..8c56192 --- /dev/null +++ b/backend/tests/e2e/users_e2e_test.go @@ -0,0 +1,157 @@ +//go:build e2e +// +build e2e + +package e2e + +import ( + "io" + "net/http" + "os" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/rede5/gohorsejobs/backend/internal/database" +) + +// TestE2E_Users_List tests listing users with RBAC +func TestE2E_Users_List(t *testing.T) { + client := newTestClient() + companyID, userID := setupTestCompanyAndUser(t) // Creates a SuperAdmin user (roles: superadmin) + defer cleanupTestCompanyAndUser(t, companyID, userID) + + // ===================== + // 1. Unauthenticated Request + // ===================== + t.Run("Unauthenticated", func(t *testing.T) { + client.setAuthToken("") // Clear token + resp, err := client.get("/api/v1/users") + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("Expected status 401, got %d", resp.StatusCode) + } + }) + + // ===================== + // 2. Authenticated as SuperAdmin (Authorized) + // ===================== + t.Run("AsSuperAdmin", func(t *testing.T) { + // createAuthToken creates a token with 'superadmin' role by default in jobs_e2e_test.go + // We can reuse it or use our new helper. + // Let's use our explicit helper to be sure. + token := createTokenWithRoles(t, userID, companyID, []string{"superadmin"}) + client.setAuthToken(token) + + resp, err := client.get("/api/v1/users") + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Errorf("Expected status 200, got %d. Body: %s", resp.StatusCode, string(body)) + } + }) + + // ===================== + // 2a. Authenticated as Admin (Authorized) + // ===================== + t.Run("AsAdmin", func(t *testing.T) { + token := createTokenWithRoles(t, userID, companyID, []string{"admin"}) + client.setAuthToken(token) + + resp, err := client.get("/api/v1/users") + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status 200, got %d", resp.StatusCode) + } + }) + + // ===================== + // 3. Authenticated as Candidate (Forbidden) + // ===================== + t.Run("AsCandidate", func(t *testing.T) { + // Create a candidate user + candID := createTestCandidate(t) + defer database.DB.Exec("DELETE FROM users WHERE id = $1", candID) + + token := createTokenWithRoles(t, candID, companyID, []string{"candidate"}) + client.setAuthToken(token) + + resp, err := client.get("/api/v1/users") + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusForbidden { + t.Errorf("Expected status 403, got %d", resp.StatusCode) + } + }) + + // ===================== + // 4. Authenticated as Recruiter (Forbidden - assuming Recruiters can't list all users) + // ===================== + t.Run("AsRecruiter", func(t *testing.T) { + // Uses same user/company for simplicity, just different claim + token := createTokenWithRoles(t, userID, companyID, []string{"recruiter"}) + client.setAuthToken(token) + + resp, err := client.get("/api/v1/users") + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusForbidden { + t.Errorf("Expected status 403, got %d", resp.StatusCode) + } + }) +} + +// Helper to create a candidate user +func createTestCandidate(t *testing.T) string { + var id string + query := ` + INSERT INTO users (identifier, password_hash, role, full_name, email, status, created_at, updated_at) + VALUES ('e2e_candidate_' || gen_random_uuid(), 'hash', 'candidate', 'E2E Candidate', 'cand@e2e.com', 'active', NOW(), NOW()) + RETURNING id + ` + err := database.DB.QueryRow(query).Scan(&id) + if err != nil { + t.Fatalf("Failed to create candidate: %v", err) + } + // Insert role + database.DB.Exec("INSERT INTO user_roles (user_id, role) VALUES ($1, 'candidate')", id) + return id +} + +func createTokenWithRoles(t *testing.T, userID, tenantID string, roles []string) string { + secret := os.Getenv("JWT_SECRET") + if secret == "" { + secret = "gohorse-super-secret-key-2024-production" + } + claims := jwt.MapClaims{ + "sub": userID, + "tenant": tenantID, + "roles": roles, + "iss": "gohorse-jobs", + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenStr, err := token.SignedString([]byte(secret)) + if err != nil { + t.Fatalf("Failed to generate token: %v", err) + } + return tokenStr +}