避免 roles.write 绕过全部 can();定价读写挂 membership.plans 权限;去掉快捷封禁双路径并让 ban/unban 走 lifecycle。 Co-authored-by: Cursor <cursoragent@cursor.com>
215 lines
6.7 KiB
Go
215 lines
6.7 KiB
Go
package integration_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"github.com/yuxingu/digital-psychology/apps/api/internal/config"
|
|
"github.com/yuxingu/digital-psychology/apps/api/internal/db"
|
|
)
|
|
|
|
func TestOpsESystemRBACBanPush(t *testing.T) {
|
|
r, _ := setupAPI(t)
|
|
|
|
env, code := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/auth/login", map[string]string{
|
|
"username": "admin",
|
|
"password": "change-me",
|
|
}, "")
|
|
if code != 200 || env.Code != 0 {
|
|
t.Fatalf("super login failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
|
}
|
|
var login struct {
|
|
Token string `json:"token"`
|
|
Admin struct {
|
|
ID string `json:"id"`
|
|
Role string `json:"role"`
|
|
} `json:"admin"`
|
|
}
|
|
if err := json.Unmarshal(env.Data, &login); err != nil || login.Token == "" {
|
|
t.Fatalf("login parse: %v %s", err, env.Data)
|
|
}
|
|
if login.Admin.Role != "super" {
|
|
t.Fatalf("bootstrap admin role want super got %q", login.Admin.Role)
|
|
}
|
|
superTok := login.Token
|
|
|
|
opsUser := "ops_" + uuid.NewString()[:8]
|
|
opsPass := "ops-pass-1"
|
|
insertOpsAdmin(t, opsUser, opsPass)
|
|
|
|
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/auth/login", map[string]string{
|
|
"username": opsUser,
|
|
"password": opsPass,
|
|
}, "")
|
|
if code != 200 || env.Code != 0 {
|
|
t.Fatalf("ops login failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
|
}
|
|
_ = json.Unmarshal(env.Data, &login)
|
|
opsTok := login.Token
|
|
if login.Admin.Role != "ops" {
|
|
t.Fatalf("ops role want ops got %q", login.Admin.Role)
|
|
}
|
|
|
|
env, code = doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/membership/plan-prices", map[string]any{
|
|
"items": []map[string]any{{"plan": "month", "display_cents": 990}},
|
|
}, opsTok)
|
|
if code != http.StatusForbidden || env.Code != 40301 {
|
|
t.Fatalf("ops put prices want 40301, got http=%d code=%d", code, env.Code)
|
|
}
|
|
|
|
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/admins", nil, opsTok)
|
|
if code != http.StatusForbidden || env.Code != 40301 {
|
|
t.Fatalf("ops list admins want 40301, got http=%d code=%d", code, env.Code)
|
|
}
|
|
|
|
key := mustRegister(t, r)
|
|
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users", nil, opsTok)
|
|
if code != 200 || env.Code != 0 {
|
|
t.Fatalf("ops list users failed http=%d code=%d", code, env.Code)
|
|
}
|
|
var list struct {
|
|
Items []struct {
|
|
ID string `json:"id"`
|
|
} `json:"items"`
|
|
}
|
|
if err := json.Unmarshal(env.Data, &list); err != nil || len(list.Items) == 0 {
|
|
t.Fatalf("users: %v %s", err, env.Data)
|
|
}
|
|
userID := list.Items[0].ID
|
|
|
|
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/membership/grant", map[string]string{
|
|
"plan": "month",
|
|
}, opsTok)
|
|
if code != http.StatusForbidden || env.Code != 40301 {
|
|
t.Fatalf("ops grant want 40301, got http=%d code=%d", code, env.Code)
|
|
}
|
|
|
|
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/ban", nil, opsTok)
|
|
if code != 200 || env.Code != 0 {
|
|
t.Fatalf("ban failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
|
}
|
|
|
|
env, _, httpStatus := doJSONExpect(t, r, http.MethodGet, "/api/v1/profiles", nil, key, 40310)
|
|
if httpStatus != http.StatusForbidden {
|
|
t.Fatalf("banned device want HTTP 403, got %d code=%d", httpStatus, env.Code)
|
|
}
|
|
|
|
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/unban", nil, opsTok)
|
|
if code != 200 || env.Code != 0 {
|
|
t.Fatalf("unban failed http=%d code=%d", code, env.Code)
|
|
}
|
|
_, key = doJSON(t, r, http.MethodGet, "/api/v1/profiles", nil, key)
|
|
|
|
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/push-jobs", map[string]string{
|
|
"title": "测试推送",
|
|
"body": "不下发",
|
|
}, opsTok)
|
|
if code != 200 || env.Code != 0 {
|
|
t.Fatalf("create push failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
|
}
|
|
var job struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
}
|
|
if err := json.Unmarshal(env.Data, &job); err != nil || job.ID == "" || job.Status != "draft" {
|
|
t.Fatalf("push job: %v %s", err, env.Data)
|
|
}
|
|
env, code = doAdminJSON(t, r, http.MethodPatch, "/api/v1/admin/push-jobs/"+job.ID, map[string]string{
|
|
"status": "cancelled",
|
|
}, opsTok)
|
|
if code != 200 || env.Code != 0 {
|
|
t.Fatalf("cancel push failed http=%d code=%d", code, env.Code)
|
|
}
|
|
|
|
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/push-jobs", map[string]string{
|
|
"title": strings.Repeat("超", 129),
|
|
"body": "too long",
|
|
}, opsTok)
|
|
if code != http.StatusBadRequest || env.Code != 40055 {
|
|
t.Fatalf("long title want 40055, got http=%d code=%d", code, env.Code)
|
|
}
|
|
|
|
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/admins", nil, superTok)
|
|
if code != 200 || env.Code != 0 {
|
|
t.Fatalf("list admins as super failed")
|
|
}
|
|
var admins struct {
|
|
Items []struct {
|
|
ID string `json:"id"`
|
|
Role string `json:"role"`
|
|
} `json:"items"`
|
|
}
|
|
if err := json.Unmarshal(env.Data, &admins); err != nil {
|
|
t.Fatalf("admins parse: %v", err)
|
|
}
|
|
var soleSuper string
|
|
superCount := 0
|
|
for _, a := range admins.Items {
|
|
if a.Role == "super" {
|
|
superCount++
|
|
soleSuper = a.ID
|
|
}
|
|
}
|
|
if superCount != 1 || soleSuper == "" {
|
|
t.Fatalf("want exactly 1 super, got %d", superCount)
|
|
}
|
|
env, code = doAdminJSON(t, r, http.MethodPatch, "/api/v1/admin/admins/"+soleSuper, map[string]string{
|
|
"role": "ops",
|
|
}, superTok)
|
|
if code != http.StatusConflict || env.Code != 40901 {
|
|
t.Fatalf("demote last super want 40901, got http=%d code=%d msg=%s", code, env.Code, env.Message)
|
|
}
|
|
|
|
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/me", nil, superTok)
|
|
if code != 200 || env.Code != 0 {
|
|
t.Fatalf("super me failed")
|
|
}
|
|
_ = env
|
|
}
|
|
|
|
func insertOpsAdmin(t *testing.T, username, password string) {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
cfg := config.Load()
|
|
pool, err := db.Connect(ctx, cfg.DatabaseURL)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
defer pool.Close()
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
t.Fatalf("hash: %v", err)
|
|
}
|
|
var roleID uuid.UUID
|
|
err = pool.QueryRow(ctx, `
|
|
INSERT INTO admin_roles(name, system)
|
|
VALUES ('ops', false)
|
|
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
|
|
RETURNING id`).Scan(&roleID)
|
|
if err != nil {
|
|
t.Fatalf("ensure ops role: %v", err)
|
|
}
|
|
_, err = pool.Exec(ctx, `
|
|
INSERT INTO admin_role_permissions(role_id, code) VALUES
|
|
($1, 'admin.users.read'),
|
|
($1, 'admin.users.status.write')
|
|
ON CONFLICT DO NOTHING`, roleID)
|
|
if err != nil {
|
|
t.Fatalf("ops role perms: %v", err)
|
|
}
|
|
_, err = pool.Exec(ctx, `
|
|
INSERT INTO admin_accounts(username, password_hash, role, status, role_id)
|
|
VALUES ($1,$2,'ops','active',$3)`, username, string(hash), roleID)
|
|
if err != nil {
|
|
t.Fatalf("insert ops admin: %v", err)
|
|
}
|
|
}
|