Files
platforms/frontend/platform_admin/scripts/audit-check.mjs

54 lines
4.6 KiB
JavaScript
Raw Normal View History

import { execFileSync } from 'node:child_process';
2026-07-27 00:18:42 +08:00
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
import ts from 'typescript';
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 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 });
const resources = resourceModule.exports.resources;
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 failures = [];
if (!responseShapeVerified) failures.push('backend list response shape is not identity-only');
const expected = manifest.resources;
if (resources.length !== expected.length) failures.push(`catalogue count: ${resources.length}/${expected.length}`);
for (const contract of expected) {
const resource = resources.find((item) => item.name === contract.name);
const pageKind = contract.name === 'ec_category' ? 'tree' : contract.pageKind;
if (!resource || resource.mode !== contract.mode || resource.pageKind !== pageKind) { failures.push(`${contract.name}: contract mismatch`); continue; }
if (!/^[\u4e00-\u9fff]/.test(resource.title) || resource.fields.some((field) => !/^[\u4e00-\u9fff]/.test(field.label))) failures.push(`${contract.name}: missing Chinese business labels`);
if (resource.fields.length === 0 || resource.fields.some((field) => field.key === 'id' || field.key.endsWith('_id'))) failures.push(`${contract.name}: invalid allowlist`);
const method = resource.mode === 'append_only' ? 'POST' : 'GET';
if (!manifest.routes.some((route) => route.method === method && route.path === resource.resource)) failures.push(`${contract.name}: resource route mismatch`);
if (resource.mode === 'readonly') {
const view = files('src/views').find((file) => file.includes(resource.name) && file.endsWith('ListPage.vue'));
if (!view || !fs.readFileSync(view, 'utf8').includes('ReadOnlyListPage')) failures.push(`${contract.name}: readonly UI`);
}
2026-07-27 00:18:42 +08:00
}
if (JSON.stringify(resources.map((item) => item.name).sort()) !== JSON.stringify(expected.map((item) => item.name).sort())) failures.push('catalogue names differ from backend ExpectedResources');
for (const name of ['ec_category', 'platform_menu']) { const view = files('src/views').find((file) => file.includes(name) && file.endsWith('TreePage.vue')); if (!view) failures.push(`${name}: tree page missing`); }
const tree = fs.readFileSync('src/views/shared/TreePage.vue', 'utf8');
if (!tree.includes('parent_identity') || !tree.includes('canWrite') || !tree.includes('resourceApi.create') || !tree.includes('resourceApi.update')) failures.push('tree: writable identity actions missing');
if (/(?:['"`]id['"`]|\bparent_id\b|\.id\b)/.test(tree)) failures.push('tree: internal id key leaked');
const readOnlyPage = fs.readFileSync('src/views/shared/ReadOnlyListPage.vue', 'utf8');
if (/resourceApi\.(?:create|update|archive)\b/.test(readOnlyPage)) failures.push('readonly: mutation action exposed');
for (const detailPage of ['src/views/shared/CrudListPage.vue', 'src/views/shared/ReadOnlyListPage.vue']) {
const source = fs.readFileSync(detailPage, 'utf8');
if (!source.includes("key !== 'id'") || !source.includes("key.endsWith('_id')")) failures.push(`${detailPage}: internal ids shown in detail`);
}
const event = resources.find((resource) => resource.name === 'saf_event');
const disposal = resources.find((resource) => resource.name === 'saf_event_disposal');
if (!event?.detailActions?.some((action) => action.name === 'saf_event_disposal' && action.resource.includes(':identity/disposals'))) failures.push('saf_event_disposal: detail action missing');
if (!disposal || disposal.resource !== '/safety/saf_event/:identity/disposals' || files('src/views').some((file) => file.includes('saf_event_disposal'))) failures.push('saf_event_disposal: independent page exposed');
for (const failure of failures) console.log(failure);
if (failures.length) process.exitCode = 1;