diff --git a/.changeset/22738-lookup-title-exposure.md b/.changeset/22738-lookup-title-exposure.md new file mode 100644 index 00000000000..b33dcab599b --- /dev/null +++ b/.changeset/22738-lookup-title-exposure.md @@ -0,0 +1,29 @@ +--- +'@objectstack/plugin-approvals': minor +'@objectstack/plugin-audit': minor +--- + +fix(plugin-approvals,plugin-audit)!: a lookup target's title is read only when the TARGET object's declared exposure serves `get` — the approvals inbox's `payload_display` and the activity summary (#22738) + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +The data routes judge an object's `enable` block through the spec's one exposure decision (`apiExposureDenialReason` / `canServeApiOperation`), and the data door's `$expand` and the dataset door's labels already ask it of a lookup's TARGET. Two more reads follow a lookup to its target's title under a system context and never asked it. + +**FROM.** For an administrator and a member alike: + +- **Approvals inbox** (`@objectstack/plugin-approvals`): `GET /api/v1/approvals/requests` and `GET /api/v1/approvals/requests/:id` carried, in `payload_display`, the title of a snapshot lookup's target whose declaration refuses `get`. +- **Activity summary** (`@objectstack/plugin-audit`): an update's tracked-change summary, and a fired milestone's summary, named such a target's records by title in the `sys_activity` row served by `GET /api/v1/data/sys_activity`. + +**TO.** Each title read first asks the decision of the TARGET object, for `get` (turning an id into the record it names, the read `GET /api/v1/data/:target/:id` performs): + +- A refused target is not read. The inbox carries no `payload_display` entry for that key, so the snapshot's stored id stands, which is what a deleted target already answers. The activity summary names the record by its stored id, which is what an unresolvable reference already answers. +- A declaration that cannot be read withholds the title too (fail-closed, at `warn`). +- Only activity rows written from now on change. A written row is a snapshot and is never rewritten, and no other activity column changes. + +A target declaring `apiEnabled: false`, the deny-all `apiMethods: []`, or a whitelist without `get` (for example `['list']`) is withheld; a target with no `enable` block, or a whitelist that grants `get`, is served exactly as before. The decision takes no caller. + +**Measured producers.** Read on `origin/main` `bf515e724d` over every non-test `.ts` source under `packages/` and `examples/`: ten objects refuse `get` by declaration, and exactly one lookup points into any of them, from an object that is itself `apiEnabled: false` (control: 88 lookups into `sys_user`). So no shipped object's served title changes. Deployed and cloud-held object definitions were NOT MEASURED. diff --git a/packages/core/src/security/second-object-read-exposure.pin.test.ts b/packages/core/src/security/second-object-read-exposure.pin.test.ts index f6ffaeb5e13..c8e0f02143e 100644 --- a/packages/core/src/security/second-object-read-exposure.pin.test.ts +++ b/packages/core/src/security/second-object-read-exposure.pin.test.ts @@ -141,16 +141,20 @@ const CALLERS: Record = { 'provenance row for the door and touches a filter position; carried by a follow-up of #22661', }, 'packages/plugins/plugin-approvals/src/approval-service.ts': { - kind: 'open', + kind: 'decided', read: "the approvals inbox's `payload_display` (`ApprovalService.enrichRows` reads referenced records' titles under a system context)", - measured: "an unexposed target's title is served on the inbox list, to an administrator and a member", - carrier: 'outside the card that took the census (another package and lane); carried by a follow-up of #22661', + operation: 'get', + decision: { file: 'packages/plugins/plugin-approvals/src/approval-service.ts', fn: 'servesPayloadDisplayTarget' }, + wiring: { file: 'packages/plugins/plugin-approvals/src/approval-service.ts', call: 'this.servesPayloadDisplayTarget(' }, + pin: 'packages/plugins/plugin-approvals/src/payload-display-target-exposure.test.ts', }, 'packages/plugins/plugin-audit/src/audit-writers.ts': { - kind: 'open', - read: "the activity timeline's tracked-change summary (`resolveLookupTitles` writes referenced records' titles at write time)", - measured: "an unexposed target's title is served in the summary on the activity read, to an administrator and a member", - carrier: 'outside the card that took the census (another package and lane); carried by a follow-up of #22661', + kind: 'decided', + read: "the activity timeline's tracked-change and milestone summaries (`resolveLookupTitles` writes referenced records' titles at write time)", + operation: 'get', + decision: { file: 'packages/plugins/plugin-audit/src/audit-writers.ts', fn: 'servesSummaryTitleTarget' }, + wiring: { file: 'packages/plugins/plugin-audit/src/audit-writers.ts', call: 'servesSummaryTitleTarget(objectName' }, + pin: 'packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts', }, 'packages/lint/src/object-graph.ts': { kind: 'not-a-served-read', @@ -280,7 +284,12 @@ describe('[#22661] every read that reaches a second object asks that object its const decided = Object.values(CALLERS).filter( (c): c is Extract => c.kind === 'decided', ); - expect(decided.map((d) => d.decision.fn).sort()).toEqual(['servesExpansionTarget', 'servesLabelTarget']); + expect(decided.map((d) => d.decision.fn).sort()).toEqual([ + 'servesExpansionTarget', + 'servesLabelTarget', + 'servesPayloadDisplayTarget', + 'servesSummaryTitleTarget', + ]); for (const d of decided) { const body = functionBody(d.decision.file, d.decision.fn); diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index f4370556fe1..c4fdfa01107 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -56,8 +56,8 @@ import type { // fields the caller had already supplied. import type { ExecutionContext } from '@objectstack/spec/kernel'; import { RESUME_AUTHORITY_SERVICE } from '@objectstack/spec/contracts'; -import { isFileIdToken, referenceTargetOf } from '@objectstack/spec/data'; -import type { ObjectCapabilitiesParsed } from '@objectstack/spec/data'; +import { canServeApiOperation, isFileIdToken, referenceTargetOf } from '@objectstack/spec/data'; +import type { EnableLike, ObjectCapabilitiesParsed } from '@objectstack/spec/data'; // [#11993] The SANCTIONED renderer for OPERATION-level refusal copy. The // Operation Message Catalog is the ONE seat for these sentences — its own // header bars both a package-local string table and a second rendering @@ -6391,6 +6391,31 @@ export class ApprovalService implements IApprovalService { } catch { return []; } } + /** + * [#22738] May `payload_display` carry a title read from `target`, the object + * a snapshot's lookup points at? Asked of the spec's one exposure decision + * (ADR-0049) for `get`, the read `GET /data/{target}/{id}` performs and the + * operation the data door's `$expand` and the dataset door's labels ask of + * the same target. It takes no caller: the read runs under a system context. + * + * A refused target is not read, so its key gets no display value and the + * snapshot's stored id stands, the answer a deleted target already gets. A + * declaration that cannot be read withholds too (fail-closed, at `warn`). + */ + private servesPayloadDisplayTarget(target: string): boolean { + let enable: EnableLike | null | undefined; + try { + enable = (this.engine as any).getSchema?.(target)?.enable; + } catch (err: any) { + this.logger?.warn?.( + `[approvals] ApprovalService.enrichRows: the API exposure declaration of "${target}" could not be read, ` + + `so its titles are not resolved and the stored ids stand (fail-closed): ${err?.message ?? err}`, + ); + return false; + } + return canServeApiOperation(enable, 'get'); + } + /** * Field key → display label for an object's schema. Lets the inbox summary * show a human field name ("考核状态") instead of a title-cased machine key @@ -6419,7 +6444,8 @@ export class ApprovalService implements IApprovalService { * Batched: one query per distinct object (target + referenced) plus one * `sys_user` lookup. Best-effort — a deleted record falls back to the * payload snapshot, and any failure leaves the field unset rather than - * failing the list. + * failing the list. [#22738] A referenced object whose declared exposure + * does not serve `get` is not read ({@link servesPayloadDisplayTarget}). */ private async enrichRows(rows: ApprovalRequestRow[]): Promise { if (!rows.length) return; @@ -6477,6 +6503,7 @@ export class ApprovalService implements IApprovalService { } const refTitles = new Map(); for (const [object, idSet] of refIds) { + if (!this.servesPayloadDisplayTarget(object)) continue; const ids = Array.from(idSet); const displayField = this.resolveDisplayField(object); try { diff --git a/packages/plugins/plugin-approvals/src/payload-display-target-exposure.test.ts b/packages/plugins/plugin-approvals/src/payload-display-target-exposure.test.ts new file mode 100644 index 00000000000..6f2ba39fd03 --- /dev/null +++ b/packages/plugins/plugin-approvals/src/payload-display-target-exposure.test.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// [#22738] The inbox's `payload_display` reads a referenced record's title only +// from a target whose declared exposure (ADR-0049) serves `get`, asked through +// the spec's one decision. A refused target is NOT READ, so its key carries no +// display value and the snapshot's stored id stands; a declaration that cannot +// be read withholds too, and says so at `warn`. The exposed targets are the +// control. The read is asserted as well as the value: "not served" could +// otherwise be met by reading the title and dropping it. + +import { describe, it, expect, vi } from 'vitest'; +import { ApprovalService } from './approval-service.js'; + +type Row = Record; +type Schema = { label?: string; enable?: Record; fields: Record }; + +/** One target per shape the decision tells apart; `open` declares no `enable` block. */ +const TARGETS: Record | undefined> = { + hidden: { apiEnabled: false }, + listonly: { apiMethods: ['list'] }, + denyall: { apiMethods: [] }, + getonly: { apiMethods: ['get'] }, + open: undefined, +}; +const REFUSED = ['hidden', 'listonly', 'denyall']; +const SERVED = ['getonly', 'open']; + +const schemas: Record = { + deal: { + label: 'Deal', + fields: { name: {}, ...Object.fromEntries(Object.keys(TARGETS).map((k) => [k, { type: 'lookup', reference: `t_${k}` }])) }, + }, + ...Object.fromEntries(Object.entries(TARGETS).map(([k, enable]) => [`t_${k}`, { fields: { name: {} }, ...(enable ? { enable } : {}) }])), +}; +const tables: Record = { + deal: [{ id: 'd1', name: 'Deal one' }], + ...Object.fromEntries(Object.keys(TARGETS).map((k) => [`t_${k}`, [{ id: `${k}_1`, name: `${k} title` }]])), +}; + +/** The two engine members the enrichment reads; `unreadable` names a target whose declaration throws. */ +function makeEngine(rows: Record, unreadable?: string) { + const reads: string[] = []; + const engine = { + getSchema(object: string) { + if (object === unreadable) throw new Error('declaration unavailable'); + return schemas[object]; + }, + // Each table holds the one row a read can ask for, so the double answers the + // table whole: what is pinned is WHICH objects are read, not the query. + async find(object: string) { + reads.push(object); + return rows[object] ?? []; + }, + }; + return { engine, reads }; +} + +function makeService(unreadable?: string) { + const warn = vi.fn(); + const { engine, reads } = makeEngine(tables, unreadable); + const service = new ApprovalService({ engine: engine as any, logger: { info() {}, warn, error() {}, debug() {} } }); + // `enrichRows` is private; its public callers drag the whole request lifecycle in. + const enrich = (rows: Row[]) => (service as unknown as { enrichRows(r: Row[]): Promise }).enrichRows(rows); + return { enrich, reads, warn }; +} + +const inboxRow = (): Row => ({ + object_name: 'deal', + record_id: 'd1', + payload: { name: 'Deal one', ...Object.fromEntries(Object.keys(TARGETS).map((k) => [k, `${k}_1`])) }, +}); + +describe('[#22738] payload_display serves a referenced title only for a target whose exposure serves `get`', () => { + it('does not read a refused target; its stored id stands, and the served targets keep their titles', async () => { + const { enrich, reads } = makeService(); + const row = inboxRow(); + await enrich([row]); + + expect(row.payload_display).toEqual(Object.fromEntries(SERVED.map((k) => [k, `${k} title`]))); + for (const k of REFUSED) { + expect(reads, `t_${k} was read`).not.toContain(`t_${k}`); + expect((row.payload as Row)[k]).toBe(`${k}_1`); + } + for (const k of SERVED) expect(reads).toContain(`t_${k}`); + }); + + it('withholds a target whose declaration cannot be read (fail-closed), and says so at warn', async () => { + const { enrich, reads, warn } = makeService('t_open'); + const row = inboxRow(); + await enrich([row]); + + expect(row.payload_display).toEqual({ getonly: 'getonly title' }); + expect(reads).not.toContain('t_open'); + expect(warn.mock.calls.some(([message]) => String(message).includes('"t_open"'))).toBe(true); + }); +}); diff --git a/packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts b/packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts index 46fd3ddd1ce..aa96fa2bce1 100644 --- a/packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts +++ b/packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts @@ -47,7 +47,7 @@ * separately to confirm it goes red on its own defect. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { createMemoryI18n } from '@objectstack/core'; import { installAuditWriters } from './audit-writers.js'; @@ -520,3 +520,81 @@ describe('[#7230] the resolution is batched per target object, and free when unu expect(lastSummary(storeFor)).toBe('Account: Acme Corp → Globex'); }); }); + +// --------------------------------------------------------------------------- +// 4. [#22738] Exposure — a title is read only from a target that serves `get` +// --------------------------------------------------------------------------- + +/** + * One target per shape the spec's exposure decision tells apart (ADR-0049): + * the off switch, a whitelist without `get`, a deny-all whitelist, and a + * whitelist that grants `get`; `crm_account` (no `enable` block) is the open + * control. `lte_deal` tracks a lookup into each, keyed by the target's name. + */ +const exposureTarget = (name: string, enable: Record) => ({ + name, label: name, enable, + fields: { id: f('id', 'text', { primaryKey: true }), name: f('name', 'text') }, +}); +const REFUSED = ['lte_hidden', 'lte_listonly', 'lte_denyall'] as const; +const exposureTargets = [ + exposureTarget('lte_hidden', { apiEnabled: false }), + exposureTarget('lte_listonly', { apiMethods: ['list'] }), + exposureTarget('lte_denyall', { apiMethods: [] }), + exposureTarget('lte_getonly', { apiMethods: ['get'] }), +]; +const lteDeal = { + name: 'lte_deal', label: 'Deal', + fields: { + id: f('id', 'text', { primaryKey: true }), + title: f('title', 'text'), + ...Object.fromEntries([...exposureTargets.map((t) => t.name), 'crm_account'].map((t) => [ + t, f(t, 'lookup', { label: t, reference: t, trackHistory: true }), + ])), + }, +}; + +async function bootExposure() { + const booted = await boot(); + for (const o of [...exposureTargets, lteDeal]) booted.engine.registry.registerObject(o as any, OWNER_PACKAGE); + await booted.engine.insert('crm_account', { id: 'acc_1', name: 'Acme Corp' }); + for (const t of exposureTargets) await booted.engine.insert(t.name, { id: `${t.name}_1`, name: `${t.name} title` }); + await booted.engine.insert('lte_deal', { id: 'd1', title: 'Deal' }); + return booted; +} + +describe('[#22738] a tracked reference into a target whose exposure refuses `get` is named by its stored id', () => { + it('reads and titles only the targets that serve `get`; a refused target is not read', async () => { + const { engine, reads, storeFor } = await bootExposure(); + const before = { ...reads.findOn }; + await engine.update( + 'lte_deal', + Object.fromEntries([...exposureTargets.map((t) => [t.name, `${t.name}_1`]), ['crm_account', 'acc_1']]), + { where: { id: 'd1' } } as any, + ); + + const summary = String(lastSummary(storeFor)); + for (const t of REFUSED) { + expect(summary).toContain(`${t}: ∅ → ${t}_1`); + expect(summary).not.toContain(`${t} title`); + expect((reads.findOn[t] ?? 0) - (before[t] ?? 0), `${t} was read`).toBe(0); + } + expect(summary).toContain('lte_getonly: ∅ → lte_getonly title'); + expect(summary).toContain('crm_account: ∅ → Acme Corp'); + }); + + it('withholds a target whose declaration cannot be read (fail-closed), and says so at warn', async () => { + const { engine, reads, storeFor } = await bootExposure(); + const getSchema = engine.getSchema.bind(engine); + (engine as any).getSchema = (name: string) => { + if (name === 'crm_account') throw new Error('declaration unavailable'); + return getSchema(name); + }; + const warn = vi.spyOn((engine as any).logger, 'warn'); + const before = reads.findOn['crm_account'] ?? 0; + await engine.update('lte_deal', { crm_account: 'acc_1' }, { where: { id: 'd1' } } as any); + + expect(lastSummary(storeFor)).toBe('crm_account: ∅ → acc_1'); + expect((reads.findOn['crm_account'] ?? 0) - before).toBe(0); + expect(warn.mock.calls.some(([message]) => String(message).includes('"crm_account"'))).toBe(true); + }); +}); diff --git a/packages/plugins/plugin-audit/src/audit-writers.ts b/packages/plugins/plugin-audit/src/audit-writers.ts index 7e8ede39cac..e8bcc7ac012 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { HookContext } from '@objectstack/spec/data'; +import type { EnableLike, HookContext } from '@objectstack/spec/data'; import type { IDataEngine } from '@objectstack/spec/contracts'; // [#6656] The read-mask CONTRACT, imported rather than re-typed. `SECRET_MASK` // is the exact string the engine's read path substitutes and @@ -25,7 +25,9 @@ import { SECRET_MASK, collectInternalReadFields, collectMaskedReadFields } from // heuristic here would be a second de-facto contract that disagrees with the // picker, the search companion and the approval inbox the day an author sets // `nameField` — the same argument the SECRET_MASK import above makes. -import { referenceTargetOf, resolveDisplayField } from '@objectstack/spec/data'; +// [#22738] `canServeApiOperation` is the spec's one exposure decision (ADR-0049), +// asked of a referenced object before its title is read into a summary. +import { canServeApiOperation, referenceTargetOf, resolveDisplayField } from '@objectstack/spec/data'; // [#22510] The acting user's object, named by the registry constant rather // than a literal — `sys_activity.actor_name` is resolved from its title. import { SystemObjectName } from '@objectstack/spec/system'; @@ -494,6 +496,36 @@ function referenceTargetForSummary(field: any): string { return typeof target === 'string' ? target.trim() : ''; } +/** [#22738] What `objectName` declares: `getSchema()` on the engine. A throw is no answer. */ +type DeclarationReader = (objectName: string) => { enable?: unknown } | null | undefined; +type WarnSink = { warn?: (message: string) => void }; + +/** + * [#22738] May an activity summary carry a title read from `target`, the object + * a tracked reference points at? Asked of the spec's one exposure decision + * (ADR-0049) for `get`, the read `GET /data/{target}/{id}` performs and the + * operation the data door's `$expand` and the dataset door's labels ask of the + * same target. It takes no caller: the title is read under `api.sudo()`. + * + * A refused target is not read, so the summary names its records by their + * stored ids, the answer an unresolvable reference already gets. A declaration + * that cannot be read withholds too (fail-closed, at `warn`). Only rows written + * from now on change; a written row is a snapshot and is never rewritten. + */ +function servesSummaryTitleTarget(target: string, readDeclaration: DeclarationReader, logger?: WarnSink): boolean { + let enable: EnableLike | null | undefined; + try { + enable = readDeclaration(target)?.enable as EnableLike | null | undefined; + } catch (err) { + logger?.warn?.( + `[audit] resolveLookupTitles: the API exposure declaration of "${target}" could not be read, so the ` + + `summary names its records by their stored ids (fail-closed): ${err instanceof Error ? err.message : String(err)}`, + ); + return false; + } + return canServeApiOperation(enable, 'get'); +} + /** * [#7230] The referenced record ids carried by one lookup-field value. * @@ -1345,7 +1377,9 @@ export function installAuditWriters( * * Best-effort throughout: an unregistered object, an unreadable row or a * failing driver leaves the id unresolved, and `displayFieldValue` renders the - * raw id exactly as it did before this branch existed. + * raw id exactly as it did before this branch existed. [#22738] So does a + * target whose declared exposure does not serve `get` + * ({@link servesSummaryTitleTarget}): it is not read at all. */ const resolveLookupTitles = async ( api: any, @@ -1355,6 +1389,7 @@ export function installAuditWriters( const sys = api.sudo(); const out = new Map>(); for (const [objectName, idSet] of plan) { + if (!servesSummaryTitleTarget(objectName, (name) => (engine as any).getSchema?.(name), (engine as any).logger)) continue; const titleField = titleFieldOf(objectName); if (!titleField) continue; const ids = Array.from(idSet); diff --git a/packages/qa/dogfood/test/lookup-title-exposure.dogfood.test.ts b/packages/qa/dogfood/test/lookup-title-exposure.dogfood.test.ts new file mode 100644 index 00000000000..3d936fe5f0b --- /dev/null +++ b/packages/qa/dogfood/test/lookup-title-exposure.dogfood.test.ts @@ -0,0 +1,220 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#22738] A lookup target's TITLE, served by the approvals inbox and by the +// activity timeline, asks the target's own declared exposure (ADR-0049) through +// the spec's one decision, for `get`, on a real boot: the real +// `SecurityPlugin`, `ObjectQL`, SQL driver, REST and auth layers, automation, +// the record-change trigger, the approvals plugin and `AuditPlugin`. +// +// ## The two positions +// +// - The inbox's `payload_display`, which maps a snapshot's lookup keys to the +// referenced records' titles (`ApprovalService.enrichRows`). +// - The tracked-change summary of an activity row, which names the referenced +// records by title, composed at write time (`resolveLookupTitles`). +// +// A target the decision does not serve for `get` is not read: the inbox carries +// no display value for that key (the snapshot's stored id stands, the answer a +// deleted target already gets) and the summary names the stored id. The +// exposed target is the control. Both are asserted for an administrator and a +// member, because each read runs under a system context and takes no caller. +// +// Armed before believed: the data door answers each target's `get` as the +// decision does (404 for the off switch, 405 for a whitelist without `get`, +// 200 for the control), and both rows exist at rest. +// +// Fixtures are synthetic. ⚠️ No test title states a value. +// `@objectstack/plugin-approvals` resolves to SOURCE here (this project's +// alias); `@objectstack/plugin-audit` resolves through its BUILT output, so a +// verdict on a change to it is a verdict on its last build. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { ApprovalsServicePlugin } from '@objectstack/plugin-approvals'; +import { AuditPlugin } from '@objectstack/plugin-audit'; +import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change'; +import { defineStack, defineFlow } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { assertArmed, armedWhen } from './armed.js'; + +const SYS = { context: { isSystem: true } } as const; +const POSITION = 'lte_reviewer'; + +/** Each lookup: its target, the target's declaration, the data door's `get` answer, a synthetic title. */ +const TARGETS = { + hidden: { object: 'lte_hidden', enable: { apiEnabled: false }, get: 404, title: 'LTEHIDDEN81' }, + listonly: { object: 'lte_listonly', enable: { apiMethods: ['list'] }, get: 405, title: 'LTELISTONLY82' }, + denyall: { object: 'lte_denyall', enable: { apiMethods: [] }, get: 405, title: 'LTEDENYALL83' }, + open: { object: 'lte_open', enable: undefined, get: 200, title: 'LTEOPEN84' }, +} as const; +type Rel = keyof typeof TARGETS; +const RELS = Object.keys(TARGETS) as Rel[]; +const WITHHELD: Rel[] = ['hidden', 'listonly', 'denyall']; + +const object = (name: string, fields: Record, enable?: unknown) => + ObjectSchema.create({ + name, label: name, pluralLabel: name, sharingModel: 'public_read_write', + ...(enable ? { enable } : {}), + fields: { name: Field.text({ label: 'Name' }), ...fields }, + } as never); +const lookups = (trackHistory: boolean) => + Object.fromEntries(RELS.map((r) => [r, Field.lookup(TARGETS[r].object, { label: r, trackHistory })])); + +const flow = defineFlow({ + name: 'lte_flow', label: 'LTE Flow', description: 'Routes each new request to the reviewer position.', + type: 'autolaunched', status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'On Create', config: { objectName: 'lte_request', triggerType: 'record-after-create' } }, + { id: 'gate', type: 'approval', label: 'Gate', config: { approvers: [{ type: 'position', value: POSITION }], behavior: 'first_response' } }, + { id: 'approved', type: 'end', label: 'Approved' }, + { id: 'rejected', type: 'end', label: 'Rejected' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'gate' }, + { id: 'e2', source: 'gate', target: 'approved', label: 'approve' }, + { id: 'e3', source: 'gate', target: 'rejected', label: 'reject' }, + ], +}); + +const fixtureStack = defineStack({ + manifest: { + id: 'com.dogfood.lookup-title-exposure', namespace: 'lte', version: '0.0.0', type: 'app', + name: 'Lookup Title Exposure Fixture', + description: 'Lookups into targets of every exposure shape, on an approval request and on a tracked record.', + }, + requires: ['automation', 'triggers'], + objects: [ + ...RELS.map((r) => object(TARGETS[r].object, {}, TARGETS[r].enable)), + object('lte_request', lookups(false)), + object('lte_tracked', lookups(true)), + ], + flows: [flow], +}); + +const crud = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }; +const read = { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }; +const memberSet = PermissionSetSchema.parse({ + name: 'lte_member', label: 'LTE member', + objects: { + ...Object.fromEntries([...RELS.map((r) => TARGETS[r].object), 'lte_request', 'lte_tracked'].map((o) => [o, crud])), + sys_approval_request: read, + sys_activity: read, + }, +}); + +type Row = Record; +const PERSONAS = ['admin', 'member'] as const; + +describe('[#22738] a lookup title is served only for a target whose declared exposure serves `get`', () => { + let stack: VerifyStack; + const token: Record = {}; + const ids = {} as Record; + let requestRecord = ''; + let trackedRecord = ''; + + beforeAll(async () => { + stack = await bootStack(fixtureStack as unknown as Parameters[0], { + automation: true, + security: new SecurityPlugin({ + defaultPermissionSets: [...securityDefaultPermissionSets, memberSet], + fallbackPermissionSet: memberSet.name, + }), + extraPlugins: [new RecordChangeTriggerPlugin(), new ApprovalsServicePlugin(), new AuditPlugin()], + }); + const ql = (await stack.kernel.getServiceAsync('objectql')) as any; + const userId = async (email: string) => String((await ql.findOne('sys_user', { where: { email }, ...SYS }))?.id ?? ''); + token.admin = await stack.signIn(); + token.member = await stack.signUp('lte-member@verify.test'); + token.submitter = await stack.signUp('lte-submitter@verify.test'); + + // Both personas approve, in the organization the request opens in: the + // slate resolves when the request opens. + const memberId = await userId('lte-member@verify.test'); + const org = (await ql.findOne('sys_member', { where: { user_id: memberId }, ...SYS }))?.organization_id; + expect(org, 'the member belongs to an organization').toBeTruthy(); + await ql.insert('sys_position', { id: 'pos_lte', name: POSITION, label: 'Reviewer', active: true, organization_id: org }, SYS); + for (const id of [await userId('admin@objectos.ai'), memberId]) { + await ql.insert('sys_user_position', { user_id: id, position: POSITION, organization_id: org }, SYS); + } + for (const r of RELS) ids[r] = String((await ql.insert(TARGETS[r].object, { name: TARGETS[r].title }, SYS)).id); + const refs = Object.fromEntries(RELS.map((r) => [r, ids[r]])); + + const created = await stack.apiAs(token.submitter, 'POST', '/data/lte_request', { name: 'Request', ...refs }); + expect(created.status).toBe(201); + const createdBody = (await created.json()) as Row; + requestRecord = String(createdBody.id ?? createdBody.record?.id ?? ''); + + const tracked = await stack.apiAs(token.admin, 'POST', '/data/lte_tracked', { name: 'Tracked' }); + expect(tracked.status).toBe(201); + const trackedBody = (await tracked.json()) as Row; + trackedRecord = String(trackedBody.id ?? trackedBody.record?.id ?? ''); + const moved = await stack.apiAs(token.admin, 'PATCH', `/data/lte_tracked/${trackedRecord}`, refs); + expect(moved.status).toBe(200); + + await assertArmed([ + armedWhen({ + control: "the data door answers each target's `get` as the decision does", + disarmedBy: 'a declaration that did not reach the registry would make every withheld case measure an exposed target', + observe: async () => + Object.fromEntries(await Promise.all(RELS.map(async (r) => + [r, (await stack.apiAs(token.admin, 'GET', `/data/${TARGETS[r].object}/${ids[r]}`)).status] as const))), + armed: (o) => RELS.every((r) => o[r] === TARGETS[r].get), + describe: (o) => JSON.stringify(o), + }), + armedWhen({ + control: 'the flow opened a request for the record, and the tracked update wrote an updated activity row', + disarmedBy: 'a scene with no request or no row would let every case below read nothing', + observe: async () => ({ + request: Boolean(await ql.findOne('sys_approval_request', { where: { object_name: 'lte_request', record_id: requestRecord }, ...SYS })), + updated: Boolean(await ql.findOne('sys_activity', { where: { object_name: 'lte_tracked', record_id: trackedRecord, type: 'updated' }, ...SYS })), + }), + armed: (o) => o.request && o.updated, + describe: (o) => JSON.stringify(o), + }), + ]); + }, 180_000); + + afterAll(async () => { + if (stack) await stack.stop(); + }); + + /** The inbox list row and the inbox item for the fixture request, as one persona. */ + const inbox = async (who: (typeof PERSONAS)[number]): Promise => { + const list = await stack.apiAs(token[who], 'GET', '/approvals/requests?status=pending'); + expect(list.status).toBe(200); + const row = ((await list.json()) as { data: Row[] }).data.find((r) => r.record_id === requestRecord); + expect(row, 'the request is in the inbox').toBeTruthy(); + const item = await stack.apiAs(token[who], 'GET', `/approvals/requests/${row!.id}`); + expect(item.status).toBe(200); + return [row!, (await item.json()) as Row]; + }; + + for (const who of PERSONAS) { + it(`inbox (${who}): a withheld target has no display value, its stored id stands, and the control keeps its title`, async () => { + for (const row of await inbox(who)) { + expect(row.payload_display?.open).toBe(TARGETS.open.title); + for (const r of WITHHELD) { + expect(row.payload_display ?? {}).not.toHaveProperty(r); + expect(row.payload?.[r]).toBe(ids[r]); + expect(JSON.stringify(row)).not.toContain(TARGETS[r].title); + } + } + }); + + it(`activity (${who}): the summary names a withheld target by its stored id, and the control by its title`, async () => { + const filter = encodeURIComponent(JSON.stringify({ object_name: 'lte_tracked', record_id: trackedRecord })); + const res = await stack.apiAs(token[who], 'GET', `/data/sys_activity?$filter=${filter}`); + expect(res.status).toBe(200); + const body = (await res.json()) as Row; + const updated = ((body.records ?? body.data ?? []) as Row[]).find((r) => r.type === 'updated'); + const summary = String(updated?.summary ?? ''); + expect(summary).toContain(TARGETS.open.title); + for (const r of WITHHELD) { + expect(summary).toContain(ids[r]); + expect(summary).not.toContain(TARGETS[r].title); + } + }); + } +});