|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#26723] An action the metadata save door stores is stored as its |
| 5 | + * `ActionSchema` parse, so a declared `requiresFeature` / |
| 6 | + * `requiresMembershipReach` is lowered into `visible` — on an object's own |
| 7 | + * `actions` and on a standalone `action` item, in draft and in active saves. |
| 8 | + * |
| 9 | + * The door already judged the body by that parse (`ObjectSchema`'s `actions` |
| 10 | + * is `ActionSchema[]`), then stored the request body as written: the gate keys |
| 11 | + * rode along raw and no `visible` was stored, so the console — which reads |
| 12 | + * `visible` alone — offered the button to every caller. The code-package |
| 13 | + * registration door has stored the parse since #25981; this is the save-door |
| 14 | + * sibling of that fix. |
| 15 | + */ |
| 16 | +import { describe, expect, it } from 'vitest'; |
| 17 | +import { ObjectSchema } from '@objectstack/spec/data'; |
| 18 | +import { ActionSchema } from '@objectstack/spec/ui'; |
| 19 | +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; |
| 20 | +import { ObjectStackProtocolImplementation } from './protocol.js'; |
| 21 | + |
| 22 | +/** The engine surface the repository write path touches (as the external-binding harness). */ |
| 23 | +function makeProtocol() { |
| 24 | + const rows: Record<string, unknown>[] = []; |
| 25 | + let nextId = 0; |
| 26 | + const engine: any = { |
| 27 | + async findOne(object: string, query?: EngineFindOneQueryInput) { |
| 28 | + assertEngineFindOnePredicate(object, query); return null; }, |
| 29 | + async find() { return []; }, |
| 30 | + async insert(table: string, data: Record<string, unknown>) { |
| 31 | + nextId += 1; |
| 32 | + if (table === 'sys_metadata') rows.push(data); |
| 33 | + return { id: `r_${nextId}` }; |
| 34 | + }, |
| 35 | + async update(_t: string, data: Record<string, unknown>, opts?: Record<string, unknown>) { |
| 36 | + assertEngineUpdateDispatch(data, opts); |
| 37 | + return { id: null }; |
| 38 | + }, |
| 39 | + async delete(_t: string, opts?: Record<string, unknown>) { |
| 40 | + assertEngineDeleteDispatch(opts); |
| 41 | + return { deleted: 0 }; |
| 42 | + }, |
| 43 | + registry: { registerItem: () => {}, registerObject: () => {}, getItem: () => undefined }, |
| 44 | + }; |
| 45 | + const protocol: any = new ObjectStackProtocolImplementation(engine, () => new Map()); |
| 46 | + return { protocol, rows }; |
| 47 | +} |
| 48 | + |
| 49 | +/** The body of the one `sys_metadata` row a save wrote, as stored. */ |
| 50 | +function storedBody(rows: Record<string, unknown>[], type: string, name: string): any { |
| 51 | + const hits = rows.filter((r) => r.type === type && r.name === name); |
| 52 | + expect(hits).toHaveLength(1); |
| 53 | + const metadata = hits[0].metadata; |
| 54 | + return typeof metadata === 'string' ? JSON.parse(metadata) : metadata; |
| 55 | +} |
| 56 | + |
| 57 | +const SSO_ONLY = { |
| 58 | + name: 'sso_only', label: 'SSO only', type: 'url' as const, target: 'https://example.com/sso', |
| 59 | + locations: ['record_header' as const], requiresFeature: 'ssoEnforced' as const, |
| 60 | +}; |
| 61 | +const OWNER_ONLY = { |
| 62 | + name: 'owner_only', label: 'Owner only', type: 'url' as const, target: 'https://example.com/owner', |
| 63 | + locations: ['record_header' as const], visible: "record.name != ''", |
| 64 | + requiresMembershipReach: 'delete_organization' as const, |
| 65 | +}; |
| 66 | +const PLAIN = { |
| 67 | + name: 'plain', label: 'Plain', type: 'url' as const, target: 'https://example.com/plain', |
| 68 | + locations: ['record_header' as const], |
| 69 | +}; |
| 70 | + |
| 71 | +const order = (actions: unknown[]) => ({ |
| 72 | + name: 'gap_order', |
| 73 | + label: 'Order', |
| 74 | + sharingModel: 'private', |
| 75 | + fields: { name: { name: 'name', label: 'Name', type: 'text' } }, |
| 76 | + actions, |
| 77 | +}); |
| 78 | + |
| 79 | +/** What the spec's own parse makes of the same declaration. */ |
| 80 | +const parsedAction = (action: unknown) => ActionSchema.parse(action) as any; |
| 81 | + |
| 82 | +describe('#26723 the save door stores an action as its ActionSchema parse', () => { |
| 83 | + it.each(['draft', 'publish'] as const)( |
| 84 | + 'an object saved in %s mode stores its gated actions lowered into `visible`', |
| 85 | + async (mode) => { |
| 86 | + const { protocol, rows } = makeProtocol(); |
| 87 | + const result = await protocol.saveMetaItem({ |
| 88 | + type: 'object', name: 'gap_order', mode, |
| 89 | + item: order([structuredClone(SSO_ONLY), structuredClone(OWNER_ONLY)]), |
| 90 | + }); |
| 91 | + expect(result.success).toBe(true); |
| 92 | + |
| 93 | + const stored = storedBody(rows, 'object', 'gap_order'); |
| 94 | + const [sso, owner] = stored.actions; |
| 95 | + expect(sso).not.toHaveProperty('requiresFeature'); |
| 96 | + // `ssoEnforced` is an opt-in flag, so its gate reads `== true`. |
| 97 | + expect(sso.visible).toEqual({ dialect: 'cel', source: 'features.ssoEnforced == true' }); |
| 98 | + expect(owner).not.toHaveProperty('requiresMembershipReach'); |
| 99 | + expect(owner.visible).toEqual(parsedAction(OWNER_ONLY).visible); |
| 100 | + // The same actions `ObjectSchema.create` gives the same declaration. |
| 101 | + const authored: any = ObjectSchema.create(order([SSO_ONLY, OWNER_ONLY]) as any); |
| 102 | + expect(stored.actions).toEqual(authored.actions); |
| 103 | + }, |
| 104 | + ); |
| 105 | + |
| 106 | + it('control: an action without the gate keys is stored as its parse, and the rest of the body as written', async () => { |
| 107 | + const { protocol, rows } = makeProtocol(); |
| 108 | + const item = order([structuredClone(PLAIN)]); |
| 109 | + await protocol.saveMetaItem({ type: 'object', name: 'gap_order', item }); |
| 110 | + |
| 111 | + const stored = storedBody(rows, 'object', 'gap_order'); |
| 112 | + expect(stored.actions).toEqual([parsedAction(PLAIN)]); |
| 113 | + expect(stored.actions[0]).not.toHaveProperty('visible'); |
| 114 | + expect(stored.fields).toEqual(item.fields); |
| 115 | + }); |
| 116 | + |
| 117 | + it('re-saving a stored body is a no-op: the parse is idempotent over its own output', async () => { |
| 118 | + const first = makeProtocol(); |
| 119 | + await first.protocol.saveMetaItem({ type: 'object', name: 'gap_order', item: order([structuredClone(SSO_ONLY)]) }); |
| 120 | + const once = storedBody(first.rows, 'object', 'gap_order'); |
| 121 | + |
| 122 | + const second = makeProtocol(); |
| 123 | + await second.protocol.saveMetaItem({ type: 'object', name: 'gap_order', item: structuredClone(once) }); |
| 124 | + expect(storedBody(second.rows, 'object', 'gap_order').actions).toEqual(once.actions); |
| 125 | + }); |
| 126 | + |
| 127 | + it('an object with no `actions` key is stored without one', async () => { |
| 128 | + const { protocol, rows } = makeProtocol(); |
| 129 | + const { actions: _none, ...bare } = order([]); |
| 130 | + await protocol.saveMetaItem({ type: 'object', name: 'gap_order', item: bare }); |
| 131 | + expect(storedBody(rows, 'object', 'gap_order')).not.toHaveProperty('actions'); |
| 132 | + }); |
| 133 | + |
| 134 | + it.each(['draft', 'publish'] as const)( |
| 135 | + 'a standalone action saved in %s mode is stored lowered', |
| 136 | + async (mode) => { |
| 137 | + const { protocol, rows } = makeProtocol(); |
| 138 | + const result = await protocol.saveMetaItem({ |
| 139 | + type: 'action', name: 'sso_only', mode, |
| 140 | + item: { ...structuredClone(SSO_ONLY), objectName: 'gap_order' }, |
| 141 | + }); |
| 142 | + expect(result.success).toBe(true); |
| 143 | + |
| 144 | + const stored = storedBody(rows, 'action', 'sso_only'); |
| 145 | + expect(stored).not.toHaveProperty('requiresFeature'); |
| 146 | + expect(stored.visible).toEqual({ dialect: 'cel', source: 'features.ssoEnforced == true' }); |
| 147 | + expect(stored).toEqual(parsedAction({ ...SSO_ONLY, objectName: 'gap_order' })); |
| 148 | + }, |
| 149 | + ); |
| 150 | +}); |
0 commit comments