feat(ECR-006): 落地运营后台 Phase A(admin API + admin-h5)
新增独立鉴权的 /api/v1/admin 与 Vue 控制台;会员授予与审计同事务,并补集成/单测。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func doAdminJSON(t *testing.T, r http.Handler, method, path string, body any, token string) (envelope, int) {
|
||||
t.Helper()
|
||||
auth := ""
|
||||
if token != "" {
|
||||
auth = "Bearer " + token
|
||||
}
|
||||
return doAdminAuth(t, r, method, path, body, auth)
|
||||
}
|
||||
|
||||
func doAdminAuth(t *testing.T, r http.Handler, method, path string, body any, authorization string) (envelope, int) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
if err := json.NewEncoder(&buf).Encode(body); err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest(method, path, &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if authorization != "" {
|
||||
req.Header.Set("Authorization", authorization)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
var env envelope
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &env)
|
||||
return env, w.Code
|
||||
}
|
||||
|
||||
func TestAdminOpsPhaseA(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users", nil, "")
|
||||
if code != http.StatusUnauthorized || env.Code == 0 {
|
||||
t.Fatalf("expected 401 without token, got http=%d code=%d", code, env.Code)
|
||||
}
|
||||
|
||||
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("login failed http=%d code=%d msg=%s body=%s", code, env.Code, env.Message, string(env.Data))
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
_, _ = doJSON(t, r, http.MethodGet, "/api/v1/profiles", nil, "")
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users", nil, login.Token)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list users failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
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("expected users, got %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",
|
||||
}, login.Token)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("grant failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
|
||||
// Atomicity: membership active AND audit row for same grant.
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users/"+userID, nil, login.Token)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("get user failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
var detail struct {
|
||||
Membership struct {
|
||||
Active bool `json:"active"`
|
||||
Status string `json:"status"`
|
||||
} `json:"membership"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &detail); err != nil || !detail.Membership.Active {
|
||||
t.Fatalf("expected active membership after grant: %v %s", err, env.Data)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/audit-logs", nil, login.Token)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("audit failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
var audit struct {
|
||||
Items []struct {
|
||||
Action string `json:"action"`
|
||||
TargetID string `json:"target_id"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &audit); err != nil || len(audit.Items) == 0 {
|
||||
t.Fatalf("expected audit rows: %v %s", err, env.Data)
|
||||
}
|
||||
if audit.Items[0].Action != "membership.grant" || audit.Items[0].TargetID != userID {
|
||||
t.Fatalf("unexpected audit %#v", audit.Items[0])
|
||||
}
|
||||
|
||||
// Logout with lowercase bearer must invalidate session.
|
||||
env, code = doAdminAuth(t, r, http.MethodPost, "/api/v1/admin/auth/logout", nil, "bearer "+login.Token)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("logout failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/me", nil, login.Token)
|
||||
if code != http.StatusUnauthorized || env.Code == 0 {
|
||||
t.Fatalf("expected 401 after logout, got http=%d code=%d", code, env.Code)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ func setupAPI(t *testing.T) (*gin.Engine, string) {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user