|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#24041] `duplicatePackage` is refused, with nothing copied, when the install |
| 5 | + * of the copy's package record is refused — and the refusal it answers is the |
| 6 | + * install's own, unchanged. |
| 7 | + * |
| 8 | + * --------------------------------------------------------------------------- |
| 9 | + * The gap these pins close |
| 10 | + * --------------------------------------------------------------------------- |
| 11 | + * The duplicate writes the copy's package record through `installPackage` |
| 12 | + * before its copy loop, inside a `catch {}` that kept nothing. A refused |
| 13 | + * install leaves no registry record and no `sys_packages` row, so the copy's |
| 14 | + * items were live, bound to an id no package record holds — after a namespace |
| 15 | + * conflict, inside a namespace another package owns — and the caller read |
| 16 | + * `success: true`, with no line logged at any level. The door now keeps the |
| 17 | + * rule its sibling `importUnmanagedPackage` states: "There is no half-copied |
| 18 | + * app." (ruling B, claim amendment 6227008947). |
| 19 | + * |
| 20 | + * --------------------------------------------------------------------------- |
| 21 | + * The two refusals, and why each is built the way it is |
| 22 | + * --------------------------------------------------------------------------- |
| 23 | + * - The REGISTRY'S NAMESPACE GATE (ADR-0048 Phase 1). |
| 24 | + * `SchemaRegistry.installPackage` refuses a namespace another installed |
| 25 | + * package owns with `NamespaceConflictError` (`NAMESPACE_CONFLICT`, 422). That |
| 26 | + * judge lives in `@objectstack/objectql`, which depends on this package, so it |
| 27 | + * cannot be imported here: the registry below refuses exactly when another |
| 28 | + * package owns the namespace and throws that error's envelope (`code`, |
| 29 | + * `status`, its sentence) at the registry's own boundary, and the protocol's |
| 30 | + * real `installPackage` is the code that meets it. |
| 31 | + * - A FAILED `sys_packages` WRITE. No stub on the install path at all: the |
| 32 | + * `package` service reports a driver fault the way the real one does |
| 33 | + * (`{ success: false, … }`), and the real primitive undoes its registry write |
| 34 | + * and throws its own 500. |
| 35 | + * |
| 36 | + * Each asserts the refusal as the ENVELOPE an HTTP boundary resolves it to |
| 37 | + * (`resolveThrownHttpError`, the resolver the door's `errorFromThrown` uses), |
| 38 | + * that it is the install's own error object, rethrown unchanged, and that |
| 39 | + * nothing was copied. A CONTROL on the same harness holds the other side: an |
| 40 | + * accepted install still copies, and logs nothing. |
| 41 | + */ |
| 42 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
| 43 | +import { resolveThrownHttpError } from '@objectstack/types'; |
| 44 | +import { ObjectStackProtocolImplementation } from './protocol.js'; |
| 45 | + |
| 46 | +const SOURCE = 'com.example.leave'; |
| 47 | +const TARGET = 'com.example.leave-copy'; |
| 48 | +/** The installed package whose namespace the copy asks for. */ |
| 49 | +const OWNER = 'com.example.taken'; |
| 50 | +const DRIVER_TEXT = 'SQLITE_ERROR: no such table: sys_packages'; |
| 51 | + |
| 52 | +/** `NamespaceConflictError`'s envelope, as `SchemaRegistry.installPackage` throws it. */ |
| 53 | +function namespaceConflict(namespace: string, existing: string, incoming: string): Error { |
| 54 | + return Object.assign( |
| 55 | + new Error( |
| 56 | + `Namespace conflict: namespace "${namespace}" is already owned by package "${existing}", ` |
| 57 | + + `so package "${incoming}" cannot be installed alongside it. Choose a different namespace for ` |
| 58 | + + `"${incoming}", or uninstall "${existing}" first.`, |
| 59 | + ), |
| 60 | + { name: 'NamespaceConflictError', code: 'NAMESPACE_CONFLICT', status: 422, httpStatus: 422 }, |
| 61 | + ); |
| 62 | +} |
| 63 | + |
| 64 | +/** |
| 65 | + * A duplicate-door harness: one source package holding one object row, a |
| 66 | + * registry that keeps its package records and namespace owners, and |
| 67 | + * optionally a `package` service. The namespace gate refuses exactly as the |
| 68 | + * real one does: a namespace another installed package owns. Whatever it |
| 69 | + * throws is kept in `thrown`, so a pin can ask for that very object back. |
| 70 | + */ |
| 71 | +function makeHarness(opts: { publish?: (data: unknown) => Promise<unknown> } = {}) { |
| 72 | + const rows = [{ |
| 73 | + id: 'r_1', |
| 74 | + type: 'object', |
| 75 | + name: 'leave_ticket', |
| 76 | + organization_id: null, |
| 77 | + package_id: SOURCE, |
| 78 | + state: 'active', |
| 79 | + metadata: JSON.stringify({ name: 'leave_ticket', label: 'Ticket' }), |
| 80 | + }]; |
| 81 | + const packages = new Map<string, any>([ |
| 82 | + [SOURCE, { manifest: { id: SOURCE, name: 'Leave', namespace: 'leave', version: '1.0.0' } }], |
| 83 | + [OWNER, { manifest: { id: OWNER, name: 'Taken', namespace: 'taken', version: '1.0.0' } }], |
| 84 | + ]); |
| 85 | + const namespaceOwners = new Map<string, string[]>([['leave', [SOURCE]], ['taken', [OWNER]]]); |
| 86 | + const thrown: unknown[] = []; |
| 87 | + const registry: any = { |
| 88 | + getPackage: vi.fn((id: string) => packages.get(id)), |
| 89 | + getNamespaceOwners: vi.fn((ns: string) => [...(namespaceOwners.get(ns) ?? [])]), |
| 90 | + installPackage: vi.fn((manifest: any) => { |
| 91 | + const owner = (namespaceOwners.get(manifest.namespace) ?? []).find((id) => id !== manifest.id); |
| 92 | + if (owner) { |
| 93 | + const refusal = namespaceConflict(manifest.namespace, owner, manifest.id); |
| 94 | + thrown.push(refusal); |
| 95 | + throw refusal; |
| 96 | + } |
| 97 | + const row = { manifest, status: 'installed', enabled: true }; |
| 98 | + packages.set(manifest.id, row); |
| 99 | + namespaceOwners.set(manifest.namespace, [...(namespaceOwners.get(manifest.namespace) ?? []), manifest.id]); |
| 100 | + return row; |
| 101 | + }), |
| 102 | + unregisterItem: vi.fn((type: string, id: string) => { |
| 103 | + if (type === 'package') packages.delete(id); |
| 104 | + }), |
| 105 | + unregisterNamespace: vi.fn((ns: string, id: string) => { |
| 106 | + namespaceOwners.set(ns, (namespaceOwners.get(ns) ?? []).filter((owner) => owner !== id)); |
| 107 | + }), |
| 108 | + }; |
| 109 | + const engine: any = { find: vi.fn(async () => rows), registry }; |
| 110 | + const services = new Map<string, unknown>(); |
| 111 | + if (opts.publish) services.set('package', { publish: vi.fn(opts.publish) }); |
| 112 | + const impl = new ObjectStackProtocolImplementation(engine as never, () => services as never); |
| 113 | + const saveMetaItem = vi.spyOn(impl, 'saveMetaItem' as never); |
| 114 | + (saveMetaItem as any).mockResolvedValue({ success: true } as never); |
| 115 | + return { impl, packages, namespaceOwners, saveMetaItem, thrown }; |
| 116 | +} |
| 117 | + |
| 118 | +/** The thrown refusal, or a failure naming what happened instead. */ |
| 119 | +async function refusalOf(run: () => Promise<unknown>): Promise<any> { |
| 120 | + try { |
| 121 | + await run(); |
| 122 | + } catch (e) { |
| 123 | + return e; |
| 124 | + } |
| 125 | + throw new Error('expected the duplicate to be refused, but it resolved'); |
| 126 | +} |
| 127 | + |
| 128 | +/** The `[protocol.duplicatePackage] refused:` log calls, each with its arguments. */ |
| 129 | +function refusalLogs(warn: { mock: { calls: unknown[][] } }): unknown[][] { |
| 130 | + return warn.mock.calls.filter((args: unknown[]) => String(args[0]).startsWith('[protocol.duplicatePackage] refused:')); |
| 131 | +} |
| 132 | + |
| 133 | +describe('[#24041] duplicatePackage is refused when the copy\'s package record is refused', () => { |
| 134 | + let warn: { mock: { calls: unknown[][] }; mockRestore: () => void }; |
| 135 | + beforeEach(() => { |
| 136 | + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) as never; |
| 137 | + }); |
| 138 | + afterEach(() => { |
| 139 | + warn.mockRestore(); |
| 140 | + }); |
| 141 | + |
| 142 | + it('answers the registry\'s namespace refusal (422 NAMESPACE_CONFLICT) unchanged, and copies nothing', async () => { |
| 143 | + const { impl, packages, namespaceOwners, saveMetaItem, thrown } = makeHarness(); |
| 144 | + |
| 145 | + const err = await refusalOf(() => (impl as any).duplicatePackage({ |
| 146 | + sourcePackageId: SOURCE, |
| 147 | + targetPackageId: TARGET, |
| 148 | + targetNamespace: 'taken', |
| 149 | + })); |
| 150 | + |
| 151 | + // The registry's own refusal, rethrown unchanged — so the door answers |
| 152 | + // its status, its code and its remedy. |
| 153 | + expect(thrown).toHaveLength(1); |
| 154 | + expect(err).toBe(thrown[0]); |
| 155 | + const envelope = resolveThrownHttpError(err); |
| 156 | + expect(envelope.status).toBe(422); |
| 157 | + expect(envelope.code).toBe('NAMESPACE_CONFLICT'); |
| 158 | + |
| 159 | + // Nothing was copied, and nothing was minted. |
| 160 | + expect(saveMetaItem).not.toHaveBeenCalled(); |
| 161 | + expect(packages.has(TARGET)).toBe(false); |
| 162 | + expect(namespaceOwners.get('taken')).toEqual([OWNER]); |
| 163 | + |
| 164 | + // Logged once, naming the target, the code and the sentence; a client |
| 165 | + // refusal is not handed to the log as an error object. |
| 166 | + const logs = refusalLogs(warn); |
| 167 | + expect(logs).toHaveLength(1); |
| 168 | + expect(logs[0]).toHaveLength(1); |
| 169 | + expect(String(logs[0]![0])).toContain(`'${TARGET}'`); |
| 170 | + expect(String(logs[0]![0])).toContain('NAMESPACE_CONFLICT'); |
| 171 | + expect(String(logs[0]![0])).toContain(err.message); |
| 172 | + }); |
| 173 | + |
| 174 | + it('answers a failed sys_packages write with the primitive\'s 500, undone, and copies nothing', async () => { |
| 175 | + const { impl, packages, saveMetaItem } = makeHarness({ |
| 176 | + publish: async () => ({ success: false, driverFault: { message: DRIVER_TEXT } }), |
| 177 | + }); |
| 178 | + |
| 179 | + const err = await refusalOf(() => (impl as any).duplicatePackage({ sourcePackageId: SOURCE, targetPackageId: TARGET })); |
| 180 | + |
| 181 | + // The primitive's own failure: a 500 whose sentence names the target and |
| 182 | + // quotes nothing of the store. |
| 183 | + const envelope = resolveThrownHttpError(err); |
| 184 | + expect(envelope.status).toBe(500); |
| 185 | + expect(err.message).toContain(`'${TARGET}'`); |
| 186 | + expect(err.message).not.toContain('SQLITE'); |
| 187 | + |
| 188 | + // Nothing was copied, and the primitive undid its registry write. |
| 189 | + expect(saveMetaItem).not.toHaveBeenCalled(); |
| 190 | + expect(packages.has(TARGET)).toBe(false); |
| 191 | + |
| 192 | + // Logged once, with the error itself attached: its `cause` is the only |
| 193 | + // place the driver's text survives. |
| 194 | + const logs = refusalLogs(warn); |
| 195 | + expect(logs).toHaveLength(1); |
| 196 | + expect(String(logs[0]![0])).toContain(`'${TARGET}'`); |
| 197 | + expect(logs[0]![1]).toBe(err); |
| 198 | + expect((err as { cause?: any }).cause?.driverFault?.message).toBe(DRIVER_TEXT); |
| 199 | + }); |
| 200 | + |
| 201 | + it('CONTROL: an accepted install still copies, and logs no refusal', async () => { |
| 202 | + const { impl, packages, saveMetaItem } = makeHarness({ publish: async () => ({ success: true }) }); |
| 203 | + |
| 204 | + const res = await (impl as any).duplicatePackage({ sourcePackageId: SOURCE, targetPackageId: TARGET }); |
| 205 | + |
| 206 | + expect(res.failed).toEqual([]); |
| 207 | + expect(res.success).toBe(true); |
| 208 | + expect(res.copied).toEqual([{ type: 'object', name: 'leave_copy_ticket' }]); |
| 209 | + expect(saveMetaItem).toHaveBeenCalledTimes(1); |
| 210 | + expect(packages.get(TARGET)?.manifest?.namespace).toBe('leave_copy'); |
| 211 | + expect(refusalLogs(warn)).toEqual([]); |
| 212 | + }); |
| 213 | +}); |
0 commit comments