Add secure administrator operations console
Provide TOTP-authenticated, role-controlled user and credit workflows with paginated audit data and SQL-backed statistics so operations can manage growth safely.
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
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", 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.ledger("user/with space", "ledger+/=");
|
||||
await adminApi.operators("operator+/=");
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
"/v1/admin/users/user%2Fwith%20space/ledger?cursor=ledger%2B%2F%3D",
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
||||
"/v1/admin/operators?cursor=operator%2B%2F%3D",
|
||||
);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user