Files
digital-psychology/apps/api/internal/integration/admin_rbac_test.go
T
jackyu66gitandCursor b5a05941d9 feat(ECR-013A): Admin RBAC 实现并 Closed
角色权限、RequirePermission、/me permissions 与 migration 000015;
Reviewer Approve → Closed。Next:ECR-013B Contract Definition。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 17:19:45 +08:00

238 lines
7.5 KiB
Go

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))
}
}