|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { describe, it, expect } from 'vitest'; |
| 4 | +import { SysAuditLog } from './index.js'; |
| 5 | + |
| 6 | +/** |
| 7 | + * #8147 — `export` and `permission_change` are RETIRED from the |
| 8 | + * `sys_audit_log.action` enum (maintainer ruling 2026-08-12 on #7675, ADR-0049 |
| 9 | + * enforce-or-remove, registered under ADR-0087 as `audit-log-action-enum-retired`). |
| 10 | + * |
| 11 | + * This file exists because **nothing else in the repo can detect a regression |
| 12 | + * here.** The enum is not enforced on writes at all: `validateRecord` skips |
| 13 | + * `readonly` fields (`record-validator.ts`, insert branch) and every |
| 14 | + * `sys_audit_log` field is `readonly: true`, so re-adding a value refuses |
| 15 | + * nothing and rejects nothing. The generated translation bundles are the only |
| 16 | + * other committed artifact that moves with the enum, and they only pin that the |
| 17 | + * bundle and the enum AGREE — regenerate both and the drift disappears. An |
| 18 | + * object field has no `retiredKey()` tombstone to reject the name at authoring |
| 19 | + * time the way a spec property does, so this pin IS the tombstone for the |
| 20 | + * platform-owned declaration (the `sys_comment` retired-fields precedent, #4756). |
| 21 | + * |
| 22 | + * The expectations below are written as literals on purpose. A test that read |
| 23 | + * the allowed set out of the object and asserted the object matched it could |
| 24 | + * not fail — expectation and reality would derive from the same source. |
| 25 | + * |
| 26 | + * If a future change genuinely needs one of these actions back, it arrives |
| 27 | + * WRITER-FIRST — the emission point, its tests, and the list view/widget that |
| 28 | + * surfaces it — and updates this file deliberately, never as collateral. |
| 29 | + */ |
| 30 | + |
| 31 | +const RETIRED_ACTIONS: ReadonlyArray<readonly [action: string, prescription: string]> = [ |
| 32 | + [ |
| 33 | + 'permission_change', |
| 34 | + 'permission-object writes are already on the ledger as ordinary `create` / `update` ' |
| 35 | + + 'rows written by the generic hook writer; a second semantically-duplicate row is ' |
| 36 | + + 'not minted. Filter the permission objects by `object_name` instead.', |
| 37 | + ], |
| 38 | + [ |
| 39 | + 'export', |
| 40 | + 'no export feature has ever written an audit row — `actionFor()` in audit-writers.ts ' |
| 41 | + + 'emits create/update/delete and nothing else. A filter on this value matched ' |
| 42 | + + 'nothing on every deployment that has ever run.', |
| 43 | + ], |
| 44 | +]; |
| 45 | + |
| 46 | +/** Option values declared by the `action` select field. */ |
| 47 | +function actionValues(): string[] { |
| 48 | + const field = (SysAuditLog as { fields?: Record<string, { options?: unknown }> }) |
| 49 | + .fields?.action; |
| 50 | + const options = (field?.options ?? []) as Array<string | { value?: string }>; |
| 51 | + return options.map((o) => (typeof o === 'string' ? o : String(o.value))); |
| 52 | +} |
| 53 | + |
| 54 | +/** Every value named by every `action` filter across every shipped list view. */ |
| 55 | +function filteredActionValues(): Array<{ view: string; value: string }> { |
| 56 | + const views = (SysAuditLog as { |
| 57 | + listViews?: Record<string, { filter?: Array<{ field?: string; value?: unknown }> }>; |
| 58 | + }).listViews ?? {}; |
| 59 | + const out: Array<{ view: string; value: string }> = []; |
| 60 | + for (const [view, def] of Object.entries(views)) { |
| 61 | + for (const clause of def.filter ?? []) { |
| 62 | + if (clause.field !== 'action') continue; |
| 63 | + const values = Array.isArray(clause.value) ? clause.value : [clause.value]; |
| 64 | + for (const v of values) out.push({ view, value: String(v) }); |
| 65 | + } |
| 66 | + } |
| 67 | + return out; |
| 68 | +} |
| 69 | + |
| 70 | +describe('sys_audit_log — retired actions stay retired (#8147)', () => { |
| 71 | + it.each(RETIRED_ACTIONS)( |
| 72 | + '%s is not declared by the action enum', |
| 73 | + (action, prescription) => { |
| 74 | + expect( |
| 75 | + actionValues(), |
| 76 | + `sys_audit_log.action '${action}' was retired under ADR-0049 (#8147) — ${prescription}`, |
| 77 | + ).not.toContain(action); |
| 78 | + }, |
| 79 | + ); |
| 80 | + |
| 81 | + it.each(RETIRED_ACTIONS)( |
| 82 | + '%s is not named by any shipped list-view filter', |
| 83 | + (action, prescription) => { |
| 84 | + const offenders = filteredActionValues().filter((f) => f.value === action); |
| 85 | + expect( |
| 86 | + offenders, |
| 87 | + `a list view filters on the retired action '${action}' (${offenders |
| 88 | + .map((o) => o.view) |
| 89 | + .join(', ')}) — it can never match. ${prescription}`, |
| 90 | + ).toEqual([]); |
| 91 | + }, |
| 92 | + ); |
| 93 | + |
| 94 | + it('every list-view action filter names a value the enum still declares', () => { |
| 95 | + const declared = new Set(actionValues()); |
| 96 | + const dangling = filteredActionValues().filter((f) => !declared.has(f.value)); |
| 97 | + expect( |
| 98 | + dangling, |
| 99 | + 'a list view filters `action` on a value the enum does not declare, so the view is ' |
| 100 | + + 'permanently empty — the visible product defect the 2026-08-12 ruling named ' |
| 101 | + + '(空 widget + 永远查不到东西的过滤器是可见产品缺陷). Narrow the filter with the enum.', |
| 102 | + ).toEqual([]); |
| 103 | + }); |
| 104 | + |
| 105 | + /** |
| 106 | + * The deliberate NON-retirement. The 2026-08-12 ruling named `import` |
| 107 | + * alongside the other two on the premise 无此 feature, and that premise is |
| 108 | + * false: `plugin-auth`'s admin user-import writes a run-level row |
| 109 | + * (`admin-import-users.ts` — `action: 'import'`, `record_id: null`, |
| 110 | + * `object_name: 'sys_user'`) on every run, and case W4 of |
| 111 | + * `packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts` |
| 112 | + * asserts that row exists. |
| 113 | + * |
| 114 | + * Retiring it would make the enum deny a value the platform writes, silently |
| 115 | + * — see the file docblock on why nothing would go red. This assertion is the |
| 116 | + * detector. If a maintainer rules that `import` should go, the WRITER and the |
| 117 | + * dogfood case go first, and this line goes with them. |
| 118 | + */ |
| 119 | + it('import is still declared — it has a live writer (#8147 escalation)', () => { |
| 120 | + expect( |
| 121 | + actionValues(), |
| 122 | + "sys_audit_log.action 'import' must stay declared: plugin-auth's admin user-import " |
| 123 | + + 'writes a real run-level row with this action on every run (admin-import-users.ts), ' |
| 124 | + + 'pinned by dogfood case W4. Removing it makes the enum deny a value the platform ' |
| 125 | + + 'writes — and silently, because readonly fields are never validated.', |
| 126 | + ).toContain('import'); |
| 127 | + }); |
| 128 | +}); |
0 commit comments