544e0d7356
Allow super administrators to inspect effective provider credentials only after audited, rate-limited step-up verification.
330 lines
10 KiB
TypeScript
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");
|
|
});
|
|
});
|