feat(ECR-010): Ops-E 系统运营;修复登出解绑;P2 Complete
落地管理员 RBAC/封禁/推送任务 stub,logout 解绑 device 并统一各页 ensureAccount,同时收口 P2 生日生成与状态文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Logout must revoke session AND unbind device so refresh is not still "logged in".
|
||||
func TestAuthLogoutUnbindsDevice(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
key := mustRegister(t, r)
|
||||
|
||||
_, key = doJSON(t, r, http.MethodGet, "/api/v1/auth/me", nil, key)
|
||||
|
||||
_, key = doJSON(t, r, http.MethodPost, "/api/v1/auth/logout", nil, key)
|
||||
testBearer = ""
|
||||
|
||||
_, _, status := doJSONExpect(t, r, http.MethodGet, "/api/v1/auth/me", nil, key, 40112)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Fatalf("after logout GET /auth/me want HTTP 401, got %d", status)
|
||||
}
|
||||
|
||||
_, key, _ = doJSONExpect(t, r, http.MethodGet, "/api/v1/profiles", nil, key, 40112)
|
||||
_ = key
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_accounts(username, password_hash, role, status)
|
||||
VALUES ($1,$2,'ops','active')`, username, string(hash))
|
||||
if err != nil {
|
||||
t.Fatalf("insert ops admin: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user