fix: harden platform audit coverage
This commit is contained in:
@@ -276,6 +276,26 @@ func TestResourceResponseDoesNotExposeAutoIncrementRelationIDs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListSafetyEventDisposalsReturnsOnlyTheRequestedEventHistory(t *testing.T) {
|
||||||
|
_, mock := setupPlatformRoleDatabase(t)
|
||||||
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "saf_event_disposal" WHERE saf_event_identity = $1`)).
|
||||||
|
WithArgs("event-a").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||||
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "saf_event_disposal" WHERE saf_event_identity = $1 ORDER BY created_at asc LIMIT $2`)).
|
||||||
|
WithArgs("event-a", 20).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "saf_event_identity", "action", "reason", "operator_identity"}).
|
||||||
|
AddRow(uint64(9), "disposal-a", nil, nil, "enabled", 1, "event-a", "close", "resolved", "operator-a"))
|
||||||
|
|
||||||
|
ctx, recorder := updateContext(http.MethodGet, "/safety/saf_event/event-a/disposals", "event-a", nil)
|
||||||
|
ListSafetyEventDisposals(ctx)
|
||||||
|
|
||||||
|
assertResponseCode(t, recorder, 0)
|
||||||
|
if !strings.Contains(recorder.Body.String(), `"saf_event_identity":"event-a"`) || strings.Contains(recorder.Body.String(), `"id":`) {
|
||||||
|
t.Fatalf("disposal history did not keep the event identity-only shape: %s", recorder.Body.String())
|
||||||
|
}
|
||||||
|
assertMockExpectations(t, mock)
|
||||||
|
}
|
||||||
|
|
||||||
func TestListGasAccountProjectsGasBasicIdentityAndNeverReturnsRelationID(t *testing.T) {
|
func TestListGasAccountProjectsGasBasicIdentityAndNeverReturnsRelationID(t *testing.T) {
|
||||||
_, mock := setupPlatformRoleDatabase(t)
|
_, mock := setupPlatformRoleDatabase(t)
|
||||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "gas_account"`)).
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "gas_account"`)).
|
||||||
|
|||||||
@@ -423,6 +423,22 @@ func stripInternalIDs(value any) any {
|
|||||||
|
|
||||||
// DisposeSafetyEvent atomically updates an event and appends its operator-owned
|
// DisposeSafetyEvent atomically updates an event and appends its operator-owned
|
||||||
// action record. Disposal records deliberately have no update or delete route.
|
// action record. Disposal records deliberately have no update or delete route.
|
||||||
|
func ListSafetyEventDisposals(ctx *gin.Context) {
|
||||||
|
page, size := pageSize(ctx)
|
||||||
|
var list []models.SafEventDisposal
|
||||||
|
query := impl.DBService.Model(&models.SafEventDisposal{}).Where("saf_event_identity = ?", ctx.Param("identity"))
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
infra.Response.Error(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := query.Order("created_at asc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil {
|
||||||
|
infra.Response.Error(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
infra.Response.Success(ctx, gin.H{"total": total, "list": resourceResponse(list)})
|
||||||
|
}
|
||||||
|
|
||||||
func DisposeSafetyEvent(ctx *gin.Context) {
|
func DisposeSafetyEvent(ctx *gin.Context) {
|
||||||
claims, err := middleware.ParseAuth(ctx)
|
claims, err := middleware.ParseAuth(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ func registerSafetyRoute(group *gin.RouterGroup) {
|
|||||||
registerRestrictedWritableResource(group, "/safety/saf_rule", &models.SafRule{}, []string{"rule_code", "version_no", "threshold", "action", "gray_scope"})
|
registerRestrictedWritableResource(group, "/safety/saf_rule", &models.SafRule{}, []string{"rule_code", "version_no", "threshold", "action", "gray_scope"})
|
||||||
registerRestrictedWritableResource(group, "/safety/saf_event", &models.SafEvent{}, []string{"event_code", "level", "title", "smart_cylinder_valve_identity", "sla_at"})
|
registerRestrictedWritableResource(group, "/safety/saf_event", &models.SafEvent{}, []string{"event_code", "level", "title", "smart_cylinder_valve_identity", "sla_at"})
|
||||||
registerRestrictedWritableResource(group, "/safety/saf_inspection", &models.SafInspection{}, []string{"result", "evidence_uri"}, requiredRelation("user_account_identity", "user_account_id", &models.UserAccount{}), requiredRelation("staff_account_identity", "staff_account_id", &models.StaffAccount{}))
|
registerRestrictedWritableResource(group, "/safety/saf_inspection", &models.SafInspection{}, []string{"result", "evidence_uri"}, requiredRelation("user_account_identity", "user_account_id", &models.UserAccount{}), requiredRelation("staff_account_identity", "staff_account_id", &models.StaffAccount{}))
|
||||||
|
group.GET("/safety/saf_event/:identity/disposals", platform.ListSafetyEventDisposals)
|
||||||
group.POST("/safety/saf_event/:identity/disposals", platform.DisposeSafetyEvent)
|
group.POST("/safety/saf_event/:identity/disposals", platform.DisposeSafetyEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,11 @@ func TestEveryContractHasRegisteredRoute(t *testing.T) {
|
|||||||
case platform.ReadOnly:
|
case platform.ReadOnly:
|
||||||
assertRouteMethods(t, routes, path, http.MethodGet)
|
assertRouteMethods(t, routes, path, http.MethodGet)
|
||||||
assertRouteMethods(t, routes, path+"/:identity", http.MethodGet)
|
assertRouteMethods(t, routes, path+"/:identity", http.MethodGet)
|
||||||
|
assertNoRouteMethods(t, routes, path, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete)
|
||||||
|
assertNoRouteMethods(t, routes, path+"/:identity", http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete)
|
||||||
case platform.AppendOnly:
|
case platform.AppendOnly:
|
||||||
assertRouteMethods(t, routes, path, http.MethodPost)
|
assertRouteMethods(t, routes, path, http.MethodGet, http.MethodPost)
|
||||||
|
assertNoRouteMethods(t, routes, path, http.MethodPut, http.MethodPatch, http.MethodDelete)
|
||||||
default:
|
default:
|
||||||
assertRouteMethods(t, routes, path, http.MethodGet, http.MethodPost)
|
assertRouteMethods(t, routes, path, http.MethodGet, http.MethodPost)
|
||||||
assertRouteMethods(t, routes, path+"/:identity", http.MethodGet, http.MethodPut, http.MethodDelete)
|
assertRouteMethods(t, routes, path+"/:identity", http.MethodGet, http.MethodPut, http.MethodDelete)
|
||||||
@@ -169,3 +172,12 @@ func assertRouteMethods(t *testing.T, routes map[string]map[string]bool, path st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assertNoRouteMethods(t *testing.T, routes map[string]map[string]bool, path string, methods ...string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, method := range methods {
|
||||||
|
if routes[path][method] {
|
||||||
|
t.Errorf("route %s %s must not be registered", method, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
| 前端资源与后端路由只按资源名和文件名进行粗略匹配 | `ResourceContract.Path` 成为唯一后端路径声明;审计逐项校验 GET/POST/PUT/PATCH/DELETE(按资源模式)以及资源路径一致性 | Go 的 `TestEveryContractHasRegisteredRoute` 和 `pnpm audit:platform` |
|
| 前端资源与后端路由只按资源名和文件名进行粗略匹配 | `ResourceContract.Path` 成为唯一后端路径声明;审计逐项校验 GET/POST/PUT/PATCH/DELETE(按资源模式)以及资源路径一致性 | Go 的 `TestEveryContractHasRegisteredRoute` 和 `pnpm audit:platform` |
|
||||||
| 页面存在但未被菜单路由实际加载时可能漏检 | 审计读取全部路由模块,解析页面动态导入,再确认页面通过 `getResource(契约路径)` 使用对应资源且路由具有 `menu.platform.*` 元数据 | `audit-check.test.mjs` 的菜单映射用例 |
|
| 页面存在但未被菜单路由实际加载时可能漏检 | 审计读取全部路由模块,解析页面动态导入,再确认页面通过 `getResource(契约路径)` 使用对应资源且路由具有 `menu.platform.*` 元数据 | `audit-check.test.mjs` 的菜单映射用例 |
|
||||||
| API 或页面可能展示、提交数据库自增 `id` / `*_id` | 对 `src/api` 与 `src/views` 全量扫描;详情页的显式过滤逻辑被识别为防护而非泄漏 | `pnpm audit:platform` |
|
| API 或页面可能展示、提交数据库自增 `id` / `*_id` | 对 `src/api` 与 `src/views` 全量扫描;详情页的显式过滤逻辑被识别为防护而非泄漏 | `pnpm audit:platform` |
|
||||||
|
| 仅追加处置和只读状态写入存在审计盲区 | 处置资源必须由 `saf_event` 详情动作触发、不可拥有独立页面;后端仅允许 GET/POST,且按事件标识查询处置历史;只读资源扫描 API、路由和页面中的 `updateStatus` | 6 个 `audit-check.test.mjs` 用例及逐契约路由方法测试 |
|
||||||
|
|
||||||
## 身份字段与保留理由
|
## 身份字段与保留理由
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ pnpm build
|
|||||||
| `go test ./...` | 0 | 通过 |
|
| `go test ./...` | 0 | 通过 |
|
||||||
| `go build ./cmd/main` | 0 | 通过 |
|
| `go build ./cmd/main` | 0 | 通过 |
|
||||||
| `go test ./internal/routers -run '^TestEveryContractHasRegisteredRoute$' -v` | 0 | 通过 |
|
| `go test ./internal/routers -run '^TestEveryContractHasRegisteredRoute$' -v` | 0 | 通过 |
|
||||||
| `node --test scripts/audit-check.test.mjs` | 0 | 通过,3 个审计用例 |
|
| `node --test scripts/audit-check.test.mjs` | 0 | 通过,6 个审计用例 |
|
||||||
| `pnpm type:check` | 0 | 通过 |
|
| `pnpm type:check` | 0 | 通过 |
|
||||||
| `pnpm audit:platform` | 0 | 通过 |
|
| `pnpm audit:platform` | 0 | 通过 |
|
||||||
| `pnpm build` | 0 | 通过 |
|
| `pnpm build` | 0 | 通过 |
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const sourceFiles = (dir, extensions = ['.ts', '.vue']) => new Map(files(dir).fi
|
|||||||
const mutationActions = (source) => [...source.matchAll(/resourceApi\.(create|update|updateStatus|archive)\b/g)].map((match) => match[1]);
|
const mutationActions = (source) => [...source.matchAll(/resourceApi\.(create|update|updateStatus|archive)\b/g)].map((match) => match[1]);
|
||||||
|
|
||||||
function requiredBackendRoutes(contract) {
|
function requiredBackendRoutes(contract) {
|
||||||
if (contract.mode === 'append_only') return [{ method: 'POST', path: contract.path }];
|
if (contract.mode === 'append_only') return [{ method: 'GET', path: contract.path }, { method: 'POST', path: contract.path }];
|
||||||
const resource = contract.path;
|
const resource = contract.path;
|
||||||
const detail = `${resource}/:identity`;
|
const detail = `${resource}/:identity`;
|
||||||
if (contract.mode === 'readonly') return [{ method: 'GET', path: resource }, { method: 'GET', path: detail }];
|
if (contract.mode === 'readonly') return [{ method: 'GET', path: resource }, { method: 'GET', path: detail }];
|
||||||
@@ -23,7 +23,7 @@ function routeCoverage(contract, routeSources, viewSources) {
|
|||||||
const expectedView = new RegExp(`getResource\\(\\s*['\"]${escapeRegExp(contract.path)}['\"]\\s*\\)`);
|
const expectedView = new RegExp(`getResource\\(\\s*['\"]${escapeRegExp(contract.path)}['\"]\\s*\\)`);
|
||||||
let hasPage = false;
|
let hasPage = false;
|
||||||
let hasMenu = false;
|
let hasMenu = false;
|
||||||
for (const source of routeSources) {
|
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)) {
|
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]}`);
|
const view = viewSources.get(`src/views/${match[1]}`);
|
||||||
if (!view || !expectedView.test(view)) continue;
|
if (!view || !expectedView.test(view)) continue;
|
||||||
@@ -34,6 +34,11 @@ function routeCoverage(contract, routeSources, viewSources) {
|
|||||||
return { hasPage, hasMenu };
|
return { hasPage, hasMenu };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sourceEntries(sources, fallbackDirectory) {
|
||||||
|
if (sources instanceof Map) return [...sources];
|
||||||
|
return sources.map((source, index) => [`${fallbackDirectory}/${index}`, source]);
|
||||||
|
}
|
||||||
|
|
||||||
function escapeRegExp(value) {
|
function escapeRegExp(value) {
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
}
|
}
|
||||||
@@ -43,11 +48,11 @@ export function scanInternalIdLeaks(sources) {
|
|||||||
const failures = [];
|
const failures = [];
|
||||||
for (const [file, source] of sources) {
|
for (const [file, source] of sources) {
|
||||||
for (const line of source.split(/\r?\n/)) {
|
for (const line of source.split(/\r?\n/)) {
|
||||||
// These are defensive filters that explicitly remove IDs from details, not leaks.
|
// Remove only the defensive predicates, then keep scanning the rest of the line.
|
||||||
if (line.includes("key !== 'id'") || line.includes("key.endsWith('_id')")) continue;
|
const scanned = line.replace(/key\s*!==\s*['\"]id['\"]/g, '').replace(/key\.endsWith\(\s*['\"]_id['\"]\s*\)/g, '');
|
||||||
const relation = line.match(/\b([A-Za-z][A-Za-z0-9_]*_id)\b/);
|
const relation = scanned.match(/\b([A-Za-z][A-Za-z0-9_]*_id)\b/);
|
||||||
if (relation) failures.push(`${file}: internal identifier ${relation[1]}`);
|
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`);
|
else if (/\bdata-index\s*=\s*['\"]id['\"]|\.id\b|[,{]\s*id\s*:/.test(scanned)) failures.push(`${file}: internal identifier id`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return failures;
|
return failures;
|
||||||
@@ -68,10 +73,21 @@ export function auditPlatform({ manifest, resources, readOnlyPage, routeSources,
|
|||||||
}
|
}
|
||||||
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`);
|
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}`);
|
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;
|
if (contract.mode === 'append_only') {
|
||||||
const coverage = routeCoverage(contract, routeSources, viewSources);
|
const event = resources.find((item) => item.name === 'saf_event');
|
||||||
if (!coverage.hasPage) failures.push(`${label}: missing page`);
|
if (!event?.detailActions?.some((action) => action.name === contract.name && action.resource === contract.path)) failures.push(`${label}: missing saf_event detail action`);
|
||||||
if (!coverage.hasMenu) failures.push(`${label}: missing menu route`);
|
if ([...viewSources.keys()].some((file) => file.includes(`/${contract.name}/`))) failures.push(`${label}: independent page exposed`);
|
||||||
|
} else {
|
||||||
|
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');
|
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);
|
const readonlyMutations = mutationActions(readOnlyPage);
|
||||||
@@ -97,7 +113,7 @@ function runAudit() {
|
|||||||
manifest,
|
manifest,
|
||||||
resources: loadResources(),
|
resources: loadResources(),
|
||||||
readOnlyPage: fs.readFileSync('src/views/shared/ReadOnlyListPage.vue', 'utf8'),
|
readOnlyPage: fs.readFileSync('src/views/shared/ReadOnlyListPage.vue', 'utf8'),
|
||||||
routeSources: [...sourceFiles('src/router/routes/modules', ['.ts']).values()],
|
routeSources: sourceFiles('src/router', ['.ts']),
|
||||||
viewSources,
|
viewSources,
|
||||||
apiSources: sourceFiles('src/api', ['.ts']),
|
apiSources: sourceFiles('src/api', ['.ts']),
|
||||||
responseShapeVerified,
|
responseShapeVerified,
|
||||||
|
|||||||
@@ -27,6 +27,18 @@ test('扫描 API 和页面中用于展示或请求的内部 ID', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('每个资源必须由带菜单元数据的路由实际加载对应页面', () => {
|
test('每个资源必须由带菜单元数据的路由实际加载对应页面', () => {
|
||||||
const failures = auditPlatform({
|
const failures = auditPlatform({
|
||||||
manifest: { resources: [{ domain: 'gas', name: 'gas_basic', path: '/gas/gas_basic', mode: 'writable', pageKind: 'list' }], routes: [
|
manifest: { resources: [{ domain: 'gas', name: 'gas_basic', path: '/gas/gas_basic', mode: 'writable', pageKind: 'list' }], routes: [
|
||||||
@@ -46,3 +58,37 @@ test('每个资源必须由带菜单元数据的路由实际加载对应页面',
|
|||||||
|
|
||||||
assert.deepEqual(failures, ['gas/gas_basic: missing menu route']);
|
assert.deepEqual(failures, ['gas/gas_basic: missing menu route']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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']);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user