Skip to content

Commit 05a30e6

Browse files
feat(service-settings,plugin-audit,plugin-sharing): config_change rows name their settings row; record pages for audit rows and queues (#30728)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9D1sr8NvtKQ3yTjysfPXCrvF)_ Refs #30209 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:设置写入的 `config_change` 审计行现在把被改的设置行 id 写进 `record_id`;`sys_audit_log` 新增默认记录页,列出「同一记录的其他变更」;`sys_queue` 新增默认记录页,成员列表按队列 `name` 过滤,「新建」预填队列名。 - 为什么改:#30209 第 4 行 B 与第 6 行 B 的裁定(6670358877)。审计行有了 `record_id`,记录页就能把同一设置行的镜像 create/update 行(带非机密明文值)摆在一起;队列成员按名字存,记录页才能直接管成员。 - 风险与回滚:⚠ 读权限收窄:新的 `config_change` 行有了 `record_id`,就归父记录读门管,只持有审计台账读授权、读不到设置表的人从此看不到新的 `config_change` 行(管理员及持 `view_all_audit_log` 者照看;旧行 `record_id` 为空,照旧可见)。席位已接受此后果,维护者可在本 PR 否决。其余是新增页面,不改 `sys_queue_member.queue`,不碰 spec 与 ui。回滚即撤本 PR。 - 先看:`packages/services/service-settings/src/settings-service.ts` 的 `upsertRow` 与 `config-change-audit.ts` 的 `record_id`;`packages/plugins/plugin-audit/src/sys-audit-log.page.ts`;`packages/plugins/plugin-sharing/src/sys-queue.page.ts`;`packages/qa/dogfood/test/audit-log-parent-read-gate.dogfood.test.ts` 新增的读门用例。 - 两分钟验证:`pnpm --filter @objectstack/plugin-sharing exec vitest run src/sys-queue-page.test.ts`(8 条全绿)与 `pnpm --filter @objectstack/service-settings exec vitest run src/config-change-audit.test.ts`(18 条全绿)。 - 需要你做的:确认是否接受上面的读权限收窄;声明队列(包里声明的队列)不在本 PR 范围。 ## What changes **Row 4 B: a `config_change` row names the settings row it changed, and an audit row's record page lists the other changes to the same record.** - `@objectstack/service-settings`: `SettingsService.upsertRow` returns the stored row's id (`rowId`) from the update branch (the probed row) and the insert branch (the inserted row), and null on the in-memory paths. The audit call passes it as `recordId`, a new optional field on the `SettingsAuditSink.record` entry, and `buildConfigChangeAuditSink` writes it to `record_id`. `object_name` already named the store (`sys_setting`, or `sys_platform_setting` for a global value). `metadata` and the digest in `new_value` are unchanged. - `@objectstack/plugin-audit`: a default full record page for `sys_audit_log` (`sys_audit_log_detail`, served through the plugin manifest's `pages`). It draws every field but `id` in the object's own field groups, and a `record:related_list` over `sys_audit_log` with `relationshipField` and `relationshipValueField` both `record_id`, newest first, with `actions: []` so there is no "New" on the append-only ledger. The page label is translated in all eleven locales (`translations/pages.ts`, wired through `withSourceFallback` with recorded source hashes and declared in the extract config). - The #24611 comment in `sys-audit-log.object.ts` and the row-class prose in `audit-log-read-visibility.ts` now say `config_change` rows name their settings row, and that older `config_change` rows (and `import` rows) do not. The gate code is unchanged. **Row 6 B: a queue's record page lists its members.** - `@objectstack/plugin-sharing`: a default full record page for `sys_queue` (`sys_queue_detail`). Its members list reads `sys_queue_member` with `relationshipField: 'queue'` and `relationshipValueField: 'name'`, so the console filters on `queue` equal to this queue's `name` and its "New" opens the member form with `queue` filled in with that name. `sys_queue_member.queue` is unchanged. The page label ships in the plugin's four locales. - Declared queues are out: a package's queue is a registry item with no `sys_queue` row, so it has no record page. Its organization's member rows stay on the Queue Members list. ## Tests - `service-settings/src/config-change-audit.test.ts` (18 pass): the global case pins `record_id` to the `sys_platform_setting` row's id, the tenant case to the `sys_setting` row's id (the re-pin near the old L386), and a new case writes, overwrites and clears one key: one stored row, three ledger rows with the same `record_id`, and `metadata` keys exactly as before. - `plugin-audit/src/sys-audit-log-page.test.ts` (10 pass): the manifest carries the page; `PageSchema` and each node's `ComponentPropsMap` row parse it (with a control the door refuses); the details cover every field but `id`; the list props; the eleven locales. - `plugin-audit/src/sys-audit-log-same-record.integration.test.ts` (6 pass), on ObjectQL, the SQLite driver and the real CRUD mirror, with the filter read off the page: beside a global `config_change` row the list holds both `config_change` rows and the mirror's create and update; the mirror's update carries the old and new value in plain text (the measure-first reading: yes); a tenant row lists only its own rows; a secret's mirror row carries neither the value nor its handle. - `plugin-sharing/src/sys-queue-page.test.ts` (8 pass), on ObjectQL and SqlDriver over in-memory SQLite: the filter `{ queue: queue.name }` read off the page lists that queue's members and no other's (control: keyed on the id it finds nothing); the "New" prefill `{ queue: 'billing' }` saves a member the list shows; and the list agrees with `OwnershipQueueService.resolveQueue` on the members. - Dogfood (both run, 14 of 14 pass): `settings-config-change-audit.dogfood.test.ts` adds an end-to-end pin that `PUT /api/settings/branding` writes a `config_change` row whose `record_id` is the stored `sys_setting` row's id, with the mirror's row about the same write beside it carrying the value. `audit-log-parent-read-gate.dogfood.test.ts` moves its run-level row to an object-less action invocation (the settings write no longer supplies one, and this boot has no other run-level producer), and adds a pin that the `config_change` row follows the gate on its settings row: the ledger-only reader gets 404, the admin 200. - Ablations through `scripts/ablation-replace.mjs`, each restored to the HEAD blob with `git diff HEAD` empty: `record_id: entry.recordId ?? null` to `record_id: null` turns 3 `config-change-audit` cases red; the queue page's `relationshipValueField: 'name'` to `'id'` turns 4 `sys-queue-page` cases red. - Full suites: `@objectstack/service-settings` 69 files, 1179 tests; `@objectstack/plugin-audit` 75 files, 1161 tests; `@objectstack/plugin-sharing` 67 files, 1417 tests; all pass, and `tsc --noEmit` exits 0 in each. ## Acceptance notes - **Read narrowing, accepted by the seat.** A reader who holds only the ledger's read grant no longer sees new `config_change` rows unless it can read the settings row they name. Holders of `view_all_audit_log` see every row. The changeset and the 风险 line say this so the maintainer can veto it here. - **Where the page is reached.** Setup's Audit Logs entry opens the `audit:log` browser drawer, not this record page. The page opens from the object's list and from record links. Linking the drawer to the page is a ui change, outside this PR. - **No ui fallback was needed.** The console's related list has no limit on a self-referencing list. A row with no `record_id` sends no query and shows the empty state. The list includes the row being read. - **Left unchanged:** the `config_changes` list view's columns; the fixture in `audit-log-deployment-level-rows.integration.test.ts`, whose null-`record_id` `config_change` row tests `tenant_id`, not the record pointer; and open PR #30448, which edits `setMany`'s env-lock branch in the same file but not `upsertRow` or the audit call. ## Verification - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 69 commands on head f6c2de4 (origin/main merged in). Each ran with its exit code captured before any pipe: 69 exit 0. `check-plugin-teardown-shape --self-test` exited 3 on the first pass (shallow clone, the pinned fixture commit unreachable); after fetching `621a4876` it exits 0. `--ran` reconciliation: "69 derived famil(ies) accounted for — 69 run, 0 NOT-MEASURED (a DERIVED zero — all 69 recorded an exit code and none of them is 3)". - `pnpm lint`: exit 0. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4cyf254wVgypzQ5Uj1a --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 82142ed commit 05a30e6

22 files changed

Lines changed: 1123 additions & 78 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/service-settings': patch
3+
'@objectstack/plugin-audit': patch
4+
'@objectstack/plugin-sharing': patch
5+
---
6+
7+
feat(service-settings,plugin-audit,plugin-sharing): a config_change ledger row names the settings row it changed, an audit row's record page lists the other changes to the same record, and a queue's record page lists its members
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
- **`@objectstack/service-settings`: the `config_change` row's shape changes (not breaking).** A settings write's `config_change` row in `sys_audit_log` now sets `record_id` to the id of the stored settings row the write changed (`sys_setting`, or `sys_platform_setting` for a global value), beside the `object_name` it already named; before, `record_id` was always null. `metadata` and the digest in `new_value` are unchanged, and an in-memory settings store still writes null. Rows written before this release keep their null `record_id`.
13+
- **Who can read those rows narrows.** Because a `config_change` row now names a record, the ledger's parent-record read gate judges it by that settings row: a reader holding only the ledger's own read grant, who cannot read the settings store, no longer sees new `config_change` rows. Holders of the ledger's audit capability (`view_all_audit_log`, administrators by default) still see every row. Older rows with a null `record_id` are still served under the ledger grant. Nothing becomes visible to a reader who could not see it before.
14+
- **`@objectstack/plugin-audit`: `sys_audit_log` gets a default record page** (`sys_audit_log_detail`) that shows the row's fields and lists every other ledger row with the same `record_id`, newest first. Beside a `config_change` row that list holds the CRUD mirror's create and update rows for the same settings row, which carry a non-secret value in plain text; a secret's mirror row carries neither the value nor its handle. The list has no "New". The page label ships in all eleven locales.
15+
- **`@objectstack/plugin-sharing`: `sys_queue` gets a default record page** (`sys_queue_detail`) whose members list reads `sys_queue_member` where `queue` equals this queue's `name`, and whose "New" opens the member form with `queue` filled in with that name. `sys_queue_member.queue` is unchanged. Declared queues have no `sys_queue` row, so they have no page. The page label ships in the plugin's four locales.

‎packages/plugins/plugin-audit/scripts/i18n-extract.config.ts‎

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@
3434
* recognised flag spelling out of this whole comment, so a second spelling
3535
* would keep the opt-in switched on after someone deleted it from the command
3636
* block — the one place that decides.
37+
*
38+
* `pages` declares the record page this plugin ships (`sys_audit_log_detail`).
39+
* The command above writes the objects sub-tree only, so the page's one bundle
40+
* key, its page-level `label`, is HAND-AUTHORED in `src/translations/pages.ts`
41+
* and rides in the `translations` baseline below. Declaring the page here is
42+
* what puts its label in the coverage ratchet's population.
3743
*/
3844

3945
import { defineStack, type ObjectStackDefinition } from '@objectstack/spec';
@@ -52,6 +58,8 @@ import { ptBRObjects } from '../src/translations/pt-BR.objects.generated.js';
5258
import { ruObjects } from '../src/translations/ru.objects.generated.js';
5359
import { deObjects } from '../src/translations/de.objects.generated.js';
5460
import { koObjects } from '../src/translations/ko.objects.generated.js';
61+
import { SysAuditLogDetailPage } from '../src/sys-audit-log.page.js';
62+
import { auditPageTranslations } from '../src/translations/pages.js';
5563

5664
/**
5765
* The annotation is load-bearing, not decoration (#10868). `defineStack`
@@ -69,18 +77,19 @@ import { koObjects } from '../src/translations/ko.objects.generated.js';
6977
*/
7078
const config: ObjectStackDefinition = defineStack({
7179
objects: [SysAuditLog, SysActivity, SysComment, SysCommentReaction] as any,
80+
pages: [SysAuditLogDetailPage],
7281
translations: [
73-
{ en: { objects: enObjects } },
74-
{ 'zh-CN': { objects: zhCNObjects } },
75-
{ 'ja-JP': { objects: jaJPObjects } },
76-
{ 'es-ES': { objects: esESObjects } },
77-
{ 'zh-Hant': { objects: zhHantObjects } },
78-
{ 'ar': { objects: arObjects } },
79-
{ 'fr': { objects: frObjects } },
80-
{ 'pt-BR': { objects: ptBRObjects } },
81-
{ 'ru': { objects: ruObjects } },
82-
{ 'de': { objects: deObjects } },
83-
{ 'ko': { objects: koObjects } },
82+
{ en: { objects: enObjects, pages: auditPageTranslations.en } },
83+
{ 'zh-CN': { objects: zhCNObjects, pages: auditPageTranslations['zh-CN'] } },
84+
{ 'ja-JP': { objects: jaJPObjects, pages: auditPageTranslations['ja-JP'] } },
85+
{ 'es-ES': { objects: esESObjects, pages: auditPageTranslations['es-ES'] } },
86+
{ 'zh-Hant': { objects: zhHantObjects, pages: auditPageTranslations['zh-Hant'] } },
87+
{ 'ar': { objects: arObjects, pages: auditPageTranslations['ar'] } },
88+
{ 'fr': { objects: frObjects, pages: auditPageTranslations['fr'] } },
89+
{ 'pt-BR': { objects: ptBRObjects, pages: auditPageTranslations['pt-BR'] } },
90+
{ 'ru': { objects: ruObjects, pages: auditPageTranslations['ru'] } },
91+
{ 'de': { objects: deObjects, pages: auditPageTranslations['de'] } },
92+
{ 'ko': { objects: koObjects, pages: auditPageTranslations['ko'] } },
8493
],
8594
});
8695

‎packages/plugins/plugin-audit/src/audit-log-read-visibility.ts‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@
3737
* which name the session, and its `login_failed` / `account_locked`, which
3838
* name the account's `sys_user` row (`auth-event-audit.ts`), and an
3939
* `action_invocation` row whose action named a subject record
40-
* (`action-invocation-audit.ts`). Judged by the record gate;
40+
* (`action-invocation-audit.ts`), and service-settings' `config_change`,
41+
* which names the stored settings row the write changed (`sys_setting`, or
42+
* `sys_platform_setting` for a global value), the row the mirror's own
43+
* `create` / `update` about that write names. Judged by the record gate;
4144
* the session is in the audit writer's `AUDIT_EXCLUDED_OBJECTS`, so the gate
4245
* names it ({@link LEDGER_NAMED_PARENTS}) to keep judging it.
4346
* A record that no longer exists is read by no caller, so its rows are
@@ -46,18 +49,20 @@
4649
* deleted record, and every `logout` row (sign-out deletes the session it
4750
* names).
4851
* - **Rows about no record** — they name no `record_id` and their action is
49-
* not a record action: the run-level `import` (plugin-auth's user import),
50-
* `config_change` (service-settings) and `platform_admin_standing_change`
51-
* (plugin-security) rows, an auth event that carried no session id, and an
52-
* `action_invocation` row of an action invoked with no subject record.
52+
* not a record action: the run-level `import` (plugin-auth's user import)
53+
* and `platform_admin_standing_change` (plugin-security) rows, a
54+
* `config_change` row stored before its writer recorded the settings row,
55+
* an auth event that carried no session id, and an `action_invocation` row
56+
* of an action invoked with no subject record.
5357
* There is no record to judge, and none carries a record's field values
5458
* (the per-writer measurement in `audit-log-field-redaction.ts`), so they
5559
* are OUTSIDE this gate's class and are served under the ledger's own grant
5660
* exactly as before — through a predicate on the ledger's own columns
5761
* ({@link ledgerRowAboutNoRecordWhere}), so a broad read is served whole. This is where the ledger differs from the activity
5862
* stream, which excludes every row naming no parent: the stream has no
5963
* platform producer of such rows, while these have three, and the shipped
60-
* `config_changes` list view reads two of them through the data door.
64+
* `config_changes` list view reads `import` rows (and the older
65+
* `config_change` rows) through the data door.
6166
* - **A record action that names no record** — a `create` / `read` / `update`
6267
* / `delete` row with no `record_id` (the CRUD mirror stamps `null` when it
6368
* cannot derive the id): about a record the gate cannot name, so excluded

‎packages/plugins/plugin-audit/src/audit-plugin.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
ISharingService,
1717
} from '@objectstack/spec/contracts';
1818
import { SysAuditLog, SysActivity, SysComment, SysCommentReaction } from './objects/index.js';
19+
import { SysAuditLogDetailPage } from './sys-audit-log.page.js';
1920
// `sys_notification` was parked here "until that [ADR-0030] migration lands".
2021
// It has landed, so the contribution moved to @objectstack/service-messaging —
2122
// the service that writes the row on every `emit()` (#4154). This plugin never
@@ -273,6 +274,10 @@ export class AuditPlugin implements Plugin {
273274
defaultDatasource: 'cloud',
274275
namespace: 'sys',
275276
objects: [SysAuditLog, SysActivity, SysComment, SysCommentReaction],
277+
// The ledger row's record page: its fields, and every ledger row about
278+
// the same record beside it, so a `config_change` row's digest sits next
279+
// to the mirror's row that carries the value (`sys-audit-log.page.ts`).
280+
pages: [SysAuditLogDetailPage],
276281
// ADR-0029 D7 — contribute the one Audit Log entry into the Setup app's
277282
// `group_diagnostics` slot. The plugin owns sys_audit_log (K2), so the
278283
// entry lives and dies with this plugin and needs no item gate.

‎packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -229,12 +229,17 @@ export const SysAuditLog = ObjectSchema.create({
229229
name: 'config_changes',
230230
label: 'Config',
231231
data: { provider: 'object', object: 'sys_audit_log' },
232-
// [#24611] No Object column. Its rows carry no `record_id`, so no pointer
233-
// names their target, and the column drew one of three stored API names
232+
// [#24611] No Object column: it drew one of three stored API names
234233
// (`sys_setting`, `sys_platform_setting` for a global-scope change,
235-
// `sys_user` for an import). Which setting changed is in `metadata` and
236-
// `new_value`, and the row's page still shows `object_name` and the
237-
// `tenant_id` a global change leaves empty.
234+
// `sys_user` for an import) as text. A `config_change` row names the
235+
// settings row it changed as its `record_id` (service-settings'
236+
// `config-change-audit.ts`), so the row's page draws that pair as the
237+
// setting's card and lists the ledger's other rows about the same
238+
// setting, the mirror's row with the value among them
239+
// (`sys-audit-log.page.ts`). An `import` row, and a `config_change` row
240+
// written before its writer recorded the id, carry no `record_id`.
241+
// Which setting changed is also in `metadata`, and the row's page shows
242+
// the `tenant_id` a global change leaves empty.
238243
columns: ['created_at', 'action', 'user_id'],
239244
// `export` removed (#8147) — retired from the enum, nothing ever wrote it.
240245
// `import` KEPT deliberately: `plugin-auth`'s admin user-import writes a
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `sys_audit_log`'s record page: the full page this plugin serves through its
5+
* manifest `pages` (`sys-audit-log.page.ts`), whose job is the list of every
6+
* ledger row about the same record beside the row being read.
7+
*
8+
* Four questions, each answered against the shipped artifacts:
9+
*
10+
* 1. the plugin's manifest carries the page;
11+
* 2. the page parses as a page, and every node's props parse against its
12+
* component's own props row (the per-type door `os validate` judges);
13+
* 3. the body draws every field the object declares (a `full` page draws
14+
* only what it authors), and the same-record list is the ledger keyed on
15+
* `record_id` on both sides, with no "New" on an append-only object;
16+
* 4. its label is translated in every shipped locale, `en` byte-equal to
17+
* the page literal.
18+
*
19+
* What the list SELECTS on a real engine and driver, the mirror's rows about
20+
* a settings write included, is `sys-audit-log-same-record.integration.test.ts`.
21+
*/
22+
23+
import { describe, it, expect } from 'vitest';
24+
import { PageSchema, ComponentPropsMap } from '@objectstack/spec/ui';
25+
import { AuditPlugin } from './audit-plugin.js';
26+
import { SysAuditLogDetailPage } from './sys-audit-log.page.js';
27+
import { SysAuditLog } from './objects/index.js';
28+
import { AuditTranslations } from './translations/index.js';
29+
import { auditPageTranslations } from './translations/pages.js';
30+
31+
type AnyRec = Record<string, any>;
32+
33+
const PAGE = SysAuditLogDetailPage as AnyRec;
34+
const COMPONENTS = (PAGE.regions?.[0]?.components ?? []) as AnyRec[];
35+
const nodeOf = (type: string): AnyRec => COMPONENTS.find((c) => c.type === type) ?? {};
36+
const FIELDS = SysAuditLog.fields as AnyRec;
37+
38+
/** The plugin's one manifest, as `init` registers it. */
39+
async function registeredManifest(): Promise<AnyRec> {
40+
const registered: AnyRec[] = [];
41+
const ctx: any = {
42+
getService: (name: string) => {
43+
if (name === 'manifest') return { register: (m: AnyRec) => registered.push(m) };
44+
throw new Error(`no service '${name}'`);
45+
},
46+
registerService: () => {},
47+
hook: () => {},
48+
logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
49+
};
50+
await new AuditPlugin().init(ctx);
51+
expect(registered).toHaveLength(1);
52+
return registered[0];
53+
}
54+
55+
describe('sys_audit_log record page — served through the manifest', () => {
56+
it('the plugin manifest carries the page in `pages`', async () => {
57+
expect((await registeredManifest()).pages).toEqual([SysAuditLogDetailPage]);
58+
});
59+
60+
it('is the default full record page of sys_audit_log, and parses as a page', () => {
61+
expect(PAGE).toMatchObject({
62+
name: 'sys_audit_log_detail',
63+
type: 'record',
64+
object: 'sys_audit_log',
65+
kind: 'full',
66+
isDefault: true,
67+
});
68+
const parsed = PageSchema.safeParse(PAGE);
69+
expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true);
70+
});
71+
72+
it('every node\'s props parse against its own component props row', () => {
73+
const map = ComponentPropsMap as Record<string, { safeParse: (v: unknown) => { success: boolean; error?: any } }>;
74+
const verdicts = COMPONENTS.map((c) => {
75+
const row = map[c.type];
76+
const result = row?.safeParse(c.properties ?? {});
77+
return { type: c.type, known: !!row, ok: !!result?.success, issues: result?.error?.issues ?? [] };
78+
});
79+
expect(verdicts.filter((v) => !v.known || !v.ok)).toEqual([]);
80+
// Control: the door refuses what it should, so the green above is a verdict.
81+
expect(map['record:related_list'].safeParse({ objectName: 'sys_audit_log', relationshipField: 'record_id', relatedField: 'x' }).success)
82+
.toBe(false);
83+
});
84+
});
85+
86+
describe('sys_audit_log record page — the body', () => {
87+
it('authors the header, the strip, the details, the same-record list and the discussion, in that order', () => {
88+
expect(COMPONENTS.map((c) => c.type)).toEqual([
89+
'page:header', 'record:highlights', 'record:details', 'record:related_list', 'record:discussion',
90+
]);
91+
expect(nodeOf('page:header')).toEqual({ type: 'page:header', properties: { placedActions: true } });
92+
});
93+
94+
it('the strip is the object\'s highlightFields minus the title field, and holds neither half of the pointer pair', () => {
95+
const strip = nodeOf('record:highlights').properties.fields as string[];
96+
expect(strip).toEqual((SysAuditLog.highlightFields ?? []).filter((f: string) => f !== SysAuditLog.nameField));
97+
expect(strip).not.toContain('record_id');
98+
expect(strip).not.toContain('object_name');
99+
});
100+
101+
it('the details draw every field the object declares, the system id apart, in its declared groups', () => {
102+
const sections = nodeOf('record:details').properties.sections as AnyRec[];
103+
const drawn = sections.flatMap((s) => s.fields as string[]);
104+
const declared = Object.keys(FIELDS).filter((f) => f !== 'id');
105+
expect([...drawn].sort()).toEqual([...declared].sort());
106+
// Each section holds one declared group's fields, so the body reads as the object groups them.
107+
for (const section of sections) {
108+
const groups = new Set((section.fields as string[]).map((f) => FIELDS[f]?.group));
109+
expect(groups.size, String(section.label?.en)).toBe(1);
110+
expect([...groups][0]).toBe(section.label.en);
111+
}
112+
});
113+
114+
it('the same-record list is the ledger, keyed on `record_id` on both sides, newest first, with no "New"', () => {
115+
const list = nodeOf('record:related_list').properties;
116+
expect(list).toMatchObject({
117+
objectName: 'sys_audit_log',
118+
relationshipField: 'record_id',
119+
relationshipValueField: 'record_id',
120+
sort: [{ field: 'created_at', order: 'desc' }],
121+
// An authored empty action list withholds "New"; the object is append-only.
122+
actions: [],
123+
});
124+
expect((SysAuditLog as AnyRec).enable?.apiMethods).toEqual(['get', 'list']);
125+
// The value it keys on is the pointer's id half, a declared column of this object.
126+
expect(FIELDS.record_id?.referenceVia).toBe('object_name');
127+
// Every column it draws is a field of the object, the two snapshots included.
128+
const columns = list.columns as string[];
129+
expect(columns.filter((c) => !(c in FIELDS))).toEqual([]);
130+
expect(columns).toEqual(expect.arrayContaining(['old_value', 'new_value']));
131+
});
132+
133+
it('every inline label carries the eleven locales this plugin ships', () => {
134+
const locales = Object.keys(auditPageTranslations).sort();
135+
const labels: AnyRec[] = [
136+
...(nodeOf('record:details').properties.sections as AnyRec[]).map((s) => s.label),
137+
nodeOf('record:related_list').properties.title,
138+
];
139+
for (const label of labels) expect(Object.keys(label).sort(), String(label.en)).toEqual(locales);
140+
});
141+
});
142+
143+
describe('sys_audit_log record page — its label is translated', () => {
144+
it('`en` mirrors the page literal byte for byte', () => {
145+
expect(auditPageTranslations.en.sys_audit_log_detail?.label).toBe(PAGE.label);
146+
});
147+
148+
it('every shipped locale serves its own label, the object\'s label in that locale', () => {
149+
for (const [locale, bundle] of Object.entries(AuditTranslations)) {
150+
const served = (bundle as AnyRec).pages?.sys_audit_log_detail?.label;
151+
expect(served, locale).toBe((auditPageTranslations as AnyRec)[locale].sys_audit_log_detail.label);
152+
expect(served, locale).toBe((bundle as AnyRec).objects?.sys_audit_log?.label);
153+
}
154+
});
155+
});

0 commit comments

Comments
 (0)