import { describe, it, expect, beforeEach, vi } from "vitest"; import type { IamClient } from "../../src/clients/iam.client.js"; import type { MsgClient } from "../../src/clients/msg.client.js"; import type { DataAnaClient } from "../../src/clients/data-ana.client.js"; import type { CoreEduClient } from "../../src/clients/core-edu.client.js"; import type { ChildDto, UserInfoDto, ViewportDto, } from "../../src/clients/dtos.js"; // Mock safeRedis 以控制 ChildGuard 缓存行为 vi.mock("../../src/shared/cache/redis.client.js", () => ({ safeRedis: vi.fn().mockResolvedValue(null), getRedisClient: vi.fn(() => null), closeRedisClient: vi.fn(), })); import { buildSelectChildMutationResolver } from "../../src/graphql/resolvers/select-child.resolver.js"; import type { ResolverDeps } from "../../src/graphql/resolvers/index.js"; import type { GraphqlContext } from "../../src/graphql/context.js"; import { ChildGuard } from "../../src/aggregation/child-guard.js"; import { ChildNotBoundError } from "../../src/shared/errors/application-error.js"; import { logger } from "../../src/shared/observability/logger.js"; import { safeRedis } from "../../src/shared/cache/redis.client.js"; const mockSafeRedis = vi.mocked(safeRedis); const MOCK_CHILDREN: ChildDto[] = [ { id: "student-001", name: "李同学", grade: "三年级", classId: "class-001", className: "三年级1班", gradeId: "grade-003", }, { id: "student-002", name: "李妹妹", grade: "一年级", classId: "class-002", className: "一年级2班", gradeId: "grade-001", }, ]; class StubIamClient implements IamClient { async getUserInfo(userId: string): Promise { return { id: userId, email: "", name: "", roles: ["parent"], permissions: [], }; } async getChildrenByParent(_parentId: string): Promise { return MOCK_CHILDREN.map((c) => ({ ...c })); } async getViewports(_userId: string): Promise { return []; } async getEffectivePermissions(_userId: string): Promise { return []; } } function buildMockContext(parentId: string, traceId: string): GraphqlContext { return { session: { parentId, roles: ["parent"], dataScope: "CHILDREN", traceId, }, req: {} as never, res: {} as never, loaders: {} as never, }; } describe("测试用例 8:selectChild 审计日志(Integration)", () => { let iamClient: StubIamClient; let childGuard: ChildGuard; let loggerInfoSpy: ReturnType; beforeEach(() => { vi.clearAllMocks(); mockSafeRedis.mockResolvedValue(null); iamClient = new StubIamClient(); childGuard = new ChildGuard(iamClient); loggerInfoSpy = vi.spyOn(logger, "info").mockImplementation(() => logger); }); it("合法 childId:记录审计日志(traceId + parentId + childId + timestamp)", async () => { // safeRedis: miss → set ok(ChildGuard.getBoundChildren) mockSafeRedis .mockResolvedValueOnce(null) // get → miss .mockResolvedValueOnce(undefined); // set → ok const deps: ResolverDeps = { iamClient, coreEduClient: {} as unknown as CoreEduClient, dataAnaClient: {} as unknown as DataAnaClient, msgClient: {} as unknown as MsgClient, childGuard, }; const resolver = buildSelectChildMutationResolver(deps); const ctx = buildMockContext("parent-001", "trace-audit-001"); const result = await resolver(null, { childId: "student-001" }, ctx); // 返回值正确 expect(result.childId).toBe("student-001"); expect(result.audited).toBe(true); expect(result.selectedAt).toBeTruthy(); // selectedAt 是合法 ISO 时间 const selectedAtDate = new Date(result.selectedAt); expect(Number.isNaN(selectedAtDate.getTime())).toBe(false); // 审计日志被调用 expect(loggerInfoSpy).toHaveBeenCalled(); // 查找 "Child selected (audit log)" 的调用 const auditCall = loggerInfoSpy.mock.calls.find( (call) => typeof call[1] === "string" && call[1].includes("audit log"), ); expect(auditCall).toBeDefined(); // 审计日志包含 traceId + parentId + childId + selectedAt const logPayload = auditCall![0] as Record; expect(logPayload.parentId).toBe("parent-001"); expect(logPayload.childId).toBe("student-001"); expect(logPayload.traceId).toBe("trace-audit-001"); expect(logPayload.selectedAt).toBe(result.selectedAt); }); it("越权 childId:先抛 ChildNotBoundError,不记录审计日志", async () => { // safeRedis: miss → set ok(ChildGuard.getBoundChildren 第一次调用) mockSafeRedis .mockResolvedValueOnce(null) // get → miss .mockResolvedValueOnce(undefined); // set → ok const deps: ResolverDeps = { iamClient, coreEduClient: {} as unknown as CoreEduClient, dataAnaClient: {} as unknown as DataAnaClient, msgClient: {} as unknown as MsgClient, childGuard, }; const resolver = buildSelectChildMutationResolver(deps); const ctx = buildMockContext("parent-001", "trace-audit-002"); // 越权 childId 抛 ChildNotBoundError await expect( resolver(null, { childId: "student-999" }, ctx), ).rejects.toThrow(ChildNotBoundError); // 不应记录审计日志(只可能有 ChildGuard 的 warn 日志) const auditCall = loggerInfoSpy.mock.calls.find( (call) => typeof call[1] === "string" && call[1].includes("audit log"), ); expect(auditCall).toBeUndefined(); }); it("审计日志的 selectedAt 与返回值一致", async () => { mockSafeRedis.mockResolvedValueOnce(null).mockResolvedValueOnce(undefined); const deps: ResolverDeps = { iamClient, coreEduClient: {} as unknown as CoreEduClient, dataAnaClient: {} as unknown as DataAnaClient, msgClient: {} as unknown as MsgClient, childGuard, }; const resolver = buildSelectChildMutationResolver(deps); const ctx = buildMockContext("parent-001", "trace-audit-003"); const result = await resolver(null, { childId: "student-002" }, ctx); const auditCall = loggerInfoSpy.mock.calls.find( (call) => typeof call[1] === "string" && call[1].includes("audit log"), ); expect(auditCall).toBeDefined(); const logPayload = auditCall![0] as Record; // 审计日志的 selectedAt 与返回值完全一致 expect(logPayload.selectedAt).toBe(result.selectedAt); // childId 也一致 expect(logPayload.childId).toBe(result.childId); }); it("不同 traceId 生成不同审计日志", async () => { // 第一次调用 mockSafeRedis.mockResolvedValueOnce(null).mockResolvedValueOnce(undefined); const deps: ResolverDeps = { iamClient, coreEduClient: {} as unknown as CoreEduClient, dataAnaClient: {} as unknown as DataAnaClient, msgClient: {} as unknown as MsgClient, childGuard, }; const resolver = buildSelectChildMutationResolver(deps); // 第一次:traceId-A const result1 = await resolver( null, { childId: "student-001" }, buildMockContext("parent-001", "traceId-A"), ); // 等待 2ms 确保 selectedAt 时间戳不同 await new Promise((r) => setTimeout(r, 2)); // 第二次:traceId-B(缓存已写入,直接命中或重新调) mockSafeRedis.mockResolvedValueOnce(null).mockResolvedValueOnce(undefined); const result2 = await resolver( null, { childId: "student-002" }, buildMockContext("parent-002", "traceId-B"), ); // 两次审计日志的 traceId 不同 const auditCalls = loggerInfoSpy.mock.calls.filter( (call) => typeof call[1] === "string" && call[1].includes("audit log"), ); expect(auditCalls).toHaveLength(2); const payload1 = auditCalls[0]![0] as Record; const payload2 = auditCalls[1]![0] as Record; expect(payload1.traceId).toBe("traceId-A"); expect(payload2.traceId).toBe("traceId-B"); expect(payload1.childId).toBe("student-001"); expect(payload2.childId).toBe("student-002"); expect(result1.selectedAt).not.toBe(result2.selectedAt); }); });