Files
Edu/services/parent-bff/test/unit/child-guard.test.ts
SpecialX 2229309a1e feat: initialize parent-bff service with full core features
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
2026-07-10 18:49:06 +08:00

234 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { IamClient } from "../../src/clients/iam.client.js";
import type { ChildDto, UserInfoDto, ViewportDto } from "../../src/clients/dtos.js";
// Mock safeRedis 以控制缓存行为
vi.mock("../../src/shared/cache/redis.client.js", () => ({
safeRedis: vi.fn().mockResolvedValue(null),
getRedisClient: vi.fn(() => null),
closeRedisClient: vi.fn(),
}));
import { ChildGuard } from "../../src/aggregation/child-guard.js";
import { ChildNotBoundError } from "../../src/shared/errors/application-error.js";
import { safeRedis } from "../../src/shared/cache/redis.client.js";
const mockSafeRedis = vi.mocked(safeRedis);
/**
* 可追踪调用次数的 Mock IamClient
*/
class TrackingIamClient implements IamClient {
getChildrenCallCount = 0;
private readonly children: ChildDto[];
constructor(children: ChildDto[]) {
this.children = children;
}
async getUserInfo(userId: string): Promise<UserInfoDto> {
return {
id: userId,
email: "test@example.com",
name: "测试家长",
roles: ["parent"],
permissions: [],
};
}
async getChildrenByParent(_parentId: string): Promise<ChildDto[]> {
this.getChildrenCallCount++;
// 小延迟确保并发请求重叠
await new Promise((r) => setTimeout(r, 10));
return this.children.map((c) => ({ ...c }));
}
async getViewports(_userId: string): Promise<ViewportDto[]> {
return [];
}
async getEffectivePermissions(_userId: string): Promise<string[]> {
return [];
}
}
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",
},
];
describe("ChildGuard", () => {
let iamClient: TrackingIamClient;
let childGuard: ChildGuard;
beforeEach(() => {
vi.clearAllMocks();
mockSafeRedis.mockResolvedValue(null);
iamClient = new TrackingIamClient(MOCK_CHILDREN);
childGuard = new ChildGuard(iamClient);
});
// ============ 测试用例 1ChildGuard 拦截越权 childId ============
describe("测试用例 1拦截越权 childId", () => {
it("childId 不在绑定列表时抛 ChildNotBoundError(403)", async () => {
// safeRedis miss → 调 iam
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
await expect(
childGuard.validateChildAccess("parent-001", "student-999"),
).rejects.toThrow(ChildNotBoundError);
try {
await childGuard.validateChildAccess("parent-001", "student-999");
} catch (err) {
const error = err as ChildNotBoundError;
expect(error.statusCode).toBe(403);
expect(error.code).toBe("BFF_PARENT_CHILD_NOT_BOUND");
expect(error.details?.parentId).toBe("parent-001");
expect(error.details?.requestedChildId).toBe("student-999");
}
});
it("childId 在绑定列表时不抛异常", async () => {
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
await expect(
childGuard.validateChildAccess("parent-001", "student-001"),
).resolves.toBeUndefined();
});
it("支持多个绑定的 childId", async () => {
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
await expect(
childGuard.validateChildAccess("parent-001", "student-002"),
).resolves.toBeUndefined();
});
});
// ============ 测试用例 2缓存命中 ============
describe("测试用例 2缓存命中30s 内第二次不调 iam", () => {
it("Redis 缓存命中时不调用 iam.GetChildrenByParent", async () => {
const cachedChildren = JSON.stringify(MOCK_CHILDREN);
// 第一次调用:缓存未命中 → 调 iam → 写缓存
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
await childGuard.getBoundChildren("parent-001");
expect(iamClient.getChildrenCallCount).toBe(1);
// 第二次调用:缓存命中 → 不调 iam
mockSafeRedis.mockResolvedValueOnce(cachedChildren); // get → hit
const children = await childGuard.getBoundChildren("parent-001");
expect(iamClient.getChildrenCallCount).toBe(1); // 仍然只有 1 次
expect(children).toHaveLength(2);
expect(children[0]!.id).toBe("student-001");
});
it("缓存返回的数据与原始数据一致", async () => {
const cachedChildren = JSON.stringify(MOCK_CHILDREN);
mockSafeRedis.mockResolvedValueOnce(cachedChildren);
const children = await childGuard.getBoundChildren("parent-001");
expect(children).toHaveLength(2);
expect(children[0]!.id).toBe("student-001");
expect(children[0]!.name).toBe("李同学");
expect(children[1]!.id).toBe("student-002");
});
});
// ============ 测试用例 3缓存击穿保护singleflight============
describe("测试用例 3缓存击穿保护singleflight", () => {
it("并发 100 请求只调 iam 1 次", async () => {
// safeRedis 始终返回 null缓存未命中
mockSafeRedis.mockResolvedValue(null);
// 并发发起 100 个请求
const promises: Promise<unknown>[] = [];
for (let i = 0; i < 100; i++) {
promises.push(childGuard.getBoundChildren("parent-001"));
}
const results = await Promise.all(promises);
// 所有请求都成功返回
expect(results).toHaveLength(100);
// iam 只被调用了 1 次singleflight 保护)
expect(iamClient.getChildrenCallCount).toBe(1);
// 所有结果一致
const firstResult = results[0] as ChildDto[];
expect(firstResult).toHaveLength(2);
for (const result of results) {
expect(result).toEqual(firstResult);
}
});
it("不同 parentId 的请求各自独立调用 iam", async () => {
mockSafeRedis.mockResolvedValue(null);
await Promise.all([
childGuard.getBoundChildren("parent-001"),
childGuard.getBoundChildren("parent-002"),
]);
// 不同 parentId 各调 1 次
expect(iamClient.getChildrenCallCount).toBe(2);
});
it("singleflight 完成后 inflight map 被清理", async () => {
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
await childGuard.getBoundChildren("parent-001");
// 再次请求应该走缓存或重新调 iaminflight 已清理)
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
await childGuard.getBoundChildren("parent-001");
expect(iamClient.getChildrenCallCount).toBe(2);
});
});
describe("缓存 JSON 解析失败降级", () => {
it("Redis 返回非法 JSON 时降级到 iam 调用", async () => {
mockSafeRedis
.mockResolvedValueOnce("invalid-json") // get → 返回非法 JSON
.mockResolvedValueOnce(undefined); // set → ok
const children = await childGuard.getBoundChildren("parent-001");
expect(children).toHaveLength(2);
expect(iamClient.getChildrenCallCount).toBe(1);
});
});
});