角色权限、RequirePermission、/me permissions 与 migration 000015; Reviewer Approve → Closed。Next:ECR-013B Contract Definition。 Co-authored-by: Cursor <cursoragent@cursor.com>
238 lines
7.5 KiB
Go
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))
|
|
}
|
|
}
|