From 48fc80b0038e5908e12c42f638a1617071ace9ef Mon Sep 17 00:00:00 2001 From: yanweidong Date: Mon, 27 Jul 2026 11:00:21 +0800 Subject: [PATCH] fix: complete platform admin audit remediation --- backend/api/cmd/resource-contract/main.go | 3 +- .../api/internal/logic/platform/resource.go | 84 +++++------ backend/api/internal/routers/platform_test.go | 27 ++++ docs/平台总后台审计报告-2026-07-27.md | 54 +++++++ .../platform_admin/scripts/audit-check.mjs | 142 ++++++++++++------ .../scripts/audit-check.test.mjs | 48 ++++++ 6 files changed, 268 insertions(+), 90 deletions(-) create mode 100644 docs/平台总后台审计报告-2026-07-27.md create mode 100644 frontend/platform_admin/scripts/audit-check.test.mjs diff --git a/backend/api/cmd/resource-contract/main.go b/backend/api/cmd/resource-contract/main.go index 1d9249c..8c07334 100644 --- a/backend/api/cmd/resource-contract/main.go +++ b/backend/api/cmd/resource-contract/main.go @@ -17,6 +17,7 @@ type route struct { type contract struct { Domain string `json:"domain"` Name string `json:"name"` + Path string `json:"path"` PageKind string `json:"pageKind"` Mode string `json:"mode"` } @@ -36,7 +37,7 @@ func main() { expected := platform.ExpectedResources() contracts := make([]contract, 0, len(expected)) for _, item := range expected { - contracts = append(contracts, contract{Domain: item.Domain, Name: item.Name, PageKind: item.PageKind, Mode: string(item.Mode)}) + contracts = append(contracts, contract{Domain: item.Domain, Name: item.Name, Path: item.Path, PageKind: item.PageKind, Mode: string(item.Mode)}) } if err := json.NewEncoder(os.Stdout).Encode(manifest{Resources: contracts, Routes: routes}); err != nil { panic(err) diff --git a/backend/api/internal/logic/platform/resource.go b/backend/api/internal/logic/platform/resource.go index e26206f..816f8e1 100644 --- a/backend/api/internal/logic/platform/resource.go +++ b/backend/api/internal/logic/platform/resource.go @@ -19,6 +19,7 @@ const ( type ResourceContract struct { Domain string Name string + Path string PageKind string Mode ResourceMode } @@ -69,51 +70,42 @@ func filterFields(values map[string]any, allowedFields []string) gin.H { // ExpectedResources returns the complete platform-admin resource catalogue. func ExpectedResources() []ResourceContract { return []ResourceContract{ - {Domain: "gas", Name: "gas_basic", Mode: Writable, PageKind: "list"}, - {Domain: "gas", Name: "gas_account", Mode: Writable, PageKind: "list"}, - {Domain: "delivery", Name: "delivery_basic", Mode: Writable, PageKind: "list"}, - {Domain: "delivery", Name: "delivery_account", Mode: Writable, PageKind: "list"}, - {Domain: "staff", Name: "staff_account", Mode: Writable, PageKind: "list"}, - {Domain: "staff", Name: "staff_credential", Mode: Writable, PageKind: "list"}, - {Domain: "user", Name: "user_account", Mode: Writable, PageKind: "list"}, - {Domain: "user", Name: "user_address", Mode: Writable, PageKind: "list"}, - {Domain: "user", Name: "user_service_relation", Mode: Writable, PageKind: "list"}, - {Domain: "device", Name: "dev_smart_cylinder_valve", Mode: Writable, PageKind: "list"}, - {Domain: "device", Name: "dev_device_binding", Mode: Writable, PageKind: "list"}, - {Domain: "safety", Name: "saf_rule", Mode: Writable, PageKind: "list"}, - {Domain: "safety", Name: "saf_event", Mode: Writable, PageKind: "list"}, - {Domain: "safety", Name: "saf_inspection", Mode: Writable, PageKind: "list"}, - {Domain: "ec", Name: "ec_category", Mode: Writable, PageKind: "list"}, - {Domain: "ec", Name: "ec_product", Mode: Writable, PageKind: "list"}, - {Domain: "ec", Name: "ec_product_attribute", Mode: Writable, PageKind: "list"}, - {Domain: "ec", Name: "ec_product_image", Mode: Writable, PageKind: "list"}, - {Domain: "ec", Name: "ec_cart", Mode: Writable, PageKind: "list"}, - {Domain: "ec", Name: "ec_order", Mode: Writable, PageKind: "list"}, - {Domain: "ec", Name: "ec_order_item", Mode: Writable, PageKind: "list"}, - {Domain: "ec", Name: "ec_review", Mode: Writable, PageKind: "list"}, - {Domain: "delivery", Name: "delivery_task", Mode: Writable, PageKind: "list"}, - {Domain: "delivery", Name: "delivery_track", Mode: Writable, PageKind: "list"}, - {Domain: "delivery", Name: "delivery_track_point", Mode: Writable, PageKind: "list"}, - {Domain: "finance", Name: "fin_payment", Mode: Writable, PageKind: "list"}, - {Domain: "finance", Name: "fin_settlement", Mode: Writable, PageKind: "list"}, - {Domain: "finance", Name: "fin_reconciliation", Mode: Writable, PageKind: "list"}, - {Domain: "content", Name: "cnt_content", Mode: Writable, PageKind: "list"}, - {Domain: "notification", Name: "ntf_template", Mode: Writable, PageKind: "list"}, - {Domain: "customer_service", Name: "cs_ticket", Mode: Writable, PageKind: "list"}, - {Domain: "platform", Name: "platfrom_account", Mode: Writable, PageKind: "list"}, - {Domain: "platform", Name: "platform_role", Mode: Writable, PageKind: "list"}, - {Domain: "platform", Name: "platform_menu", Mode: Writable, PageKind: "tree"}, - {Domain: "device", Name: "dev_telemetry", Mode: ReadOnly, PageKind: "list"}, - {Domain: "wallet", Name: "wallet", Mode: ReadOnly, PageKind: "list"}, - {Domain: "wallet", Name: "wallet_ledger", Mode: ReadOnly, PageKind: "list"}, - {Domain: "wallet", Name: "wallet_recharge", Mode: ReadOnly, PageKind: "list"}, - {Domain: "wallet", Name: "wallet_withdrawal", Mode: ReadOnly, PageKind: "list"}, - {Domain: "report", Name: "report", Mode: ReadOnly, PageKind: "list"}, - {Domain: "report", Name: "report_item", Mode: ReadOnly, PageKind: "list"}, - {Domain: "report", Name: "report_metric_snapshot", Mode: ReadOnly, PageKind: "list"}, - {Domain: "audit", Name: "aud_operation_log", Mode: ReadOnly, PageKind: "list"}, - {Domain: "audit", Name: "aud_export_log", Mode: ReadOnly, PageKind: "list"}, - {Domain: "audit", Name: "aud_approval", Mode: ReadOnly, PageKind: "list"}, - {Domain: "safety", Name: "saf_event_disposal", Mode: AppendOnly, PageKind: "list"}, + resourceContract("gas", "gas_basic", Writable, "list"), resourceContract("gas", "gas_account", Writable, "list"), + resourceContract("delivery", "delivery_basic", Writable, "list"), resourceContract("delivery", "delivery_account", Writable, "list"), + resourceContract("staff", "staff_account", Writable, "list"), resourceContract("staff", "staff_credential", Writable, "list"), + resourceContract("user", "user_account", Writable, "list"), resourceContract("user", "user_address", Writable, "list"), resourceContract("user", "user_service_relation", Writable, "list"), + resourceContract("device", "dev_smart_cylinder_valve", Writable, "list"), resourceContract("device", "dev_device_binding", Writable, "list"), resourceContract("device", "dev_telemetry", ReadOnly, "list"), + resourceContract("safety", "saf_rule", Writable, "list"), resourceContract("safety", "saf_event", Writable, "list"), resourceContract("safety", "saf_inspection", Writable, "list"), resourceContract("safety", "saf_event_disposal", AppendOnly, "list"), + resourceContract("ec", "ec_category", Writable, "list"), resourceContract("ec", "ec_product", Writable, "list"), resourceContract("ec", "ec_product_attribute", Writable, "list"), resourceContract("ec", "ec_product_image", Writable, "list"), resourceContract("ec", "ec_cart", Writable, "list"), resourceContract("ec", "ec_order", Writable, "list"), resourceContract("ec", "ec_order_item", Writable, "list"), resourceContract("ec", "ec_review", Writable, "list"), + resourceContract("delivery", "delivery_task", Writable, "list"), resourceContract("delivery", "delivery_track", Writable, "list"), resourceContract("delivery", "delivery_track_point", Writable, "list"), + resourceContract("finance", "fin_payment", Writable, "list"), resourceContract("finance", "fin_settlement", Writable, "list"), resourceContract("finance", "fin_reconciliation", Writable, "list"), + resourceContract("content", "cnt_content", Writable, "list"), resourceContract("notification", "ntf_template", Writable, "list"), resourceContract("customer_service", "cs_ticket", Writable, "list"), + resourceContract("platform", "platfrom_account", Writable, "list"), resourceContract("platform", "platform_role", Writable, "list"), resourceContract("platform", "platform_menu", Writable, "tree"), + resourceContract("wallet", "wallet", ReadOnly, "list"), resourceContract("wallet", "wallet_ledger", ReadOnly, "list"), resourceContract("wallet", "wallet_recharge", ReadOnly, "list"), resourceContract("wallet", "wallet_withdrawal", ReadOnly, "list"), + resourceContract("report", "report", ReadOnly, "list"), resourceContract("report", "report_item", ReadOnly, "list"), resourceContract("report", "report_metric_snapshot", ReadOnly, "list"), + resourceContract("audit", "aud_operation_log", ReadOnly, "list"), resourceContract("audit", "aud_export_log", ReadOnly, "list"), resourceContract("audit", "aud_approval", ReadOnly, "list"), + } +} + +func resourceContract(domain, name string, mode ResourceMode, pageKind string) ResourceContract { + return ResourceContract{Domain: domain, Name: name, Path: resourcePath(domain, name), Mode: mode, PageKind: pageKind} +} + +func resourcePath(domain, name string) string { + switch name { + case "staff_account": + return "/staff/account" + case "staff_credential": + return "/staff/credential" + case "user_account": + return "/user/account" + case "user_address": + return "/user/address" + case "user_service_relation": + return "/user/service_relation" + case "saf_event_disposal": + return "/safety/saf_event/:identity/disposals" + default: + return "/" + domain + "/" + name } } diff --git a/backend/api/internal/routers/platform_test.go b/backend/api/internal/routers/platform_test.go index 22d096e..e8f4d3d 100644 --- a/backend/api/internal/routers/platform_test.go +++ b/backend/api/internal/routers/platform_test.go @@ -4,9 +4,36 @@ import ( "net/http" "testing" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform" "github.com/gin-gonic/gin" ) +func TestEveryContractHasRegisteredRoute(t *testing.T) { + engine := gin.New() + RegisterPlatform("heqi", engine) + routes := make(map[string]map[string]bool) + for _, route := range engine.Routes() { + if routes[route.Path] == nil { + routes[route.Path] = make(map[string]bool) + } + routes[route.Path][route.Method] = true + } + for _, contract := range platform.ExpectedResources() { + path := "/heqi/platform/v1" + contract.Path + switch contract.Mode { + case platform.ReadOnly: + assertRouteMethods(t, routes, path, http.MethodGet) + assertRouteMethods(t, routes, path+"/:identity", http.MethodGet) + case platform.AppendOnly: + assertRouteMethods(t, routes, path, http.MethodPost) + default: + assertRouteMethods(t, routes, path, http.MethodGet, http.MethodPost) + assertRouteMethods(t, routes, path+"/:identity", http.MethodGet, http.MethodPut, http.MethodDelete) + assertRouteMethods(t, routes, path+"/:identity/status", http.MethodPatch) + } + } +} + func TestPlatformGasRouteUsesGasBasic(t *testing.T) { engine := gin.New() RegisterPlatform("heqi", engine) diff --git a/docs/平台总后台审计报告-2026-07-27.md b/docs/平台总后台审计报告-2026-07-27.md new file mode 100644 index 0000000..df70661 --- /dev/null +++ b/docs/平台总后台审计报告-2026-07-27.md @@ -0,0 +1,54 @@ +# 平台总后台审计报告(2026-07-27) + +## 范围与结论 + +本次审计覆盖 46 个平台后台资源:34 个可写资源、11 个只读资源和 1 个仅追加的安全事件处置资源。后端资源契约现同时声明领域、资源名、HTTP 路径、页面类型和读写模式,并由运行时注册路由生成清单。前端审计据此逐项校验后端路由、前端资源声明、实际加载的页面组件和带菜单元数据的路由;任一层缺失均以 `领域/资源: missing ` 失败。 + +## 发现与处置 + +| 发现 | 处置 | 验证 | +| --- | --- | --- | +| 只读页面的静态检查未覆盖状态变更调用 | 将 `resourceApi.updateStatus` 纳入只读页的变更操作集合,同时覆盖 create、update、archive | `audit-check.test.mjs` 的只读页用例 | +| 前端资源与后端路由只按资源名和文件名进行粗略匹配 | `ResourceContract.Path` 成为唯一后端路径声明;审计逐项校验 GET/POST/PUT/PATCH/DELETE(按资源模式)以及资源路径一致性 | Go 的 `TestEveryContractHasRegisteredRoute` 和 `pnpm audit:platform` | +| 页面存在但未被菜单路由实际加载时可能漏检 | 审计读取全部路由模块,解析页面动态导入,再确认页面通过 `getResource(契约路径)` 使用对应资源且路由具有 `menu.platform.*` 元数据 | `audit-check.test.mjs` 的菜单映射用例 | +| API 或页面可能展示、提交数据库自增 `id` / `*_id` | 对 `src/api` 与 `src/views` 全量扫描;详情页的显式过滤逻辑被识别为防护而非泄漏 | `pnpm audit:platform` | + +## 身份字段与保留理由 + +- 对外 HTTP 仅使用 `identity` 及关联的 `*_identity`;数据库主键 `id` 和关系外键 `*_id` 仍用于 GORM 关联、索引和内部查询,并由响应投影剔除,不能删除。 +- `status`、`version`、`created_at`、`updated_at` 是归档、乐观并发和审计所需实体元数据,予以保留。 +- `product_snapshot`、`report_metric_snapshot` 保留历史商品及报表快照,避免后续主数据变更影响历史记录。 +- 审计的 `before_data`、`after_data`、`operator_identity`,以及审批的 `handler_identity`、`handled_at` 用于追踪处置人和决策,不可作为冗余清除。 + +## 冗余清理 + +未删除模型字段或页面。审计没有找到同时满足“不在需求、无后端调用、无前端消费者、无测试或迁移依赖”的候选项;为避免移除潜在兼容入口,保留现有非契约文件,且不将它们纳入菜单与资源契约。 + +## 验证命令 + +以下命令在提交前执行: + +```powershell +cd backend/api +go test ./... +go build ./cmd/main +go test ./internal/routers -run '^TestEveryContractHasRegisteredRoute$' -v + +cd ../../frontend/platform_admin +node --test scripts/audit-check.test.mjs +pnpm type:check +pnpm lint +pnpm audit:platform +pnpm build +``` + +| 命令 | 退出码 | 结果 | +| --- | --- | --- | +| `go test ./...` | 0 | 通过 | +| `go build ./cmd/main` | 0 | 通过 | +| `go test ./internal/routers -run '^TestEveryContractHasRegisteredRoute$' -v` | 0 | 通过 | +| `node --test scripts/audit-check.test.mjs` | 0 | 通过,3 个审计用例 | +| `pnpm type:check` | 0 | 通过 | +| `pnpm audit:platform` | 0 | 通过 | +| `pnpm build` | 0 | 通过 | +| `pnpm lint` | 1 | 阻塞:Biome 配置在本目录要求 `.gitignore`。以 `--vcs-root=../..` 启动后发现 187 个既有全仓格式/未使用变量错误;未将无关全仓格式化混入本次审计修复。 | diff --git a/frontend/platform_admin/scripts/audit-check.mjs b/frontend/platform_admin/scripts/audit-check.mjs index 2f54eee..c72ec8e 100644 --- a/frontend/platform_admin/scripts/audit-check.mjs +++ b/frontend/platform_admin/scripts/audit-check.mjs @@ -4,50 +4,106 @@ 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`); +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: '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 routeSources) { + 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 }; } -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`); + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -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; + +/** 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/)) { + // These are defensive filters that explicitly remove IDs from details, not leaks. + if (line.includes("key !== 'id'") || line.includes("key.endsWith('_id')")) continue; + const relation = line.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(line)) 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}`); + if (contract.mode === 'append_only') continue; + const coverage = routeCoverage(contract, routeSources, viewSources); + if (!coverage.hasPage) failures.push(`${label}: missing page`); + if (!coverage.hasMenu) failures.push(`${label}: missing menu route`); + } + 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/routes/modules', ['.ts']).values()], + 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(); diff --git a/frontend/platform_admin/scripts/audit-check.test.mjs b/frontend/platform_admin/scripts/audit-check.test.mjs new file mode 100644 index 0000000..2f7df0c --- /dev/null +++ b/frontend/platform_admin/scripts/audit-check.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { auditPlatform, scanInternalIdLeaks } from './audit-check.mjs'; + +test('只读页面将状态变更视为违规写操作', () => { + const failures = auditPlatform({ + manifest: { resources: [], routes: [] }, + resources: [], + readOnlyPage: '', + 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', ''], + ])); + + assert.deepEqual(failures, [ + 'src/api/leak.ts: internal identifier gas_basic_id', + 'src/views/leak.vue: internal identifier id', + ]); +}); + +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']); +});