diff --git a/frontend-vue/tests/shared-api-allowlist.test.ts b/frontend-vue/tests/shared-api-allowlist.test.ts new file mode 100644 index 00000000..3814117c --- /dev/null +++ b/frontend-vue/tests/shared-api-allowlist.test.ts @@ -0,0 +1,106 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +/** task-213:前端共享层导出白名单。页面不得直连 shared/api/http 或拼 URL;只经 API 层/桥。 */ + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'src') + +function walk(dir: string): string[] { + const out: string[] = [] + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) out.push(...walk(full)) + else if (entry.name.endsWith('.ts') || entry.name.endsWith('.vue')) out.push(full) + } + return out +} + +const relative = (f: string) => path.relative(ROOT, f).replaceAll('\\', '/') + +/** 命中直连 http 模块 import 的文件(http.ts 内部及 shared 内部允许)。 */ +function httpDirectImports(files: string[]): string[] { + const hits: string[] = [] + for (const f of files) { + const text = fs.readFileSync(f, 'utf8') + const imp = /from\s+['"][^'"]*(?:shared\/)?api\/http(?:\.ts)?['"]/.test(text) + if (imp && !relative(f).startsWith('shared/')) hits.push(relative(f)) + } + return hits +} + +/** 直接 import axios(应只在 shared/api/http.ts)。 */ +function axiosDirectImports(files: string[]): string[] { + const hits: string[] = [] + for (const f of files) { + const text = fs.readFileSync(f, 'utf8') + if (/from\s+['"]axios['"]/.test(text) && relative(f) !== 'shared/api/http.ts') { + hits.push(relative(f)) + } + } + return hits +} + +const allFiles = walk(ROOT) + +test('页面不直连 shared/api/http', () => { + assert.deepEqual(httpDirectImports(allFiles), []) +}) + +test('页面不直接 import axios(收敛到 http.ts)', () => { + assert.deepEqual(axiosDirectImports(allFiles), []) +}) + +test('违规 import 可被检出(helper 判真)', () => { + const tmp = path.join(ROOT, 'pages/__tmp_violation__.ts') + fs.writeFileSync(tmp, "import { http } from '../../shared/api/http.ts'\n") + try { + const hits = httpDirectImports(walk(ROOT)) + assert.ok(hits.some((h) => h.includes('pages/__tmp_violation__')), '应检出 shared 外违规 import') + } finally { + fs.rmSync(tmp) + } +}) + +test('shared 内部 import http 被放行(允许项)', () => { + const tmp = path.join(ROOT, 'shared/api/__tmp_internal__.ts') + fs.writeFileSync(tmp, "import { http } from './http.ts'\n") + try { + const hits = httpDirectImports([tmp]) + assert.deepEqual(hits, [], 'shared 内部允许直连 http') + } finally { + fs.rmSync(tmp) + } +}) + +test('页面经 java-modules/shared api 层调用(存在且被引用)', () => { + const texts = allFiles.filter((f) => relative(f).startsWith('pages/') || /-main\.ts$/.test(relative(f))) + .map((f) => fs.readFileSync(f, 'utf8')) + const usingApi = texts.filter((t) => /shared\/api\/(java-modules|types\/modules|progress-light|permission|upload|user)/.test(t)) + assert.ok(usingApi.length >= 1, '应至少一个页面入口使用共享 API 层') +}) + +test('bridge 适配层 import http 被放行', () => { + const bridges = allFiles.filter((f) => relative(f).startsWith('shared/bridges/')) + for (const f of bridges) { + const text = fs.readFileSync(f, 'utf8') + if (/api\/http/.test(text)) return // 存在 bridge 使用 http,属允许 + } + // 无 bridge 使用 http 也接受(不强求) + assert.ok(true) +}) + +test('页面文件不得拼接 /newApi URL 字面量', () => { + const pages = allFiles.filter((f) => relative(f).startsWith('pages/') || /-main\.ts$/.test(relative(f))) + for (const f of pages) { + const text = fs.readFileSync(f, 'utf8') + assert.ok(!text.includes("'/newApi") && !text.includes('"/newApi'), `页面不应拼 URL: ${relative(f)}`) + } +}) + +test('白名单扫描可重复、结果稳定', () => { + assert.deepEqual(httpDirectImports(allFiles), httpDirectImports(allFiles)) + assert.deepEqual(axiosDirectImports(allFiles), axiosDirectImports(allFiles)) +})