Files
OSGAccountServer/admin-web/src/test/client.test.ts
T
Rocky 544e0d7356 Add TOTP-gated provider API key reveal
Allow super administrators to inspect effective provider credentials only after audited, rate-limited step-up verification.
2026-08-22 17:56:35 +08:00

330 lines
10 KiB
TypeScript

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");
});
});