fix(migrations): add migration to fix superadmin role and users e2e test
This commit is contained in:
parent
06ed927ef4
commit
861128571a
2 changed files with 167 additions and 0 deletions
10
backend/migrations/022_fix_superadmin_role.sql
Normal file
10
backend/migrations/022_fix_superadmin_role.sql
Normal file
|
|
@ -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)
|
||||||
157
backend/tests/e2e/users_e2e_test.go
Normal file
157
backend/tests/e2e/users_e2e_test.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue