import { afterEach, describe, expect, it, vi } from "vitest"; import { adminApi, ApiError, setCsrfToken } from "../api/client"; describe("adminApi", () => { afterEach(() => { vi.unstubAllGlobals(); setCsrfToken(); }); it("会话恢复不依赖 csrfToken,且所有请求都携带同源凭据", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response( JSON.stringify({ authenticated: true, operatorName: "owner", role: "SUPER_ADMIN", }), { status: 200, headers: { "Content-Type": "application/json" } }, ), ); vi.stubGlobal("fetch", fetchMock); const session = await adminApi.session(); expect(fetchMock).toHaveBeenCalledOnce(); expect(session).toEqual({ authenticated: true, operatorName: "owner", role: "SUPER_ADMIN", }); expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ credentials: "include", }); }); it("积分赠送携带 CSRF 与幂等请求头", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response( JSON.stringify({ transactionId: "tx-1", balanceAfter: 120 }), { status: 200, headers: { "Content-Type": "application/json" } }, ), ); vi.stubGlobal("fetch", fetchMock); setCsrfToken("csrf-test"); await adminApi.grantCredits({ userId: "user-1", amount: 20, reason: "客服补偿", idempotencyKey: "grant-1", }); const request = fetchMock.mock.calls[0]?.[1] as RequestInit; const headers = request.headers as Headers; expect(headers.get("X-CSRF-Token")).toBe("csrf-test"); expect(headers.get("Idempotency-Key")).toBe("grant-1"); expect(request.credentials).toBe("include"); }); it("内容发布请求携带 CSRF 并编码 Skill ID", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(null, { status: 204 }), ); vi.stubGlobal("fetch", fetchMock); setCsrfToken("csrf-content"); await adminApi.setContentSkillEnabled("official.skill/with space", true); expect(fetchMock.mock.calls[0]?.[0]).toBe( "/v1/admin/content/skills/official.skill%2Fwith%20space/enable", ); const request = fetchMock.mock.calls[0]?.[1] as RequestInit; expect((request.headers as Headers).get("X-CSRF-Token")).toBe("csrf-content"); expect(request.method).toBe("POST"); }); it("登录请求不依赖已有会话 CSRF", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response( JSON.stringify({ operatorName: "owner", role: "SUPER_ADMIN", csrfToken: "new-csrf", }), { status: 200, headers: { "Content-Type": "application/json" } }, ), ); vi.stubGlobal("fetch", fetchMock); const response = await adminApi.login( "owner", "a-strong-password", "123456", ); const request = fetchMock.mock.calls[0]?.[1] as RequestInit; expect((request.headers as Headers).has("X-CSRF-Token")).toBe(false); expect(response.csrfToken).toBe("new-csrf"); }); it("流水与管理员列表把 cursor 安全传入查询参数", async () => { const fetchMock = vi.fn().mockImplementation(async () => { return new Response(JSON.stringify({ items: [] }), { status: 200, headers: { "Content-Type": "application/json" }, }); }); vi.stubGlobal("fetch", fetchMock); await adminApi.latestLedger("latest+/="); await adminApi.ledger("user/with space", "ledger+/="); await adminApi.operators("operator+/="); expect(fetchMock.mock.calls[0]?.[0]).toBe( "/v1/admin/credits/ledger?cursor=latest%2B%2F%3D", ); expect(fetchMock.mock.calls[1]?.[0]).toBe( "/v1/admin/users/user%2Fwith%20space/ledger?cursor=ledger%2B%2F%3D", ); expect(fetchMock.mock.calls[2]?.[0]).toBe( "/v1/admin/operators?cursor=operator%2B%2F%3D", ); }); it("typed query 编码日期、筛选、排序与布尔值", async () => { const fetchMock = vi.fn().mockImplementation(async () => new Response(JSON.stringify({ items: [] }), { status: 200, headers: { "Content-Type": "application/json" }, }), ); vi.stubGlobal("fetch", fetchMock); await adminApi.users({ q: "user + value", from: "2026-08-01T00:00:00.000Z", until: "2026-08-20T23:59:59.999Z", status: "suspended", sort: "createdAt", order: "asc", limit: 25, }); await adminApi.operators({ enabled: false, locked: true, sort: "username", order: "desc", }); expect(fetchMock.mock.calls[0]?.[0]).toContain( "q=user+%2B+value&from=2026-08-01T00%3A00%3A00.000Z", ); expect(fetchMock.mock.calls[0]?.[0]).toContain( "status=suspended&sort=createdAt&order=asc&limit=25", ); expect(fetchMock.mock.calls[1]?.[0]).toBe( "/v1/admin/operators?enabled=false&locked=true&sort=username&order=desc", ); }); it("流水查询编码三维筛选、关联 ID 与金额排序", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ items: [] }), { status: 200, headers: { "Content-Type": "application/json" }, }), ); vi.stubGlobal("fetch", fetchMock); await adminApi.latestLedger({ cursor: "next+/=", limit: 50, from: "2026-08-01T00:00:00.000Z", until: "2026-08-21T00:00:00.000Z", type: "settle", entryType: "USAGE_SETTLE", usageType: "hotword", referenceId: "11111111-1111-4111-8111-111111111111", sort: "amount", order: "asc", }); const url = String(fetchMock.mock.calls[0]?.[0]); expect(url).toContain("cursor=next%2B%2F%3D&limit=50"); expect(url).toContain("type=settle&entryType=USAGE_SETTLE&usageType=hotword"); expect(url).toContain( "referenceId=11111111-1111-4111-8111-111111111111&sort=amount&order=asc", ); }); it("缺少 CSRF 时在发送变更请求前失败", async () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); await expect(adminApi.logout()).rejects.toMatchObject({ code: "CSRF_TOKEN_MISSING", status: 403, }); expect(fetchMock).not.toHaveBeenCalled(); }); it("服务错误转换为稳定 ApiError", async () => { vi.stubGlobal( "fetch", vi.fn().mockResolvedValue( new Response(JSON.stringify({ code: "RATE_LIMITED" }), { status: 429, headers: { "Content-Type": "application/json" }, }), ), ); const promise = adminApi.overview("30d"); await expect(promise).rejects.toBeInstanceOf(ApiError); await expect(promise).rejects.toMatchObject({ code: "RATE_LIMITED", message: "操作过于频繁,请稍后再试", status: 429, }); }); it("幂等冲突显示可操作的审计提示", async () => { vi.stubGlobal( "fetch", vi.fn().mockResolvedValue( new Response(JSON.stringify({ code: "IDEMPOTENCY_CONFLICT" }), { status: 409, headers: { "Content-Type": "application/json" }, }), ), ); setCsrfToken("csrf-test"); await expect( adminApi.grantCredits({ userId: "user-1", amount: 20, reason: "客服补偿", idempotencyKey: "grant-conflict-1", }), ).rejects.toMatchObject({ code: "IDEMPOTENCY_CONFLICT", message: "该赠送请求与已有记录冲突,请核对审计日志", status: 409, }); }); it("创建管理员携带 CSRF 并返回一次性 TOTP 配置", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response( JSON.stringify({ operatorId: "operator-1", totpSecret: "JBSWY3DPEHPK3PXP", otpauthUri: "otpauth://totp/OSG:operator", }), { status: 201, headers: { "Content-Type": "application/json" } }, ), ); vi.stubGlobal("fetch", fetchMock); setCsrfToken("csrf-test"); const result = await adminApi.createOperator({ username: "support", password: "a-strong-password", role: "SUPPORT", }); const request = fetchMock.mock.calls[0]?.[1] as RequestInit; expect((request.headers as Headers).get("X-CSRF-Token")).toBe("csrf-test"); expect(request.body).toBe( JSON.stringify({ username: "support", password: "a-strong-password", role: "SUPPORT", }), ); expect(result.totpSecret).toBe("JBSWY3DPEHPK3PXP"); }); it("Provider API Key 替换请求携带 CSRF 且只提交新 Key", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response( JSON.stringify({ providerId: "deepseek", configured: true, source: "RUNTIME_OVERRIDE", updatedAt: "2026-08-22T08:00:00Z", }), { status: 200, headers: { "Content-Type": "application/json" } }, ), ); vi.stubGlobal("fetch", fetchMock); setCsrfToken("csrf-provider"); await adminApi.updateProviderApiKey("deepseek", { apiKey: "new-provider-key" }); expect(fetchMock.mock.calls[0]?.[0]).toBe( "/v1/admin/providers/deepseek/api-key", ); const request = fetchMock.mock.calls[0]?.[1] as RequestInit; expect(request.method).toBe("PUT"); expect((request.headers as Headers).get("X-CSRF-Token")).toBe("csrf-provider"); expect(request.body).toBe(JSON.stringify({ apiKey: "new-provider-key" })); }); it("Provider API Key 查看请求携带 CSRF 且只提交动态验证码", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ apiKey: "current-provider-key" }), { status: 200, headers: { "Content-Type": "application/json" }, }), ); vi.stubGlobal("fetch", fetchMock); setCsrfToken("csrf-reveal"); const result = await adminApi.revealProviderApiKey("volcengine", { totpCode: "123456", }); expect(fetchMock.mock.calls[0]?.[0]).toBe( "/v1/admin/providers/volcengine/api-key/reveal", ); const request = fetchMock.mock.calls[0]?.[1] as RequestInit; expect(request.method).toBe("POST"); expect((request.headers as Headers).get("X-CSRF-Token")).toBe("csrf-reveal"); expect(request.body).toBe(JSON.stringify({ totpCode: "123456" })); expect(result.apiKey).toBe("current-provider-key"); }); });