|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * ADR-0146 D1 — `POST /packages/:id/publish-drafts` carries the author's change |
| 5 | + * note into every item history row it writes, and records WHO published as the |
| 6 | + * identity the request was authorized as. |
| 7 | + * |
| 8 | + * Before this, the door forwarded `body.actor` and never `message`; the |
| 9 | + * protocol wrote the note only into the package commit and hard-coded every |
| 10 | + * history row's `change_note` as the sentence naming the package. So Studio's |
| 11 | + * item history could not say why an item changed, and any caller holding |
| 12 | + * `manage_metadata` could record any name as the publisher. |
| 13 | + * |
| 14 | + * What is real: the door (`HttpDispatcher.handlePackages`), the protocol's |
| 15 | + * `publishPackageDrafts` and its per-item promote, ObjectQL, and a SqlDriver |
| 16 | + * over on-disk better-sqlite3 holding the four metadata tables. The rows read |
| 17 | + * back are what landed in SQLite, not what a double was told. |
| 18 | + */ |
| 19 | + |
| 20 | +import { afterEach, describe, expect, it } from 'vitest'; |
| 21 | +import { mkdtempSync, rmSync } from 'node:fs'; |
| 22 | +import { tmpdir } from 'node:os'; |
| 23 | +import { join } from 'node:path'; |
| 24 | +import { ObjectQL } from '@objectstack/objectql'; |
| 25 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 26 | +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; |
| 27 | +import { |
| 28 | + SysMetadataObject, |
| 29 | + SysMetadataHistoryObject, |
| 30 | + SysMetadataAuditObject, |
| 31 | + SysMetadataCommitObject, |
| 32 | +} from '@objectstack/metadata-core'; |
| 33 | +import { captureExpectedReadRefusals, type ExpectedReadRefusalCapture } from '../expected-read-refusal-noise.js'; |
| 34 | +import { HttpDispatcher } from '../http-dispatcher.js'; |
| 35 | + |
| 36 | +const PKG = 'com.notes.app'; |
| 37 | +const PLATFORM_PKG = '@objectstack/platform-objects'; |
| 38 | +/** The object the published views bind to; the build probe reads it (see the list-commits suite). */ |
| 39 | +const UNBOUND_PROBE_OBJECT = 'anything'; |
| 40 | +const DEFAULT_NOTE = `publish app package '${PKG}'`; |
| 41 | + |
| 42 | +let cleanup: Array<() => void> = []; |
| 43 | +let noise: ExpectedReadRefusalCapture | null = null; |
| 44 | +afterEach(() => { |
| 45 | + for (const c of cleanup) c(); |
| 46 | + cleanup = []; |
| 47 | + expect(noise?.tablesSeen() ?? ['no capture was installed']).toEqual([]); |
| 48 | + noise = null; |
| 49 | +}); |
| 50 | + |
| 51 | +async function boot() { |
| 52 | + const dir = mkdtempSync(join(tmpdir(), 'os-publish-note-')); |
| 53 | + cleanup.push(() => rmSync(dir, { recursive: true, force: true })); |
| 54 | + const driver = new SqlDriver({ |
| 55 | + client: 'better-sqlite3', |
| 56 | + connection: { filename: join(dir, 'data.sqlite') }, |
| 57 | + useNullAsDefault: true, |
| 58 | + }); |
| 59 | + const objects = [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject] as any[]; |
| 60 | + noise = captureExpectedReadRefusals([UNBOUND_PROBE_OBJECT]); |
| 61 | + noise.captureDriver(driver); |
| 62 | + await driver.initObjects(objects); |
| 63 | + |
| 64 | + const engine = new ObjectQL(); |
| 65 | + noise.captureEngine(engine); |
| 66 | + engine.registerDriver(driver as any, true); |
| 67 | + await engine.init(); |
| 68 | + for (const o of objects) engine.registry.registerObject(o, PLATFORM_PKG); |
| 69 | + engine.registry.installPackage({ id: PKG, name: PKG, version: '1.0.0' } as any, 'authored'); |
| 70 | + cleanup.push(() => { void engine.destroy(); }); |
| 71 | + |
| 72 | + const protocol = new ObjectStackProtocolImplementation(engine as any, undefined, undefined, 'package-author'); |
| 73 | + const kernel: any = { |
| 74 | + getService: (name: string) => { |
| 75 | + if (name === 'protocol') return Promise.resolve(protocol); |
| 76 | + if (name === 'objectql') return Promise.resolve(engine); |
| 77 | + return null; |
| 78 | + }, |
| 79 | + context: { getService: () => null, trigger: () => {} }, |
| 80 | + }; |
| 81 | + return { engine, protocol, dispatcher: new HttpDispatcher(kernel) }; |
| 82 | +} |
| 83 | + |
| 84 | +async function draftViews(protocol: any, names: string[]) { |
| 85 | + for (const name of names) { |
| 86 | + await protocol.saveMetaItem({ |
| 87 | + type: 'view', |
| 88 | + name, |
| 89 | + item: { |
| 90 | + name, |
| 91 | + label: name, |
| 92 | + type: 'grid', |
| 93 | + object: UNBOUND_PROBE_OBJECT, |
| 94 | + viewKind: 'list', |
| 95 | + data: { provider: 'object', object: UNBOUND_PROBE_OBJECT }, |
| 96 | + columns: ['id'], |
| 97 | + }, |
| 98 | + packageId: PKG, |
| 99 | + mode: 'draft', |
| 100 | + }); |
| 101 | + } |
| 102 | +} |
| 103 | + |
| 104 | +const caller = (userId: string) => ({ |
| 105 | + request: {}, |
| 106 | + executionContext: { userId, systemPermissions: ['manage_metadata', 'studio.access'] }, |
| 107 | +}) as any; |
| 108 | + |
| 109 | +/** Publish through the door and return the history rows and commit rows this publish wrote. */ |
| 110 | +async function publish(env: Awaited<ReturnType<typeof boot>>, body: Record<string, unknown>, userId = 'u_alice') { |
| 111 | + const seqBefore = Math.max( |
| 112 | + 0, |
| 113 | + ...((await env.engine.find('sys_metadata_history', { where: {} })) as any[]).map((r) => Number(r.event_seq)), |
| 114 | + ); |
| 115 | + const result = await env.dispatcher.handlePackages(`/${PKG}/publish-drafts`, 'POST', body, {}, caller(userId)); |
| 116 | + expect(result.response?.status).toBe(200); |
| 117 | + const data: any = (result.response as any)?.body?.data; |
| 118 | + expect(data?.outcome).toBe('published'); |
| 119 | + const history = ((await env.engine.find('sys_metadata_history', { where: {} })) as any[]) |
| 120 | + .filter((r) => Number(r.event_seq) > seqBefore); |
| 121 | + const commits = (await env.engine.find('sys_metadata_commit', { where: { package_id: PKG } })) as any[]; |
| 122 | + return { data, history, commit: commits.find((c) => c.id === data.commitId) }; |
| 123 | +} |
| 124 | + |
| 125 | +describe('publish-drafts records the change note and the session user (ADR-0146 D1)', () => { |
| 126 | + it('a note reaches every history row the publish writes, and the package commit', async () => { |
| 127 | + const env = await boot(); |
| 128 | + await draftViews(env.protocol, ['notes_open', 'notes_closed']); |
| 129 | + |
| 130 | + const { data, history, commit } = await publish(env, { message: 'Split notes into open and closed lists' }); |
| 131 | + |
| 132 | + expect(data.publishedCount).toBe(2); |
| 133 | + expect(history.map((r) => r.name).sort()).toEqual(['notes_closed', 'notes_open']); |
| 134 | + for (const row of history) expect(row.change_note).toBe('Split notes into open and closed lists'); |
| 135 | + expect(commit?.message).toBe('Split notes into open and closed lists'); |
| 136 | + // And read back through the history door's own mapping, as the panel reads it. |
| 137 | + const read: any = await env.protocol.historyMetaItem({ type: 'view', name: 'notes_open' }); |
| 138 | + const latest = [...read.events].sort((a: any, b: any) => b.seq - a.seq)[0]; |
| 139 | + expect(latest).toMatchObject({ op: 'publish', message: 'Split notes into open and closed lists', actor: 'u_alice' }); |
| 140 | + }); |
| 141 | + |
| 142 | + it('no note keeps the sentence naming the package on every row', async () => { |
| 143 | + const env = await boot(); |
| 144 | + await draftViews(env.protocol, ['notes_open', 'notes_closed']); |
| 145 | + |
| 146 | + const { history } = await publish(env, {}); |
| 147 | + |
| 148 | + expect(history).toHaveLength(2); |
| 149 | + for (const row of history) expect(row.change_note).toBe(DEFAULT_NOTE); |
| 150 | + }); |
| 151 | + |
| 152 | + it('a body `actor` is ignored: the authorized user is recorded on every row and on the commit', async () => { |
| 153 | + const env = await boot(); |
| 154 | + await draftViews(env.protocol, ['notes_open', 'notes_closed']); |
| 155 | + |
| 156 | + const { history, commit } = await publish(env, { actor: 'u_mallory', message: 'n' }, 'u_alice'); |
| 157 | + |
| 158 | + expect(history).toHaveLength(2); |
| 159 | + for (const row of history) expect(row.recorded_by).toBe('u_alice'); |
| 160 | + expect(commit?.actor).toBe('u_alice'); |
| 161 | + }); |
| 162 | + |
| 163 | + it('a caller with no user id records no actor, never the body’s', async () => { |
| 164 | + const env = await boot(); |
| 165 | + await draftViews(env.protocol, ['notes_open']); |
| 166 | + const system = { request: {}, executionContext: { isSystem: true } } as any; |
| 167 | + |
| 168 | + const result = await env.dispatcher.handlePackages( |
| 169 | + `/${PKG}/publish-drafts`, 'POST', { actor: 'u_mallory' }, {}, system, |
| 170 | + ); |
| 171 | + |
| 172 | + expect(result.response?.status).toBe(200); |
| 173 | + const rows = (await env.engine.find('sys_metadata_history', { where: { name: 'notes_open' } })) as any[]; |
| 174 | + const published = rows.filter((r) => r.operation_type === 'publish'); |
| 175 | + expect(published).toHaveLength(1); |
| 176 | + expect(published[0].recorded_by ?? null).toBeNull(); |
| 177 | + }); |
| 178 | +}); |
0 commit comments