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 反超,名次与分数都变。 // 已发布成绩必须先撤销发布才能改(服务端拒绝直接覆盖 published 行), // 这里直接把状态置回 draft,等价于撤销发布但不额外留一条 unpublish 版本。 env.exec(t, `UPDATE results SET state = 'draft' WHERE event_id = ?`, ids.event) 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) } }