Skip to content

Commit da44b70

Browse files
committed
test(objectql,dogfood): pin that a refused package registration leaves nothing behind
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5kPpaFpvn6zyWoBxLN
1 parent beaf38d commit da44b70

2 files changed

Lines changed: 180 additions & 0 deletions

File tree

‎packages/objectql/src/registry-module-mock.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ export interface RegistryDoubleInstance {
9797
registerItem: any;
9898
registerApp: any;
9999
installPackage: any;
100+
getPackage: any;
101+
getObjectContributors: any;
100102
reset: any;
101103
metadata: any;
102104
[member: string]: any;
@@ -173,6 +175,14 @@ export function createRegistryModuleMock(
173175
registerItem: vi.fn(),
174176
registerApp: vi.fn(),
175177
installPackage: vi.fn(),
178+
// [#28697] `registerApp` asks, before it writes anything, whether a refused
179+
// first registration of the package could be withdrawn: no package record
180+
// under its id, and who already contributes to each object it owns. The
181+
// same lesson as `getAllObjects` above: empty is the truthful body for a
182+
// suite that installs nothing for real, and an omitted member is a hard
183+
// `TypeError` at every `registerApp`.
184+
getPackage: vi.fn(() => undefined),
185+
getObjectContributors: vi.fn(() => []),
176186
reset: vi.fn(),
177187
metadata: { get: vi.fn(() => new Map()) },
178188
...instanceOverrides,
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// GOLDEN REGRESSION — a package the install refuses part-way through
4+
// registering leaves nothing of itself in the running system (#28697).
5+
//
6+
// ## What was measured before the fix
7+
//
8+
// Installed into the showcase through `POST /api/v1/marketplace/install-local`,
9+
// an inline manifest whose object and app were clean and whose connector wore
10+
// the showcase's own `_packageId` answered 422 `PLUGIN_REGISTER_FAILED`, with
11+
// no ledger entry; but the package, its object and its app had already
12+
// registered, and stayed readable until restart.
13+
//
14+
// ## What this file pins, on one boot
15+
//
16+
// 1. that install is refused as before, and afterwards neither the package,
17+
// its object nor its app reads; the same id then installs clean, 200;
18+
// 2. an install the registry refuses part-way (its second object is one the
19+
// showcase owns) is refused 422 `PLUGIN_REGISTER_FAILED`, and afterwards
20+
// neither the package nor its first object reads; the showcase's object
21+
// still reads;
22+
// 3. a refused upgrade of an installed package (v2's connector wears the
23+
// showcase's id) leaves v1 serving: its object, its app and the package at
24+
// version 1.0.0 read, and v2's new object does not.
25+
//
26+
// Boots a fixture stack of its own (the install-local plugin mounted), so it
27+
// stays out of `SHARED_SHOWCASE`.
28+
29+
import { mkdtempSync, rmSync } from 'node:fs';
30+
import { tmpdir } from 'node:os';
31+
import { join } from 'node:path';
32+
33+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
34+
import { type VerifyStack } from '@objectstack/verify';
35+
import { MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection';
36+
import { bootShowcase } from './showcase-boot.js';
37+
38+
const ROUTE = '/marketplace/install-local';
39+
/** The showcase's own package id, and an object it owns. */
40+
const HOST_PACKAGE_ID = 'com.example.showcase';
41+
const HOST_OBJECT = 'showcase_product';
42+
43+
const FRESH_ID = 'com.dogfood.refusedfresh';
44+
const MIDWAY_ID = 'com.dogfood.refusedmidway';
45+
const UPGRADE_ID = 'com.dogfood.refusedupgrade';
46+
47+
const object = (name: string) => ({ name, label: name, fields: { title: { type: 'text', label: 'Title' } } });
48+
const app = (name: string, objectName: string) => ({
49+
name,
50+
label: name,
51+
navigation: [{ id: `nav_${objectName}`, type: 'object', label: objectName, objectName }],
52+
});
53+
/** A connector naming the showcase as its owner: refused from the body alone. */
54+
const stampedConnector = (name: string) => ({
55+
name,
56+
label: 'Claims the host as its owner',
57+
type: 'api',
58+
provider: 'openapi',
59+
providerConfig: { spec: { openapi: '3.0.0', info: { title: 't', version: '1' }, paths: {} } },
60+
_packageId: HOST_PACKAGE_ID,
61+
});
62+
const install = (id: string, namespace: string, version: string, extra: Record<string, unknown>) => ({
63+
manifest: { id, namespace, name: `Refused ${namespace}`, version, type: 'app', ...extra },
64+
});
65+
66+
const FRESH = install(FRESH_ID, 'refusedfresh', '1.0.0', {
67+
objects: [object('refusedfresh_thing')],
68+
apps: [app('refusedfresh_app', 'refusedfresh_thing')],
69+
connectors: [stampedConnector('refusedfresh_conn')],
70+
});
71+
const FRESH_CLEAN = install(FRESH_ID, 'refusedfresh', '1.0.0', {
72+
objects: [object('refusedfresh_thing')],
73+
apps: [app('refusedfresh_app', 'refusedfresh_thing')],
74+
});
75+
const MIDWAY = install(MIDWAY_ID, 'refusedmidway', '1.0.0', {
76+
objects: [object('refusedmidway_thing'), object(HOST_OBJECT)],
77+
});
78+
const UPGRADE_V1 = install(UPGRADE_ID, 'refusedupgrade', '1.0.0', {
79+
objects: [object('refusedupgrade_thing')],
80+
apps: [app('refusedupgrade_app', 'refusedupgrade_thing')],
81+
});
82+
const UPGRADE_V2 = install(UPGRADE_ID, 'refusedupgrade', '2.0.0', {
83+
objects: [object('refusedupgrade_thing'), object('refusedupgrade_new')],
84+
apps: [app('refusedupgrade_app', 'refusedupgrade_thing')],
85+
connectors: [stampedConnector('refusedupgrade_conn')],
86+
});
87+
88+
type Answer = { status: number; body: any };
89+
90+
async function json(res: Response): Promise<Answer> {
91+
return { status: res.status, body: await res.json().catch(() => null) };
92+
}
93+
94+
describe('dogfood: a package the install refuses part-way leaves nothing of itself registered', () => {
95+
let stack: VerifyStack;
96+
let storageDir: string;
97+
const answers: Record<string, Answer> = {};
98+
99+
beforeAll(async () => {
100+
storageDir = mkdtempSync(join(tmpdir(), 'dogfood-refused-package-'));
101+
stack = await bootShowcase({
102+
extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })],
103+
});
104+
const token = await stack.signIn();
105+
const get = async (path: string) => json(await stack.apiAs(token, 'GET', path));
106+
const post = async (body: unknown) => json(await stack.apiAs(token, 'POST', ROUTE, body));
107+
108+
// 1
109+
answers.fresh = await post(FRESH);
110+
answers.freshPackage = await get(`/packages/${FRESH_ID}`);
111+
answers.freshObject = await get('/meta/object/refusedfresh_thing');
112+
answers.freshApp = await get('/meta/app/refusedfresh_app');
113+
answers.freshClean = await post(FRESH_CLEAN);
114+
answers.freshCleanApp = await get('/meta/app/refusedfresh_app');
115+
116+
// 2
117+
answers.midway = await post(MIDWAY);
118+
answers.midwayPackage = await get(`/packages/${MIDWAY_ID}`);
119+
answers.midwayObject = await get('/meta/object/refusedmidway_thing');
120+
answers.hostObject = await get(`/meta/object/${HOST_OBJECT}`);
121+
122+
// 3
123+
answers.v1 = await post(UPGRADE_V1);
124+
answers.v2 = await post(UPGRADE_V2);
125+
answers.upgradePackage = await get(`/packages/${UPGRADE_ID}`);
126+
answers.upgradeObject = await get('/meta/object/refusedupgrade_thing');
127+
answers.upgradeApp = await get('/meta/app/refusedupgrade_app');
128+
answers.upgradeNew = await get('/meta/object/refusedupgrade_new');
129+
}, 300_000);
130+
131+
afterAll(async () => {
132+
await stack?.stop?.();
133+
if (storageDir) rmSync(storageDir, { recursive: true, force: true });
134+
});
135+
136+
const shown = (a: Answer) => JSON.stringify(a.body).slice(0, 600);
137+
138+
it('1: a body-only refusal leaves no package, object or app; the same id then installs clean', () => {
139+
expect(answers.fresh.status, shown(answers.fresh)).toBe(422);
140+
expect(answers.fresh.body?.error?.code).toBe('PLUGIN_REGISTER_FAILED');
141+
expect(answers.fresh.body?.error?.message).toContain('Drop `_packageId` from connector \'refusedfresh_conn\'');
142+
expect(answers.freshPackage.status, shown(answers.freshPackage)).toBe(404);
143+
expect(answers.freshObject.status, shown(answers.freshObject)).toBe(404);
144+
expect(answers.freshApp.status, shown(answers.freshApp)).toBe(404);
145+
expect(answers.freshClean.status, shown(answers.freshClean)).toBe(200);
146+
expect(answers.freshClean.body?.data?.hotLoaded).toBe(true);
147+
expect(answers.freshCleanApp.status).toBe(200);
148+
});
149+
150+
it('2: a refusal the registry raises part-way leaves no package or object; the showcase\'s object still reads', () => {
151+
expect(answers.midway.status, shown(answers.midway)).toBe(422);
152+
expect(answers.midway.body?.error?.code).toBe('PLUGIN_REGISTER_FAILED');
153+
expect(answers.midway.body?.error?.message).toContain(`already owned by package "${HOST_PACKAGE_ID}"`);
154+
expect(answers.midwayPackage.status, shown(answers.midwayPackage)).toBe(404);
155+
expect(answers.midwayObject.status, shown(answers.midwayObject)).toBe(404);
156+
expect(answers.hostObject.status, shown(answers.hostObject)).toBe(200);
157+
});
158+
159+
it('3: a refused upgrade leaves the installed version serving', () => {
160+
expect(answers.v1.status, shown(answers.v1)).toBe(200);
161+
expect(answers.v2.status, shown(answers.v2)).toBe(422);
162+
expect(answers.v2.body?.error?.code).toBe('PLUGIN_REGISTER_FAILED');
163+
expect(answers.upgradePackage.status, shown(answers.upgradePackage)).toBe(200);
164+
expect(JSON.stringify(answers.upgradePackage.body)).toContain('"version":"1.0.0"');
165+
expect(JSON.stringify(answers.upgradePackage.body)).not.toContain('"version":"2.0.0"');
166+
expect(answers.upgradeObject.status).toBe(200);
167+
expect(answers.upgradeApp.status).toBe(200);
168+
expect(answers.upgradeNew.status, shown(answers.upgradeNew)).toBe(404);
169+
});
170+
});

0 commit comments

Comments
 (0)