2026-07-27 11:00:21 +08:00
|
|
|
import assert from 'node:assert/strict';
|
2026-07-27 14:03:45 +08:00
|
|
|
import { readFileSync } from 'node:fs';
|
2026-07-27 11:00:21 +08:00
|
|
|
import test from 'node:test';
|
|
|
|
|
import { auditPlatform, scanInternalIdLeaks } from './audit-check.mjs';
|
|
|
|
|
|
2026-07-27 14:03:45 +08:00
|
|
|
const resourcesSource = readFileSync('src/api/resources.ts', 'utf8');
|
|
|
|
|
|
|
|
|
|
test('资源定义使用 safe 和 audit 前缀', () => {
|
|
|
|
|
assert.match(resourcesSource, /define\('safe_event', '\/safety\/safe_event'/);
|
|
|
|
|
assert.match(resourcesSource, /define\('audit_approval', '\/audit\/audit_approval'/);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-27 11:00:21 +08:00
|
|
|
test('只读页面将状态变更视为违规写操作', () => {
|
|
|
|
|
const failures = auditPlatform({
|
|
|
|
|
manifest: { resources: [], routes: [] },
|
|
|
|
|
resources: [],
|
|
|
|
|
readOnlyPage: '<script setup>resourceApi.updateStatus(resource, identity, status)</script>',
|
|
|
|
|
routeSources: [],
|
|
|
|
|
viewSources: new Map(),
|
|
|
|
|
apiSources: new Map(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
assert.deepEqual(failures, ['readonly: mutation action exposed (updateStatus)']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('扫描 API 和页面中用于展示或请求的内部 ID', () => {
|
|
|
|
|
const failures = scanInternalIdLeaks(new Map([
|
|
|
|
|
['src/api/leak.ts', "resourceApi.create('/gas/gas_basic', { gas_basic_id: 7 })"],
|
|
|
|
|
['src/views/leak.vue', '<a-table-column data-index="id" />'],
|
|
|
|
|
]));
|
|
|
|
|
|
|
|
|
|
assert.deepEqual(failures, [
|
|
|
|
|
'src/api/leak.ts: internal identifier gas_basic_id',
|
|
|
|
|
'src/views/leak.vue: internal identifier id',
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-27 11:13:55 +08:00
|
|
|
test('防护表达式不能掩盖同一行的内部 ID 泄漏', () => {
|
|
|
|
|
const failures = scanInternalIdLeaks(new Map([
|
|
|
|
|
['src/views/leak.vue', "const visible = row.id; const safe = key !== 'id';"],
|
|
|
|
|
['src/api/leak.ts', "send({ gas_basic_id: 7 }); const safe = key.endsWith('_id');"],
|
|
|
|
|
]));
|
|
|
|
|
|
|
|
|
|
assert.deepEqual(failures, [
|
|
|
|
|
'src/views/leak.vue: internal identifier id',
|
|
|
|
|
'src/api/leak.ts: internal identifier gas_basic_id',
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-27 11:00:21 +08:00
|
|
|
test('每个资源必须由带菜单元数据的路由实际加载对应页面', () => {
|
|
|
|
|
const failures = auditPlatform({
|
|
|
|
|
manifest: { resources: [{ domain: 'gas', name: 'gas_basic', path: '/gas/gas_basic', mode: 'writable', pageKind: 'list' }], routes: [
|
|
|
|
|
{ method: 'GET', path: '/gas/gas_basic' },
|
|
|
|
|
{ method: 'POST', path: '/gas/gas_basic' },
|
|
|
|
|
{ method: 'GET', path: '/gas/gas_basic/:identity' },
|
|
|
|
|
{ method: 'PUT', path: '/gas/gas_basic/:identity' },
|
|
|
|
|
{ method: 'PATCH', path: '/gas/gas_basic/:identity/status' },
|
|
|
|
|
{ method: 'DELETE', path: '/gas/gas_basic/:identity' },
|
|
|
|
|
] },
|
|
|
|
|
resources: [{ name: 'gas_basic', resource: '/gas/gas_basic', mode: 'writable', pageKind: 'list', title: '气站管理', fields: [{ key: 'name', label: '名称' }] }],
|
|
|
|
|
readOnlyPage: '',
|
|
|
|
|
routeSources: ["{ component: () => import('@/views/gas/gas_basic/ListPage.vue') }"],
|
|
|
|
|
viewSources: new Map([['src/views/gas/gas_basic/ListPage.vue', "getResource('/gas/gas_basic')"]]),
|
|
|
|
|
apiSources: new Map(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
assert.deepEqual(failures, ['gas/gas_basic: missing menu route']);
|
|
|
|
|
});
|
2026-07-27 11:13:55 +08:00
|
|
|
|
|
|
|
|
test('仅追加处置必须挂在安全事件详情动作且不得有独立页面', () => {
|
|
|
|
|
const failures = auditPlatform({
|
|
|
|
|
manifest: { resources: [{ domain: 'safety', name: 'saf_event_disposal', path: '/safety/saf_event/:identity/disposals', mode: 'append_only', pageKind: 'list' }], routes: [
|
|
|
|
|
{ method: 'GET', path: '/safety/saf_event/:identity/disposals' },
|
|
|
|
|
{ method: 'POST', path: '/safety/saf_event/:identity/disposals' },
|
|
|
|
|
] },
|
|
|
|
|
resources: [{ name: 'saf_event_disposal', resource: '/safety/saf_event/:identity/disposals', mode: 'append_only', pageKind: 'list', title: '事件处置', fields: [{ key: 'action', label: '处置动作' }] }],
|
|
|
|
|
readOnlyPage: '',
|
|
|
|
|
routeSources: [],
|
|
|
|
|
viewSources: new Map([['src/views/safety/saf_event_disposal/ListPage.vue', '<template />']]),
|
|
|
|
|
apiSources: new Map(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
assert.deepEqual(failures, [
|
|
|
|
|
'safety/saf_event_disposal: missing saf_event detail action',
|
|
|
|
|
'safety/saf_event_disposal: independent page exposed',
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('只读资源拒绝 API 或路由中的状态写入', () => {
|
|
|
|
|
const failures = auditPlatform({
|
|
|
|
|
manifest: { resources: [{ domain: 'wallet', name: 'wallet', path: '/wallet/wallet', mode: 'readonly', pageKind: 'list' }], routes: [
|
|
|
|
|
{ method: 'GET', path: '/wallet/wallet' }, { method: 'GET', path: '/wallet/wallet/:identity' },
|
|
|
|
|
] },
|
|
|
|
|
resources: [{ name: 'wallet', resource: '/wallet/wallet', mode: 'readonly', pageKind: 'list', title: '钱包', fields: [{ key: 'balance_amount', label: '余额' }] }],
|
|
|
|
|
readOnlyPage: '',
|
|
|
|
|
routeSources: ["{ component: () => import('@/views/wallet/wallet/ListPage.vue'), meta: { locale: 'menu.platform.wallet' } }"],
|
|
|
|
|
viewSources: new Map([['src/views/wallet/wallet/ListPage.vue', "getResource('/wallet/wallet')"]]),
|
|
|
|
|
apiSources: new Map([['src/api/wallet.ts', "resourceApi.updateStatus('/wallet/wallet', identity, 'disabled')"]]),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
assert.deepEqual(failures, ['wallet/wallet: readonly status mutation in src/api/wallet.ts']);
|
|
|
|
|
});
|