From 9bee920e4d71eafa248713680e4ebeb338e79bd0 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:35:37 +0800 Subject: [PATCH] test(portal-shell): add security stack tests for PQ manifest and APQ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers v2.1 M3 安全加固验证: - PQ Manifest 完整性(6 cases):DocumentNode 校验、sha256 稳定性、确定性、唯一性、manifest 文件有效性、hash 一致性 - Query depth limit(2 cases):11 层嵌套构造、合法查询构造(实际拒绝由 apollo-router limits.max_depth=10 执行) - APQ behavior(2 cases):默认启用、NEXT_PUBLIC_APOLLO_APQ=false 关闭 测试结果:95/95 passed (85 原有 + 10 新增) --- .../src/lib/api/__tests__/security.test.ts | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 apps/portal-shell/src/lib/api/__tests__/security.test.ts diff --git a/apps/portal-shell/src/lib/api/__tests__/security.test.ts b/apps/portal-shell/src/lib/api/__tests__/security.test.ts new file mode 100644 index 0000000..b646da6 --- /dev/null +++ b/apps/portal-shell/src/lib/api/__tests__/security.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect } from "vitest"; +import { print, type DocumentNode } from "graphql"; +import { sha256 } from "crypto-hash"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as operations from "../operations"; + +/** + * 安全栈测试(v2.1 M3 安全加固) + * + * 覆盖: + * - PQ Manifest 完整性:所有 operations 都能生成稳定 sha256 hash + * - Manifest 文件存在且为有效 JSON + * - 深度限制:构造 11 层嵌套查询(应在 router 侧被拒绝) + * + * 关联:spec §4.2 PQ Manifest、§5.1 深度限制、§9.9 安全测试 + */ + +// 判定一个导出是否为 GraphQL DocumentNode +function isDocumentNode(value: unknown): value is DocumentNode { + return ( + typeof value === "object" && + value !== null && + "kind" in value && + (value as { kind: string }).kind === "Document" && + "loc" in value && + (value as { loc: unknown }).loc !== null && + typeof (value as { loc: { source: { body: string } } }).loc.source?.body === + "string" + ); +} + +describe("Persisted Query Manifest", () => { + it("所有 operations 都是 DocumentNode", () => { + const nonDocExports: string[] = []; + for (const [name, value] of Object.entries(operations)) { + if (!isDocumentNode(value)) { + nonDocExports.push(name); + } + } + // 允许 barrel 中的非文档导出(如类型),但应为 0 + expect(nonDocExports).toEqual([]); + }); + + it("所有 operations 都能生成稳定 sha256 hash(64 位 hex)", async () => { + const hashes: Record = {}; + for (const [name, doc] of Object.entries(operations)) { + if (!isDocumentNode(doc)) continue; + const query = print(doc); + const hash = await sha256(query); + expect(hash).toMatch(/^[a-f0-9]{64}$/); + hashes[name] = hash; + } + // 至少有 51 个文档(7 domain × ~7 query) + const docCount = Object.keys(hashes).length; + expect(docCount).toBeGreaterThanOrEqual(30); + }); + + it("相同 query 生成相同 hash(确定性)", async () => { + const samples = Object.values(operations) + .filter(isDocumentNode) + .slice(0, 3); + for (const doc of samples) { + const query = print(doc); + const hash1 = await sha256(query); + const hash2 = await sha256(query); + expect(hash1).toBe(hash2); + } + }); + + it("不同 query 生成不同 hash", async () => { + const docs = Object.values(operations).filter(isDocumentNode); + if (docs.length < 2) return; // 不足两个无法比较 + const hashes = new Set(); + for (const doc of docs) { + hashes.add(await sha256(print(doc))); + } + // 所有 hash 应唯一 + expect(hashes.size).toBe(docs.length); + }); + + it("manifest 文件存在且为有效 JSON", () => { + const manifestPath = path.resolve( + __dirname, + "../../../../public/pq-manifest.json", + ); + expect(fs.existsSync(manifestPath)).toBe(true); + const content = fs.readFileSync(manifestPath, "utf-8"); + const manifest = JSON.parse(content) as Record; + // 至少 30 个 query + expect(Object.keys(manifest).length).toBeGreaterThanOrEqual(30); + // 每个 key 是 64 位 hex,value 是非空字符串 + for (const [hash, query] of Object.entries(manifest)) { + expect(hash).toMatch(/^[a-f0-9]{64}$/); + expect(typeof query).toBe("string"); + expect(query.length).toBeGreaterThan(0); + } + }); + + it("manifest 中的 hash 与运行时计算的 hash 一致", async () => { + const manifestPath = path.resolve( + __dirname, + "../../../../public/pq-manifest.json", + ); + const content = fs.readFileSync(manifestPath, "utf-8"); + const manifest = JSON.parse(content) as Record; + // 抽样 3 个验证 + const entries = Object.entries(manifest).slice(0, 3); + for (const [manifestHash, query] of entries) { + const runtimeHash = await sha256(query); + expect(runtimeHash).toBe(manifestHash); + } + }); +}); + +describe("Query depth limit", () => { + it("11 层嵌套查询应被 router 侧 max_depth=10 拒绝", () => { + // 此测试验证查询构造的合法性,实际拒绝由 apollo-router limits.max_depth=10 执行 + // 关联:portal-shell spec §5.1、router.yaml limits.max_depth + const deepQuery = + "query { user { parent { user { parent { user { parent { user { parent { user { parent { user { parent { user { parent { user { parent { user { id } } } } } } } } } } } } } } } } }"; + // 计算嵌套深度({ 的数量近似) + const depth = (deepQuery.match(/{/g) ?? []).length; + expect(depth).toBeGreaterThan(10); + // 实际查询发送时,router 会返回 QUERY_DEPTH_EXCEEDED 错误 + // 集成测试需 apollo-router 运行,此处仅断言构造 + }); + + it("合法查询(深度 ≤ 10)应通过 router 校验", () => { + // 此测试验证合法查询的构造,实际通过由 apollo-router 执行 + const validQuery = + "query { user { classes { students { grades { subject } } } } }"; + const depth = (validQuery.match(/{/g) ?? []).length; + expect(depth).toBeLessThanOrEqual(10); + }); +}); + +describe("APQ behavior", () => { + it("NEXT_PUBLIC_APOLLO_APQ 未设置时默认启用 APQ", () => { + // 关联:apollo-client.ts APQ_ENABLED = process.env.NEXT_PUBLIC_APOLLO_APQ !== "false" + // 默认值(未设置)应为启用 + const originalValue = process.env.NEXT_PUBLIC_APOLLO_APQ; + delete process.env.NEXT_PUBLIC_APOLLO_APQ; + // 重新计算(模拟 apollo-client.ts 中的逻辑) + const apqEnabled = process.env.NEXT_PUBLIC_APOLLO_APQ !== "false"; + expect(apqEnabled).toBe(true); + // 恢复 + if (originalValue !== undefined) { + process.env.NEXT_PUBLIC_APOLLO_APQ = originalValue; + } + }); + + it("NEXT_PUBLIC_APOLLO_APQ=false 时关闭 APQ", () => { + const originalValue = process.env.NEXT_PUBLIC_APOLLO_APQ; + process.env.NEXT_PUBLIC_APOLLO_APQ = "false"; + const apqEnabled = process.env.NEXT_PUBLIC_APOLLO_APQ !== "false"; + expect(apqEnabled).toBe(false); + if (originalValue !== undefined) { + process.env.NEXT_PUBLIC_APOLLO_APQ = originalValue; + } else { + delete process.env.NEXT_PUBLIC_APOLLO_APQ; + } + }); +});