120 lines
7.0 KiB
JavaScript
120 lines
7.0 KiB
JavaScript
import { execFileSync } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import vm from 'node:vm';
|
|
import ts from 'typescript';
|
|
|
|
const files = (dir) => fs.existsSync(dir) ? fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => entry.isDirectory() ? files(path.join(dir, entry.name)) : [path.join(dir, entry.name)]) : [];
|
|
const sourceFiles = (dir, extensions = ['.ts', '.vue']) => new Map(files(dir).filter((file) => extensions.includes(path.extname(file))).map((file) => [file.replaceAll('\\', '/'), fs.readFileSync(file, 'utf8')]));
|
|
const mutationActions = (source) => [...source.matchAll(/resourceApi\.(create|update|updateStatus|archive)\b/g)].map((match) => match[1]);
|
|
|
|
function requiredBackendRoutes(contract) {
|
|
if (contract.mode === 'append_only') return [{ method: 'GET', path: contract.path }, { method: 'POST', path: contract.path }];
|
|
const resource = contract.path;
|
|
const detail = `${resource}/:identity`;
|
|
if (contract.mode === 'readonly') return [{ method: 'GET', path: resource }, { method: 'GET', path: detail }];
|
|
return [
|
|
{ method: 'GET', path: resource }, { method: 'POST', path: resource }, { method: 'GET', path: detail },
|
|
{ method: 'PUT', path: detail }, { method: 'PATCH', path: `${detail}/status` }, { method: 'DELETE', path: detail },
|
|
];
|
|
}
|
|
|
|
function routeCoverage(contract, routeSources, viewSources) {
|
|
const expectedView = new RegExp(`getResource\\(\\s*['\"]${escapeRegExp(contract.path)}['\"]\\s*\\)`);
|
|
let hasPage = false;
|
|
let hasMenu = false;
|
|
for (const [, source] of sourceEntries(routeSources, 'src/router')) {
|
|
for (const match of source.matchAll(/component:\s*\(\)\s*=>\s*import\(['\"]@\/views\/([^'\"]+)['\"]\)([\s\S]{0,260}?meta:\s*\{[^}]*\})?/g)) {
|
|
const view = viewSources.get(`src/views/${match[1]}`);
|
|
if (!view || !expectedView.test(view)) continue;
|
|
hasPage = true;
|
|
if (/locale:\s*['\"]menu\.platform\./.test(match[2] ?? '')) hasMenu = true;
|
|
}
|
|
}
|
|
return { hasPage, hasMenu };
|
|
}
|
|
|
|
function sourceEntries(sources, fallbackDirectory) {
|
|
if (sources instanceof Map) return [...sources];
|
|
return sources.map((source, index) => [`${fallbackDirectory}/${index}`, source]);
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
/** Scans user-facing API and view sources for auto-increment primary or relation IDs. */
|
|
export function scanInternalIdLeaks(sources) {
|
|
const failures = [];
|
|
for (const [file, source] of sources) {
|
|
for (const line of source.split(/\r?\n/)) {
|
|
// Remove only the defensive predicates, then keep scanning the rest of the line.
|
|
const scanned = line.replace(/key\s*!==\s*['\"]id['\"]/g, '').replace(/key\.endsWith\(\s*['\"]_id['\"]\s*\)/g, '');
|
|
const relation = scanned.match(/\b([A-Za-z][A-Za-z0-9_]*_id)\b/);
|
|
if (relation) failures.push(`${file}: internal identifier ${relation[1]}`);
|
|
else if (/\bdata-index\s*=\s*['\"]id['\"]|\.id\b|[,{]\s*id\s*:/.test(scanned)) failures.push(`${file}: internal identifier id`);
|
|
}
|
|
}
|
|
return failures;
|
|
}
|
|
|
|
/** Evaluates every platform contract against its backend route, UI definition, page and menu route. */
|
|
export function auditPlatform({ manifest, resources, readOnlyPage, routeSources, viewSources, apiSources, responseShapeVerified = true }) {
|
|
const failures = [];
|
|
if (!responseShapeVerified) failures.push('backend list response shape is not identity-only');
|
|
if (resources.length !== manifest.resources.length) failures.push(`catalogue count: ${resources.length}/${manifest.resources.length}`);
|
|
for (const contract of manifest.resources) {
|
|
const resource = resources.find((item) => item.name === contract.name);
|
|
const pageKind = contract.name === 'ec_category' ? 'tree' : contract.pageKind;
|
|
const label = `${contract.domain}/${contract.name}`;
|
|
if (!resource || resource.resource !== contract.path || resource.mode !== contract.mode || resource.pageKind !== pageKind) {
|
|
failures.push(`${label}: missing frontend resource`);
|
|
continue;
|
|
}
|
|
if (!/^[\u4e00-\u9fff]/.test(resource.title) || resource.fields.length === 0 || resource.fields.some((field) => field.key === 'id' || field.key.endsWith('_id')) || resource.fields.some((field) => !/^[\u4e00-\u9fff]/.test(field.label))) failures.push(`${label}: invalid frontend allowlist`);
|
|
for (const expected of requiredBackendRoutes(contract)) if (!manifest.routes.some((route) => route.method === expected.method && route.path === expected.path)) failures.push(`${label}: missing backend ${expected.method}`);
|
|
const coverage = routeCoverage(contract, routeSources, viewSources);
|
|
if (!coverage.hasPage) failures.push(`${label}: missing page`);
|
|
if (!coverage.hasMenu) failures.push(`${label}: missing menu route`);
|
|
if (contract.mode === 'readonly') {
|
|
const statusCall = new RegExp(`resourceApi\\.updateStatus\\(\\s*['\"]${escapeRegExp(contract.path)}['\"]`);
|
|
for (const [file, source] of [...sourceEntries(routeSources, 'src/router'), ...sourceEntries(apiSources, 'src/api'), ...sourceEntries(viewSources, 'src/views')]) {
|
|
if (statusCall.test(source)) failures.push(`${label}: readonly status mutation in ${file}`);
|
|
}
|
|
}
|
|
}
|
|
if (JSON.stringify(resources.map((item) => item.name).sort()) !== JSON.stringify(manifest.resources.map((item) => item.name).sort())) failures.push('catalogue names differ from backend ExpectedResources');
|
|
const readonlyMutations = mutationActions(readOnlyPage);
|
|
if (readonlyMutations.length) failures.push(`readonly: mutation action exposed (${readonlyMutations.join(', ')})`);
|
|
failures.push(...scanInternalIdLeaks(new Map([...apiSources, ...viewSources])));
|
|
return failures;
|
|
}
|
|
|
|
function loadResources() {
|
|
const compiled = ts.transpileModule(fs.readFileSync('src/api/resources.ts', 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText;
|
|
const resourceModule = { exports: {} };
|
|
vm.runInNewContext(compiled, { module: resourceModule, exports: resourceModule.exports });
|
|
return resourceModule.exports.resources;
|
|
}
|
|
|
|
function runAudit() {
|
|
const backendDirectory = path.resolve('../..', 'backend/api');
|
|
const manifest = JSON.parse(execFileSync('go', ['run', './cmd/resource-contract'], { cwd: backendDirectory, encoding: 'utf8' }));
|
|
let responseShapeVerified = true;
|
|
try { execFileSync('go', ['test', '-count=1', './internal/logic/platform', '-run', '^TestListGasAccountProjectsGasBasicIdentityAndNeverReturnsRelationID$'], { cwd: backendDirectory, stdio: 'pipe' }); } catch { responseShapeVerified = false; }
|
|
const viewSources = sourceFiles('src/views', ['.vue']);
|
|
const failures = auditPlatform({
|
|
manifest,
|
|
resources: loadResources(),
|
|
readOnlyPage: fs.readFileSync('src/views/shared/ReadOnlyListPage.vue', 'utf8'),
|
|
routeSources: sourceFiles('src/router', ['.ts']),
|
|
viewSources,
|
|
apiSources: sourceFiles('src/api', ['.ts']),
|
|
responseShapeVerified,
|
|
});
|
|
for (const failure of failures) console.log(failure);
|
|
if (failures.length) process.exitCode = 1;
|
|
}
|
|
|
|
if (import.meta.url === `file://${process.argv[1]?.replaceAll('\\', '/')}`) runAudit();
|