|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [ADR-0029 D9.9, ADR-0086 D3, #28776] The adopt-orphans door |
| 5 | + * (`reassignOrphanedMetadata`, REST `POST /api/v1/packages/:id/adopt-orphans`) |
| 6 | + * asks the save door's two owner judges, in the save door's order (before the |
| 7 | + * `__c` judge #28735 added), against the TARGET package, before the first |
| 8 | + * storage update, and refuses the adoption whole with each judge's envelope: |
| 9 | + * |
| 10 | + * - an active orphan `object` whose name ANOTHER package owns in the registry: |
| 11 | + * `OBJECT_OVERLAY_PACKAGE_MISMATCH` / 422 (D9.9); |
| 12 | + * - an active orphan `permission` set whose body `packageId` disagrees with the |
| 13 | + * target: `VALIDATION_ERROR` / 400, `issues[0].path = 'packageId'` (D3). |
| 14 | + * |
| 15 | + * The D9.9 arm skips the `'sys_metadata'` sentinel owner: an orphan object is |
| 16 | + * the sentinel's own registration, the owner the adoption hands over, so the |
| 17 | + * judge asked unchanged would refuse EVERY object adoption (measured on a real |
| 18 | + * CRM boot for #28776: `classifyObjectContribution` answers `mismatch` with the |
| 19 | + * sentinel for a package-less object saved through the real door). The plain |
| 20 | + * orphan below pins that. |
| 21 | + * |
| 22 | + * The namespace-prefix judge is not asked at this door (#28776's ruling), so |
| 23 | + * nothing here pins it either way. |
| 24 | + * |
| 25 | + * The engine double stores rows and answers the registry's owner lookups from a |
| 26 | + * table the test sets, the way `SchemaRegistry.getObjectOwner` answers them; the |
| 27 | + * write-through to the registry after a rebind is spied out, since what is |
| 28 | + * pinned is which rows the adoption moves. |
| 29 | + */ |
| 30 | + |
| 31 | +import { afterEach, describe, expect, it, vi } from 'vitest'; |
| 32 | +// [#5619] The producer's OWN write-verb dispatch decision, from |
| 33 | +// `@objectstack/metadata-core` and NOT `@objectstack/objectql` (objectql |
| 34 | +// depends on this package, so that import would close a cycle). |
| 35 | +import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; |
| 36 | +import { ObjectStackProtocolImplementation } from './protocol.js'; |
| 37 | + |
| 38 | +const TARGET = 'app.probe-target'; |
| 39 | +const OTHER = 'app.probe-other'; |
| 40 | + |
| 41 | +interface Row { |
| 42 | + id: string; |
| 43 | + type: string; |
| 44 | + name: string; |
| 45 | + package_id: string | null; |
| 46 | + organization_id: string | null; |
| 47 | + state: string; |
| 48 | + metadata: string; |
| 49 | +} |
| 50 | + |
| 51 | +/** Rows stored package-less, and the registry's owner of each object name. */ |
| 52 | +function makeProtocol(rows: Array<Omit<Row, 'id' | 'package_id' | 'organization_id'>>, owners: Record<string, string>) { |
| 53 | + const store = new Map<string, Row>(rows.map((r, i) => [`r_${i + 1}`, { |
| 54 | + ...r, id: `r_${i + 1}`, package_id: null, organization_id: null, |
| 55 | + }])); |
| 56 | + const engine: any = { |
| 57 | + async find(table: string, opts?: { where?: Record<string, unknown>; limit?: number }) { |
| 58 | + if (table !== 'sys_metadata') return []; |
| 59 | + // The one shape the adoption reads: every environment row, no bound. |
| 60 | + if (opts?.limit !== undefined || Object.keys(opts?.where ?? {}).length !== 0) { |
| 61 | + throw new Error(`fake engine: unsupported read ${JSON.stringify(opts)}`); |
| 62 | + } |
| 63 | + return [...store.values()].map((r) => ({ ...r })); |
| 64 | + }, |
| 65 | + async findOne(table: string, opts: { where: Record<string, unknown> }) { |
| 66 | + assertEngineFindOnePredicate(table, opts); |
| 67 | + return null; |
| 68 | + }, |
| 69 | + async update(_t: string, data: Record<string, unknown>, opts: { where: Record<string, unknown> }) { |
| 70 | + assertEngineUpdateDispatch(data, opts); |
| 71 | + const row = store.get(String(opts.where.id)); |
| 72 | + if (!row) return { id: null }; |
| 73 | + Object.assign(row, data); |
| 74 | + return { id: row.id }; |
| 75 | + }, |
| 76 | + registry: { |
| 77 | + getObjectOwner: (name: string) => (owners[name] ? { packageId: owners[name] } : undefined), |
| 78 | + getArtifactItem: () => undefined, |
| 79 | + getPackage: () => undefined, |
| 80 | + registerObject: () => {}, |
| 81 | + unregisterObject: () => true, |
| 82 | + }, |
| 83 | + }; |
| 84 | + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), undefined) as any; |
| 85 | + const update = vi.spyOn(engine, 'update'); |
| 86 | + const writeThrough = vi.spyOn(protocol, 'applyRegistryWriteThrough').mockResolvedValue(undefined); |
| 87 | + const bindings = () => Object.fromEntries([...store.values()].map((r) => [`${r.type}/${r.name}`, r.package_id])); |
| 88 | + return { protocol, update, writeThrough, bindings }; |
| 89 | +} |
| 90 | + |
| 91 | +const object = (name: string, ...fields: string[]) => ({ |
| 92 | + type: 'object', name, state: 'active', |
| 93 | + metadata: JSON.stringify({ |
| 94 | + name, label: name, sharingModel: 'private', |
| 95 | + fields: Object.fromEntries(['title', ...fields].map((f) => [f, { type: 'text', label: f }])), |
| 96 | + }), |
| 97 | +}); |
| 98 | +const permissionSet = (name: string, packageId?: string) => ({ |
| 99 | + type: 'permission', name, state: 'active', |
| 100 | + metadata: JSON.stringify({ name, label: name, objects: {}, ...(packageId !== undefined ? { packageId } : {}) }), |
| 101 | +}); |
| 102 | + |
| 103 | +async function attempt<T>(run: () => Promise<T>): Promise<{ result: T | null; error: any }> { |
| 104 | + try { |
| 105 | + return { result: await run(), error: null }; |
| 106 | + } catch (error) { |
| 107 | + return { result: null, error }; |
| 108 | + } |
| 109 | +} |
| 110 | + |
| 111 | +afterEach(() => vi.restoreAllMocks()); |
| 112 | + |
| 113 | +describe('[ADR-0029 D9.9, #28776] the adopt door refuses an orphan object whose name another package owns', () => { |
| 114 | + it('refused whole with OBJECT_OVERLAY_PACKAGE_MISMATCH / 422 naming the owner; no row moves, nothing is written through', async () => { |
| 115 | + const h = makeProtocol([object('probe_clean'), object('probe_taken')], { probe_clean: 'sys_metadata', probe_taken: OTHER }); |
| 116 | + |
| 117 | + const { error } = await attempt(() => h.protocol.reassignOrphanedMetadata({ targetPackageId: TARGET })); |
| 118 | + |
| 119 | + expect(error).toBeInstanceOf(Error); |
| 120 | + expect({ code: error.code, status: error.status }).toEqual({ code: 'OBJECT_OVERLAY_PACKAGE_MISMATCH', status: 422 }); |
| 121 | + expect(error.message).toContain( |
| 122 | + `Cannot layer object 'probe_taken': the overlay is bound to package '${TARGET}', but the object is owned by package '${OTHER}'.`, |
| 123 | + ); |
| 124 | + expect(h.update).not.toHaveBeenCalled(); |
| 125 | + expect(h.writeThrough).not.toHaveBeenCalled(); |
| 126 | + expect(h.bindings()).toEqual({ 'object/probe_clean': null, 'object/probe_taken': null }); |
| 127 | + }); |
| 128 | + |
| 129 | + it('is asked before the `__c` judge, in the save door\'s order: a row both judges refuse answers the owner refusal', async () => { |
| 130 | + const h = makeProtocol([object('probe_taken', 'x__c')], { probe_taken: OTHER }); |
| 131 | + |
| 132 | + const { error } = await attempt(() => h.protocol.reassignOrphanedMetadata({ targetPackageId: TARGET })); |
| 133 | + |
| 134 | + expect({ code: error?.code, status: error?.status }).toEqual({ code: 'OBJECT_OVERLAY_PACKAGE_MISMATCH', status: 422 }); |
| 135 | + expect(h.bindings()).toEqual({ 'object/probe_taken': null }); |
| 136 | + }); |
| 137 | + |
| 138 | + it('CONTROL (the sentinel pin): a plain orphan the registry owns under the `sys_metadata` sentinel is admitted and moves; so does one nobody owns', async () => { |
| 139 | + const h = makeProtocol([object('probe_plain'), object('probe_unowned')], { probe_plain: 'sys_metadata' }); |
| 140 | + |
| 141 | + const res = await h.protocol.reassignOrphanedMetadata({ targetPackageId: TARGET }); |
| 142 | + |
| 143 | + expect(res).toMatchObject({ success: true, reassignedCount: 2, targetPackageId: TARGET }); |
| 144 | + expect(h.bindings()).toEqual({ 'object/probe_plain': TARGET, 'object/probe_unowned': TARGET }); |
| 145 | + }); |
| 146 | + |
| 147 | + it('CONTROL: an orphan object the target itself owns is admitted', async () => { |
| 148 | + const h = makeProtocol([object('probe_mine')], { probe_mine: TARGET }); |
| 149 | + |
| 150 | + const res = await h.protocol.reassignOrphanedMetadata({ targetPackageId: TARGET }); |
| 151 | + |
| 152 | + expect(res.reassignedCount).toBe(1); |
| 153 | + expect(h.bindings()).toEqual({ 'object/probe_mine': TARGET }); |
| 154 | + }); |
| 155 | +}); |
| 156 | + |
| 157 | +describe('[ADR-0086 D3, #28776] the adopt door refuses an orphan permission set whose body names another owner', () => { |
| 158 | + it('refused whole with VALIDATION_ERROR / 400, `issues` at `packageId`; no row moves', async () => { |
| 159 | + const h = makeProtocol([object('probe_clean'), permissionSet('probe_set', OTHER)], { probe_clean: 'sys_metadata' }); |
| 160 | + |
| 161 | + const { error } = await attempt(() => h.protocol.reassignOrphanedMetadata({ targetPackageId: TARGET })); |
| 162 | + |
| 163 | + expect(error).toBeInstanceOf(Error); |
| 164 | + expect({ code: error.code, status: error.status }).toEqual({ code: 'VALIDATION_ERROR', status: 400 }); |
| 165 | + expect(error.issues).toEqual([expect.objectContaining({ path: 'packageId', code: 'custom' })]); |
| 166 | + expect(error.message).toContain(`Drop \`packageId\` from permission set 'probe_set'`); |
| 167 | + expect(h.update).not.toHaveBeenCalled(); |
| 168 | + expect(h.bindings()).toEqual({ 'object/probe_clean': null, 'permission/probe_set': null }); |
| 169 | + }); |
| 170 | + |
| 171 | + it('CONTROL: a set whose body names the target, and one that names no owner, are admitted and move', async () => { |
| 172 | + const h = makeProtocol([permissionSet('probe_named', TARGET), permissionSet('probe_bare')], {}); |
| 173 | + |
| 174 | + const res = await h.protocol.reassignOrphanedMetadata({ targetPackageId: TARGET }); |
| 175 | + |
| 176 | + expect(res.reassignedCount).toBe(2); |
| 177 | + expect(h.bindings()).toEqual({ 'permission/probe_named': TARGET, 'permission/probe_bare': TARGET }); |
| 178 | + }); |
| 179 | +}); |
0 commit comments