289 lines
13 KiB
Go
289 lines
13 KiB
Go
package httpapi_test
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"testing"
|
||
)
|
||
|
||
// 用户管理是系统配置:guest 读 403、写 401,recorder 一律 403(见 devdocs/08 第 2 节)。
|
||
func TestUsersAdminOnly(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.seed(t)
|
||
|
||
guest, _ := env.client()
|
||
status, body := env.do(guest, http.MethodGet, "/api/users", nil, false)
|
||
if status != http.StatusForbidden || body.Error == nil || body.Error.Code != "FORBIDDEN" {
|
||
t.Fatalf("guest 读用户列表应 403 FORBIDDEN,得到 %d %+v", status, body.Error)
|
||
}
|
||
status, body = env.do(guest, http.MethodPost, "/api/users", map[string]any{
|
||
"username": "hacker", "role": "admin", "password": "password-1",
|
||
}, false)
|
||
if status != http.StatusUnauthorized || body.Error == nil || body.Error.Code != "AUTH_REQUIRED" {
|
||
t.Fatalf("guest 建用户应 401 AUTH_REQUIRED,得到 %d %+v", status, body.Error)
|
||
}
|
||
if status, _ := env.do(guest, http.MethodPost, "/api/users/1/password", map[string]any{}, false); status != http.StatusUnauthorized {
|
||
t.Fatalf("guest 重置密码应 401,得到 %d", status)
|
||
}
|
||
|
||
env.createUser("recorder", "recorder-pass", "recorder")
|
||
client, _ := env.client()
|
||
env.login(client, "recorder", "recorder-pass")
|
||
|
||
if status, body := env.do(client, http.MethodGet, "/api/users", nil, false); status != http.StatusForbidden || body.Error == nil || body.Error.Code != "FORBIDDEN" {
|
||
t.Fatalf("recorder 读用户列表应 403,得到 %d %+v", status, body.Error)
|
||
}
|
||
if status, _ := env.do(client, http.MethodPost, "/api/users", map[string]any{
|
||
"username": "someone", "role": "recorder", "password": "password-1",
|
||
}, true); status != http.StatusForbidden {
|
||
t.Fatalf("recorder 建用户应 403,得到 %d", status)
|
||
}
|
||
if status, _ := env.do(client, http.MethodPut, "/api/users/1", map[string]any{"active": false}, true); status != http.StatusForbidden {
|
||
t.Fatalf("recorder 停用用户应 403,得到 %d", status)
|
||
}
|
||
}
|
||
|
||
// admin 建用户、改资料、重置密码、停用;并拒绝把自己降级或停用。
|
||
func TestAdminManagesUsers(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
adminID := env.createUser("admin", "admin-pass", "admin")
|
||
admin, _ := env.client()
|
||
env.login(admin, "admin", "admin-pass")
|
||
|
||
status, body := env.do(admin, http.MethodGet, "/api/users", nil, false)
|
||
if status != http.StatusOK || !body.OK {
|
||
t.Fatalf("admin 读用户列表应 200,得到 %d %+v", status, body.Error)
|
||
}
|
||
users := decodeData[[]map[string]any](t, body)
|
||
if len(users) != 1 || users[0]["username"] != "admin" || users[0]["role"] != "admin" {
|
||
t.Fatalf("用户列表内容不对: %+v", users)
|
||
}
|
||
if users[0]["lastLoginAt"] == nil {
|
||
t.Fatalf("登录后应回填 lastLoginAt: %+v", users[0])
|
||
}
|
||
|
||
status, body = env.do(admin, http.MethodPost, "/api/users", map[string]any{
|
||
"username": "zhang", "displayName": "张三", "role": "recorder", "password": "init-pass-1",
|
||
}, true)
|
||
if status != http.StatusOK || !body.OK {
|
||
t.Fatalf("建用户应 200,得到 %d %+v", status, body.Error)
|
||
}
|
||
created := decodeData[map[string]any](t, body)
|
||
newID := int64(created["id"].(float64))
|
||
if created["username"] != "zhang" || created["displayName"] != "张三" ||
|
||
created["role"] != "recorder" || created["active"] != true || created["mustChangePassword"] != true {
|
||
t.Fatalf("新建用户返回不对: %+v", created)
|
||
}
|
||
|
||
if status, body := env.do(admin, http.MethodPost, "/api/users", map[string]any{
|
||
"username": "zhang", "role": "recorder", "password": "init-pass-1",
|
||
}, true); status != http.StatusConflict || body.Error == nil || body.Error.Code != "CONFLICT" {
|
||
t.Fatalf("重名用户应 409 CONFLICT,得到 %d %+v", status, body.Error)
|
||
}
|
||
if status, _ := env.do(admin, http.MethodPost, "/api/users", map[string]any{
|
||
"username": "weak", "role": "recorder", "password": "123",
|
||
}, true); status != http.StatusBadRequest {
|
||
t.Fatalf("弱密码应 400,得到 %d", status)
|
||
}
|
||
if status, _ := env.do(admin, http.MethodPost, "/api/users", map[string]any{
|
||
"username": "guestish", "role": "guest", "password": "password-1",
|
||
}, true); status != http.StatusBadRequest {
|
||
t.Fatalf("role=guest 应 400,得到 %d", status)
|
||
}
|
||
|
||
// 新用户拿到初始密码即可登录。
|
||
sub, _ := env.client()
|
||
env.login(sub, "zhang", "init-pass-1")
|
||
|
||
// 改资料:displayName + role。
|
||
status, body = env.do(admin, http.MethodPut, fmt.Sprintf("/api/users/%d", newID), map[string]any{
|
||
"displayName": "张三丰", "role": "admin",
|
||
}, true)
|
||
if status != http.StatusOK || !body.OK {
|
||
t.Fatalf("改用户应 200,得到 %d %+v", status, body.Error)
|
||
}
|
||
updated := decodeData[map[string]any](t, body)
|
||
if updated["displayName"] != "张三丰" || updated["role"] != "admin" || updated["active"] != true {
|
||
t.Fatalf("改用户返回不对: %+v", updated)
|
||
}
|
||
|
||
// 管理员重置密码:留空随机生成,返回一次性明文。
|
||
status, body = env.do(admin, http.MethodPost, fmt.Sprintf("/api/users/%d/password", newID), map[string]any{}, true)
|
||
if status != http.StatusOK || !body.OK {
|
||
t.Fatalf("重置密码应 200,得到 %d %+v", status, body.Error)
|
||
}
|
||
reset := decodeData[map[string]any](t, body)
|
||
password, _ := reset["password"].(string)
|
||
if reset["generated"] != true || len(password) < 12 || reset["mustChangePassword"] != true {
|
||
t.Fatalf("随机密码返回不对: %+v", reset)
|
||
}
|
||
// 旧密码立即失效,新密码可用;被重置者的会话已被作废。
|
||
if status, _ := env.do(sub, http.MethodPost, "/api/auth/login", map[string]any{
|
||
"username": "zhang", "password": "init-pass-1",
|
||
}, false); status != http.StatusUnauthorized {
|
||
t.Fatalf("旧密码应失效(401),得到 %d", status)
|
||
}
|
||
sub2, _ := env.client()
|
||
env.login(sub2, "zhang", password)
|
||
|
||
// 停用后不能登录。
|
||
status, body = env.do(admin, http.MethodPut, fmt.Sprintf("/api/users/%d", newID), map[string]any{"active": false}, true)
|
||
if status != http.StatusOK || !body.OK {
|
||
t.Fatalf("停用用户应 200,得到 %d %+v", status, body.Error)
|
||
}
|
||
if disabled := decodeData[map[string]any](t, body); disabled["active"] != false {
|
||
t.Fatalf("停用后 active 应为 false: %+v", disabled)
|
||
}
|
||
sub3, _ := env.client()
|
||
if status, _ := env.do(sub3, http.MethodPost, "/api/auth/login", map[string]any{
|
||
"username": "zhang", "password": password,
|
||
}, false); status != http.StatusUnauthorized {
|
||
t.Fatalf("停用用户登录应 401,得到 %d", status)
|
||
}
|
||
|
||
// 不能把自己降级或停用。
|
||
status, body = env.do(admin, http.MethodPut, fmt.Sprintf("/api/users/%d", adminID), map[string]any{"role": "recorder"}, true)
|
||
if status != http.StatusBadRequest || body.Error == nil || body.Error.Code != "VALIDATION_FAILED" {
|
||
t.Fatalf("自己降级应 400 VALIDATION_FAILED,得到 %d %+v", status, body.Error)
|
||
}
|
||
if status, _ := env.do(admin, http.MethodPut, fmt.Sprintf("/api/users/%d", adminID), map[string]any{"active": false}, true); status != http.StatusBadRequest {
|
||
t.Fatalf("自己停用应 400,得到 %d", status)
|
||
}
|
||
// 只改自己的显示名仍然允许。
|
||
status, body = env.do(admin, http.MethodPut, fmt.Sprintf("/api/users/%d", adminID), map[string]any{"displayName": "总管理员"}, true)
|
||
if status != http.StatusOK || !body.OK {
|
||
t.Fatalf("改自己显示名应 200,得到 %d %+v", status, body.Error)
|
||
}
|
||
}
|
||
|
||
// 变更历史:recorder 可读列表与详情,guest 403;详情含快照与成绩/分数差异。
|
||
func TestRevisionsListAndDetail(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
ids := env.seed(t)
|
||
env.createUser("recorder", "recorder-pass", "recorder")
|
||
client, _ := env.client()
|
||
env.login(client, "recorder", "recorder-pass")
|
||
|
||
guest, _ := env.client()
|
||
if status, body := env.do(guest, http.MethodGet, "/api/revisions", nil, false); status != http.StatusForbidden || body.Error == nil || body.Error.Code != "FORBIDDEN" {
|
||
t.Fatalf("guest 读变更历史应 403,得到 %d %+v", status, body.Error)
|
||
}
|
||
if status, _ := env.do(guest, http.MethodGet, "/api/revisions/1", nil, false); status != http.StatusForbidden {
|
||
t.Fatalf("guest 读变更详情应 403,得到 %d", status)
|
||
}
|
||
|
||
// 第一次发布:A 10 分、B 8 分。
|
||
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||
"eventId": ids.event,
|
||
"results": []map[string]any{
|
||
{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||
{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||
},
|
||
}, true)
|
||
status, body := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{"eventId": ids.event}, true)
|
||
if status != http.StatusOK || !body.OK {
|
||
t.Fatalf("发布应 200,得到 %d %+v", status, body.Error)
|
||
}
|
||
|
||
status, body = env.do(client, http.MethodGet, fmt.Sprintf("/api/revisions?eventId=%d&limit=10", ids.event), nil, false)
|
||
if status != http.StatusOK || !body.OK {
|
||
t.Fatalf("读变更列表应 200,得到 %d %+v", status, body.Error)
|
||
}
|
||
page := decodeData[map[string]any](t, body)
|
||
items := page["items"].([]any)
|
||
if len(items) != 1 {
|
||
t.Fatalf("首次发布后应有 1 条变更记录,得到 %d", len(items))
|
||
}
|
||
first := items[0].(map[string]any)
|
||
if first["action"] != "publish" || first["eventName"] != "100米跑" || first["actorName"] != "recorder" {
|
||
t.Fatalf("变更列表字段不对: %+v", first)
|
||
}
|
||
if first["changedCount"].(float64) != 2 {
|
||
t.Fatalf("首次发布应记 2 条成绩变化,得到 %v", first["changedCount"])
|
||
}
|
||
if first["parentId"] != nil {
|
||
t.Fatalf("首条记录没有父节点: %+v", first)
|
||
}
|
||
firstID := int64(first["id"].(float64))
|
||
|
||
status, body = env.do(client, http.MethodGet, fmt.Sprintf("/api/revisions/%d", firstID), nil, false)
|
||
if status != http.StatusOK || !body.OK {
|
||
t.Fatalf("读变更详情应 200,得到 %d %+v", status, body.Error)
|
||
}
|
||
detail := decodeData[map[string]any](t, body)
|
||
if snapshot := detail["snapshot"].([]any); len(snapshot) != 2 {
|
||
t.Fatalf("快照应含 2 条成绩,得到 %d", len(snapshot))
|
||
}
|
||
diff := detail["diff"].(map[string]any)
|
||
changes := diff["results"].([]any)
|
||
if len(changes) != 2 || changes[0].(map[string]any)["change"] != "added" {
|
||
t.Fatalf("首次发布的差异应为 2 条 added: %+v", changes)
|
||
}
|
||
teams := diff["teams"].([]any)
|
||
if len(teams) != 2 {
|
||
t.Fatalf("应涉及 2 个单位,得到 %d", len(teams))
|
||
}
|
||
got := map[string]map[string]any{}
|
||
for _, raw := range teams {
|
||
m := raw.(map[string]any)
|
||
got[m["teamCode"].(string)] = m
|
||
}
|
||
if got["A"]["pointsAfter"].(float64) != 10 || got["A"]["pointsDelta"].(float64) != 10 {
|
||
t.Fatalf("A 单位分数不对: %+v", got["A"])
|
||
}
|
||
if got["B"]["pointsAfter"].(float64) != 8 || got["B"]["pointsDelta"].(float64) != 8 {
|
||
t.Fatalf("B 单位分数不对: %+v", got["B"])
|
||
}
|
||
|
||
// 第二次发布:A 12.00 变慢,B 反超,名次与分数都变。
|
||
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||
"eventId": ids.event,
|
||
"results": []map[string]any{
|
||
{"entryId": ids.entryA, "resultValue": 12.00, "resultText": "12.00", "unit": "s", "status": "ok"},
|
||
{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||
},
|
||
}, true)
|
||
status, body = env.do(client, http.MethodPost, "/api/results/publish", map[string]any{"eventId": ids.event}, true)
|
||
if status != http.StatusOK || !body.OK {
|
||
t.Fatalf("第二次发布应 200,得到 %d %+v", status, body.Error)
|
||
}
|
||
|
||
status, body = env.do(client, http.MethodGet, fmt.Sprintf("/api/revisions?eventId=%d&limit=10", ids.event), nil, false)
|
||
items = decodeData[map[string]any](t, body)["items"].([]any)
|
||
if len(items) != 2 {
|
||
t.Fatalf("两次发布后应有 2 条变更记录,得到 %d", len(items))
|
||
}
|
||
second := items[0].(map[string]any)
|
||
if second["changedCount"].(float64) != 2 {
|
||
t.Fatalf("第二次发布应记 2 条成绩变化,得到 %v", second["changedCount"])
|
||
}
|
||
secondID := int64(second["id"].(float64))
|
||
|
||
status, body = env.do(client, http.MethodGet, fmt.Sprintf("/api/revisions/%d", secondID), nil, false)
|
||
detail = decodeData[map[string]any](t, body)
|
||
diff = detail["diff"].(map[string]any)
|
||
changes = diff["results"].([]any)
|
||
if len(changes) != 2 || changes[0].(map[string]any)["change"] != "changed" {
|
||
t.Fatalf("第二次发布的差异应为 changed: %+v", changes)
|
||
}
|
||
if fields := changes[0].(map[string]any)["fields"].([]any); len(fields) == 0 {
|
||
t.Fatalf("changed 记录应给出变化字段: %+v", changes[0])
|
||
}
|
||
teams = diff["teams"].([]any)
|
||
got = map[string]map[string]any{}
|
||
for _, raw := range teams {
|
||
m := raw.(map[string]any)
|
||
got[m["teamCode"].(string)] = m
|
||
}
|
||
if got["A"]["pointsDelta"].(float64) != -2 || got["B"]["pointsDelta"].(float64) != 2 {
|
||
t.Fatalf("第二次发布的分数增减不对: A=%+v B=%+v", got["A"], got["B"])
|
||
}
|
||
|
||
// 分页:limit/offset 生效。
|
||
status, body = env.do(client, http.MethodGet, fmt.Sprintf("/api/revisions?eventId=%d&limit=1&offset=1", ids.event), nil, false)
|
||
page = decodeData[map[string]any](t, body)
|
||
if page["total"].(float64) != 2 || page["limit"].(float64) != 1 || len(page["items"].([]any)) != 1 {
|
||
t.Fatalf("分页参数未生效: %+v", page)
|
||
}
|
||
}
|