107 lines
3.9 KiB
TypeScript
107 lines
3.9 KiB
TypeScript
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))
|
||
})
|