add complete parent-bff implementation including: - GraphQL endpoint with depth/cost validation - ChildGuard越权校验 with redis cache and singleflight - parallel orchestration with partial failure fallback - three-level cache fallback strategy (Redis + LRU + downstream) - Kafka consumer for cache invalidation and notification push - opossum circuit breaker for downstream services - Prometheus metrics and SLO alerts - Helm chart for k8s deployment with multi-environment configs - Grafana dashboard for observability - complete unit and integration tests
252 lines
8.2 KiB
TypeScript
252 lines
8.2 KiB
TypeScript
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<UserInfoDto> {
|
||
return {
|
||
id: userId,
|
||
email: "",
|
||
name: "",
|
||
roles: ["parent"],
|
||
permissions: [],
|
||
};
|
||
}
|
||
async getChildrenByParent(_parentId: string): Promise<ChildDto[]> {
|
||
return MOCK_CHILDREN.map((c) => ({ ...c }));
|
||
}
|
||
async getViewports(_userId: string): Promise<ViewportDto[]> {
|
||
return [];
|
||
}
|
||
async getEffectivePermissions(_userId: string): Promise<string[]> {
|
||
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<typeof vi.spyOn>;
|
||
|
||
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<string, unknown>;
|
||
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<string, unknown>;
|
||
// 审计日志的 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<string, unknown>;
|
||
const payload2 = auditCalls[1]![0] as Record<string, unknown>;
|
||
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);
|
||
});
|
||
});
|