package integration_test import ( "context" "encoding/json" "fmt" "net/http" "path/filepath" "testing" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/crypto/bcrypt" "github.com/yuxingu/digital-psychology/apps/api/internal/config" "github.com/yuxingu/digital-psychology/apps/api/internal/db" "github.com/yuxingu/digital-psychology/apps/api/internal/httpserver" ) func setupAPIPool(t *testing.T) (*gin.Engine, *pgxpool.Pool) { t.Helper() gin.SetMode(gin.TestMode) ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) t.Cleanup(cancel) cfg := config.Load() cfg.Admin.BootstrapUsername = "admin" cfg.Admin.BootstrapPassword = "change-me" pool, err := db.Connect(ctx, cfg.DatabaseURL) if err != nil { t.Skipf("postgres unavailable (run npm run deps:up): %v", err) } t.Cleanup(pool.Close) migDir := filepath.Join("..", "..", "migrations") if err := db.Migrate(ctx, pool, migDir); err != nil { t.Fatalf("migrate: %v", err) } return httpserver.NewRouter(pool, cfg), pool } func adminLogin(t *testing.T, r http.Handler, user, pass string) string { t.Helper() env, code := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/auth/login", map[string]string{ "username": user, "password": pass, }, "") if code != 200 || env.Code != 0 { t.Fatalf("login %s failed http=%d code=%d msg=%s", user, code, env.Code, env.Message) } var login struct { Token string `json:"token"` } if err := json.Unmarshal(env.Data, &login); err != nil || login.Token == "" { t.Fatalf("login token missing: %v %s", err, env.Data) } return login.Token } func TestAdminRBAC(t *testing.T) { r, pool := setupAPIPool(t) ctx := context.Background() superTok := adminLogin(t, r, "admin", "change-me") // AC-S-03: no admin session → 401 env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/roles", nil, "") if code != http.StatusUnauthorized { t.Fatalf("expected 401 without admin token, got http=%d", code) } // AC-F-03: /me includes permissions env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/me", nil, superTok) if code != 200 || env.Code != 0 { t.Fatalf("me failed http=%d code=%d", code, env.Code) } var me struct { Permissions []string `json:"permissions"` Role string `json:"role"` } if err := json.Unmarshal(env.Data, &me); err != nil || len(me.Permissions) == 0 { t.Fatalf("expected permissions on me: %v %s", err, env.Data) } if me.Role != "super_admin" { t.Fatalf("expected super_admin role, got %q", me.Role) } // AC-F-01: list roles env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/roles", nil, superTok) if code != 200 || env.Code != 0 { t.Fatalf("list roles failed http=%d code=%d msg=%s", code, env.Code, env.Message) } var roles struct { Items []struct { ID string `json:"id"` Name string `json:"name"` } `json:"items"` } if err := json.Unmarshal(env.Data, &roles); err != nil || len(roles.Items) == 0 { t.Fatalf("expected roles: %v %s", err, env.Data) } var superRoleID string for _, it := range roles.Items { if it.Name == "super_admin" { superRoleID = it.ID } } if superRoleID == "" { t.Fatal("super_admin role missing") } env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/roles/"+superRoleID, nil, superTok) if code != 200 || env.Code != 0 { t.Fatalf("get role failed http=%d code=%d", code, env.Code) } // Seed limited role + account for deny ACs limitedRoleID := uuid.New() _, err := pool.Exec(ctx, ` INSERT INTO admin_roles(id, name, system) VALUES ($1, $2, false) ON CONFLICT (name) DO NOTHING`, limitedRoleID, "rbac_limited_"+limitedRoleID.String()[:8]) if err != nil { t.Fatalf("insert role: %v", err) } // resolve actual id if conflict var roleName string err = pool.QueryRow(ctx, `SELECT id, name FROM admin_roles WHERE id=$1`, limitedRoleID).Scan(&limitedRoleID, &roleName) if err != nil { t.Fatalf("load limited role: %v", err) } hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost) if err != nil { t.Fatal(err) } limitedUser := fmt.Sprintf("limited_%d", time.Now().UnixNano()) _, err = pool.Exec(ctx, ` INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1, $2, $3)`, limitedUser, string(hash), limitedRoleID) if err != nil { t.Fatalf("insert limited admin: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limitedUser) _, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID) }) limitedTok := adminLogin(t, r, limitedUser, "limited-pass") // AC-S-01: no roles.write → 403 env, code = doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/roles/"+limitedRoleID.String()+"/permissions", map[string]any{"permissions": []string{"admin.users.read"}}, limitedTok) if code != http.StatusForbidden { t.Fatalf("expected 403 roles.write, got http=%d code=%d msg=%s", code, env.Code, env.Message) } // AC-S-02: no membership.grant → 403 _ = mustRegister(t, r) env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users", nil, superTok) if code != 200 { t.Fatalf("list users: %d", code) } var list struct { Items []struct { ID string `json:"id"` } `json:"items"` } _ = json.Unmarshal(env.Data, &list) if len(list.Items) == 0 { t.Fatal("need a user for grant deny") } env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+list.Items[0].ID+"/membership/grant", map[string]string{"plan": "month"}, limitedTok) if code != http.StatusForbidden { t.Fatalf("expected 403 membership.grant, got http=%d code=%d", code, env.Code) } // AC-F-02 + AC-O-01: super replaces permissions want := []string{"admin.users.read", "admin.roles.read"} env, code = doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/roles/"+limitedRoleID.String()+"/permissions", map[string]any{"permissions": want}, superTok) if code != 200 || env.Code != 0 { t.Fatalf("put permissions failed http=%d code=%d msg=%s", code, env.Code, env.Message) } env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/roles/"+limitedRoleID.String(), nil, superTok) if code != 200 { t.Fatalf("get after put: %d", code) } var roleDetail struct { Permissions []string `json:"permissions"` } if err := json.Unmarshal(env.Data, &roleDetail); err != nil { t.Fatal(err) } if len(roleDetail.Permissions) != 2 { t.Fatalf("expected 2 perms, got %#v", roleDetail.Permissions) } env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/audit-logs", nil, superTok) if code != 200 { t.Fatalf("audit: %d", code) } var audit struct { Items []struct { Action string `json:"action"` } `json:"items"` } _ = json.Unmarshal(env.Data, &audit) foundUpdate, foundDeny := false, false for _, it := range audit.Items { if it.Action == "roles.permissions.update" { foundUpdate = true } if it.Action == "permission.denied" { foundDeny = true } } if !foundUpdate { t.Fatal("expected roles.permissions.update audit") } if !foundDeny { t.Fatal("expected permission.denied audit") } // AC-S-04: system role cannot be deleted (FK + system seed; no Delete API) tag, err := pool.Exec(ctx, `DELETE FROM admin_roles WHERE name='super_admin'`) if err == nil && tag.RowsAffected() > 0 { t.Fatal("expected delete super_admin to fail or affect 0 rows") } // AC-P-01: list roles under 500ms locally start := time.Now() env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/roles", nil, superTok) if code != 200 || time.Since(start) > 500*time.Millisecond { t.Fatalf("AC-P-01 list roles slow or failed: http=%d dur=%v", code, time.Since(start)) } }