|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#27414, ADR-0073 D3] The `/automation` definition doors record who saved. |
| 5 | + * |
| 6 | + * `POST /`, `PUT /:name` and `POST /:name/clone` persist through one path, |
| 7 | + * `registerAndSaveFlow`, which called `protocol.saveMetaItem` with no `actor` |
| 8 | + * although the execution context it had just admitted carried the caller's |
| 9 | + * `userId`. So the `sys_metadata` row a signed-in admin created, updated or |
| 10 | + * cloned recorded `null` in `created_by` / `updated_by` and in |
| 11 | + * `sys_metadata_history.recorded_by`. The save now passes the caller's id the |
| 12 | + * way the toggle door does (`activationCaller`), and nothing when the context |
| 13 | + * has no user. |
| 14 | + * |
| 15 | + * ## The rig |
| 16 | + * |
| 17 | + * One REAL store, as in `meta-save-actor.test.ts`: better-sqlite3 `:memory:`, |
| 18 | + * the real `sys_metadata*` objects and a real |
| 19 | + * `ObjectStackProtocolImplementation`, so the claim is about the rows, read |
| 20 | + * back after every save. The automation service is a fake that holds the |
| 21 | + * flows it is given; the door is driven through the real `HttpDispatcher`. |
| 22 | + * |
| 23 | + * The CONTROL is a caller with no user (`isSystem`, the engine's own |
| 24 | + * self-invocation shape): its save records null, as before. |
| 25 | + */ |
| 26 | + |
| 27 | +import { describe, it, expect, afterEach } from 'vitest'; |
| 28 | +import { ObjectQL } from '@objectstack/objectql'; |
| 29 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 30 | +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; |
| 31 | +import { SysMetadata, SysMetadataHistoryObject, SysMetadataAuditObject } from '@objectstack/platform-objects/metadata'; |
| 32 | + |
| 33 | +import { HttpDispatcher } from '../http-dispatcher.js'; |
| 34 | +import type { HttpProtocolContext } from '../http-dispatcher.js'; |
| 35 | + |
| 36 | +/** `registry.registerObject` requires a package id (see the sibling real-stack tests). */ |
| 37 | +const TEST_PACKAGE_ID = 'objectstack-test'; |
| 38 | +const SOURCE = 'customer_source_flow'; |
| 39 | + |
| 40 | +const definitionOf = (name: string, label: string) => ({ |
| 41 | + name, |
| 42 | + label, |
| 43 | + type: 'autolaunched', |
| 44 | + nodes: [ |
| 45 | + { id: 'start', type: 'start', label: 'Start' }, |
| 46 | + { id: 'end', type: 'end', label: 'End' }, |
| 47 | + ], |
| 48 | + edges: [{ id: 'e1', source: 'start', target: 'end' }], |
| 49 | +}); |
| 50 | + |
| 51 | +/** An author holds `manage_metadata`, so every door below admits the write. */ |
| 52 | +const author = (userId: string): HttpProtocolContext => ({ |
| 53 | + request: {}, |
| 54 | + executionContext: { userId, systemPermissions: ['manage_metadata'] }, |
| 55 | +} as HttpProtocolContext); |
| 56 | +/** CONTROL: engine self-invocation, admitted by `isSystem`, with no user to record. */ |
| 57 | +const SYSTEM = (): HttpProtocolContext => ({ request: {}, executionContext: { isSystem: true } } as HttpProtocolContext); |
| 58 | + |
| 59 | +interface Recorded { created_by: unknown; updated_by: unknown; recorded_by: unknown[] } |
| 60 | + |
| 61 | +const liveEngines: ObjectQL[] = []; |
| 62 | +afterEach(async () => { |
| 63 | + while (liveEngines.length) { |
| 64 | + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } |
| 65 | + } |
| 66 | +}); |
| 67 | + |
| 68 | +async function boot() { |
| 69 | + const engine = new ObjectQL(); |
| 70 | + liveEngines.push(engine); |
| 71 | + engine.registerDriver(new SqlDriver({ |
| 72 | + client: 'better-sqlite3', |
| 73 | + connection: { filename: ':memory:' }, |
| 74 | + useNullAsDefault: true, |
| 75 | + }), true); |
| 76 | + await engine.init(); |
| 77 | + engine.registry.registerObject(SysMetadata as any, TEST_PACKAGE_ID); |
| 78 | + engine.registry.registerObject(SysMetadataHistoryObject as any, TEST_PACKAGE_ID); |
| 79 | + engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID); |
| 80 | + await engine.syncSchemas(); |
| 81 | + const protocol = new ObjectStackProtocolImplementation(engine as any); |
| 82 | + |
| 83 | + const flows = new Map<string, unknown>([[SOURCE, definitionOf(SOURCE, 'Source')]]); |
| 84 | + const automation = { |
| 85 | + handlerReady: true, |
| 86 | + registerFlow: (name: string, definition: unknown) => { flows.set(name, definition); return definition; }, |
| 87 | + unregisterFlow: (name: string) => { flows.delete(name); }, |
| 88 | + getFlow: async (name: string) => flows.get(name) ?? null, |
| 89 | + listFlows: async () => [...flows.keys()], |
| 90 | + }; |
| 91 | + const services: Record<string, unknown> = { protocol, automation }; |
| 92 | + const resolve = (name: string): unknown => services[name]; |
| 93 | + const kernel: any = { getService: resolve, getServiceAsync: async (name: string) => resolve(name), context: { getService: resolve } }; |
| 94 | + |
| 95 | + const recorded = async (name: string): Promise<Recorded> => { |
| 96 | + const rows = await engine.find('sys_metadata', { where: { type: 'flow', name } }); |
| 97 | + expect(rows ?? [], `one sys_metadata row for ${name}`).toHaveLength(1); |
| 98 | + const history = await engine.find('sys_metadata_history', { where: { type: 'flow', name } }); |
| 99 | + return { |
| 100 | + created_by: (rows as any[])[0].created_by ?? null, |
| 101 | + updated_by: (rows as any[])[0].updated_by ?? null, |
| 102 | + recorded_by: [...(history ?? [])] |
| 103 | + .sort((a: any, b: any) => Number(a.version) - Number(b.version)) |
| 104 | + .map((h: any) => h.recorded_by ?? null), |
| 105 | + }; |
| 106 | + }; |
| 107 | + return { dispatcher: new HttpDispatcher(kernel), recorded }; |
| 108 | +} |
| 109 | + |
| 110 | +const statusOf = (r: { response?: { status?: number } }) => r.response?.status; |
| 111 | + |
| 112 | +describe('[#27414] the /automation definition doors record the user who saved', () => { |
| 113 | + it('POST / records the creator; PUT /:name by another user records that user as the updater', async () => { |
| 114 | + const h = await boot(); |
| 115 | + expect(statusOf(await h.dispatcher.handleAutomation('', 'POST', definitionOf('new_flow', 'v1'), author('u_author'), undefined))).toBe(200); |
| 116 | + expect(await h.recorded('new_flow')).toEqual({ created_by: 'u_author', updated_by: 'u_author', recorded_by: ['u_author'] }); |
| 117 | + |
| 118 | + const definition = definitionOf('new_flow', 'v2'); |
| 119 | + expect(statusOf(await h.dispatcher.handleAutomation('/new_flow', 'PUT', { definition }, author('u_editor'), undefined))).toBe(200); |
| 120 | + expect(await h.recorded('new_flow')).toEqual({ created_by: 'u_author', updated_by: 'u_editor', recorded_by: ['u_author', 'u_editor'] }); |
| 121 | + }, 60_000); |
| 122 | + |
| 123 | + it('POST /:name/clone records the admin who cloned on the copy', async () => { |
| 124 | + const h = await boot(); |
| 125 | + const result = await h.dispatcher.handleAutomation( |
| 126 | + `/${SOURCE}/clone`, 'POST', { name: 'my_copy', label: 'My Copy' }, author('u_admin'), undefined, |
| 127 | + ); |
| 128 | + expect(statusOf(result)).toBe(200); |
| 129 | + expect(await h.recorded('my_copy')).toEqual({ created_by: 'u_admin', updated_by: 'u_admin', recorded_by: ['u_admin'] }); |
| 130 | + }, 60_000); |
| 131 | + |
| 132 | + it('CONTROL: a caller with no user records null on all three doors, never a label', async () => { |
| 133 | + const h = await boot(); |
| 134 | + expect(statusOf(await h.dispatcher.handleAutomation('', 'POST', definitionOf('sys_flow', 'v1'), SYSTEM(), undefined))).toBe(200); |
| 135 | + expect(statusOf(await h.dispatcher.handleAutomation('/sys_flow', 'PUT', { definition: definitionOf('sys_flow', 'v2') }, SYSTEM(), undefined))).toBe(200); |
| 136 | + expect(await h.recorded('sys_flow')).toEqual({ created_by: null, updated_by: null, recorded_by: [null, null] }); |
| 137 | + |
| 138 | + expect(statusOf(await h.dispatcher.handleAutomation(`/${SOURCE}/clone`, 'POST', { name: 'sys_copy', label: 'System Copy' }, SYSTEM(), undefined))).toBe(200); |
| 139 | + expect(await h.recorded('sys_copy')).toEqual({ created_by: null, updated_by: null, recorded_by: [null] }); |
| 140 | + }, 60_000); |
| 141 | +}); |
0 commit comments