Files
Scoreboard/server/internal/httpapi/users_revisions_test.go
T

289 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}