Skip to content

Commit ebf0b8e

Browse files
committed
test(cli): pin that an install-local uninstall leaves no package permission set or grant behind
Reproduces the defect at the public door on both orders of events (hot install -> DELETE -> restart; install -> restart -> DELETE -> restart). Red on main until the uninstall runs the registered cleanups. Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 9ff7428 commit ebf0b8e

1 file changed

Lines changed: 361 additions & 0 deletions

File tree

Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #21490 — an install-local uninstall (`DELETE /api/v1/marketplace/install-local/:id`)
5+
* runs the protocol's registered uninstall cleanups, so the package's permission
6+
* sets and their bindings die with it (ADR-0090: "No ghost grants").
7+
*
8+
* ## The defect, measured at this door on `main` 9ff74285f1
9+
*
10+
* A package installed through install-local declares a permission set, and the
11+
* set is projected into `sys_permission_set` with `managed_by: package` (#21322
12+
* made that happen on the hot install, as a restart already did). The DELETE
13+
* answered 200 and removed the ledger entry, and nothing else: the package's
14+
* object answered 404 after a restart, but its `sys_permission_set` row — and
15+
* every grant of it — survived, on both orders of events:
16+
*
17+
* - hot install → DELETE → restart;
18+
* - install → restart → DELETE → restart.
19+
*
20+
* The protocol's own uninstall (`deletePackage`) runs every cleanup a domain
21+
* plugin registered through `registerUninstallCleanup` — plugin-security's
22+
* `security.package-permissions` is the one that removes package-owned sets with
23+
* their position and user bindings. This door never ran that registry.
24+
*
25+
* ## What each `it` reads
26+
*
27+
* One fixture, two orders of events, each probed through the data route a user
28+
* and an admin use: the set by name, the user grant of it by set id, and — after
29+
* the restart — the package's object. The grant is made through the data door
30+
* before the uninstall, so "no binding" is read off a row that existed, not off
31+
* an empty table.
32+
*
33+
* ## Spawn shape
34+
*
35+
* Shared with `package-install-local-boot-steps.integration.test.ts` (#21322):
36+
* the tsx source entry, one process group per `os start`, every workspace
37+
* package — `@objectstack/cloud-connection` and `@objectstack/metadata-protocol`
38+
* included — resolved through its `exports` to `dist/`, so an ablation of either
39+
* package's source reaches this file only after that package is rebuilt. Every
40+
* boot runs in `beforeAll`; the `it`s only read what the phases recorded.
41+
*/
42+
43+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
44+
import { spawn, type ChildProcess } from 'node:child_process';
45+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
46+
import { tmpdir } from 'node:os';
47+
import { join } from 'node:path';
48+
import {
49+
CLI,
50+
childEnv,
51+
E2E_SECRET_KEY,
52+
portContentionError,
53+
portDriftError,
54+
probeThroughChild,
55+
randomPort,
56+
TSX,
57+
} from './helpers/serve-process.js';
58+
59+
/** The banner's tail — every row above it has printed. */
60+
const READY = /Press Ctrl\+C to stop/;
61+
const BOOT_TIMEOUT_MS = 180_000;
62+
63+
const APP_ID = 'com.example.tasksapp';
64+
const TASK = 'tasks_app_task';
65+
const PERMISSION_SET = 'tasks_app_task_user';
66+
const UNINSTALL = `/api/v1/marketplace/install-local/${APP_ID}`;
67+
/** The development dev-admin seed — see the #21321 sibling for why it is the operator on every boot. */
68+
const EMAIL = 'admin@objectos.ai';
69+
const PASSWORD = 'admin123';
70+
71+
/** `dist/objectstack.json` as `os build` writes it for this app (schema defaults trimmed). */
72+
const ARTIFACT = {
73+
manifest: { id: APP_ID, namespace: 'tasks_app', version: '0.1.0', type: 'app', name: 'Tasks App' },
74+
objects: [{
75+
name: TASK,
76+
label: 'Task',
77+
sharingModel: 'public_read_write',
78+
fields: { name: { type: 'text', label: 'Name' } },
79+
}],
80+
permissions: [{
81+
name: PERMISSION_SET,
82+
label: 'Tasks App Task User',
83+
objects: { [TASK]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false } },
84+
}],
85+
};
86+
87+
const groups: ChildProcess[] = [];
88+
const dirs: string[] = [];
89+
90+
interface LiveStart {
91+
child: ChildProcess;
92+
base: string;
93+
output: () => string;
94+
}
95+
96+
function bootStart(cwd: string, home: string, port: string): Promise<LiveStart> {
97+
return new Promise((resolveBoot, rejectBoot) => {
98+
const child = spawn(TSX, [CLI, 'start', '-p', port, '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui'], {
99+
cwd,
100+
// `childEnv`, never a bare `...process.env` — see its header.
101+
env: childEnv({ NO_COLOR: '1', OS_CLOUD_URL: 'off', OS_LOG_LEVEL: 'warn', OS_SECRET_KEY: E2E_SECRET_KEY }),
102+
stdio: ['ignore', 'pipe', 'pipe'],
103+
// Own process group: `os start` supervises a `serve` grandchild.
104+
detached: true,
105+
});
106+
groups.push(child);
107+
let out = '';
108+
let settled = false;
109+
const settle = (err: Error | null) => {
110+
if (settled) return;
111+
settled = true;
112+
clearTimeout(timer);
113+
if (err) rejectBoot(err);
114+
else resolveBoot({ child, base: `http://localhost:${port}`, output: () => out });
115+
};
116+
const timer = setTimeout(
117+
() => settle(new Error(`os start never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)),
118+
BOOT_TIMEOUT_MS,
119+
);
120+
const onData = (d: unknown) => {
121+
out += String(d);
122+
// The child is the authority on the port it bound.
123+
if (READY.test(out)) settle(portDriftError(out, 'os start', port));
124+
};
125+
child.stdout?.on('data', onData);
126+
child.stderr?.on('data', onData);
127+
child.on('exit', (code) =>
128+
settle(portContentionError(out, 'os start', port)
129+
?? new Error(`os start exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)),
130+
);
131+
});
132+
}
133+
134+
async function stopGroup(child: ChildProcess): Promise<void> {
135+
if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return;
136+
await new Promise<void>((done) => {
137+
const give = setTimeout(() => {
138+
try { process.kill(-child.pid!, 'SIGKILL'); } catch { /* group already gone */ }
139+
done();
140+
}, 15_000);
141+
child.once('exit', () => { clearTimeout(give); done(); });
142+
try { process.kill(-child.pid!, 'SIGTERM'); } catch { clearTimeout(give); done(); }
143+
});
144+
}
145+
146+
interface Answer { status: number; body: any }
147+
148+
/** One exchange against the running `os start`, attributed to the child if the transport fails. ⛔ No assertion inside it. */
149+
function http(live: LiveStart, method: string, path: string, token: string, body?: unknown): Promise<Answer> {
150+
return probeThroughChild(
151+
{
152+
child: live.child,
153+
transcript: () => `\n--- child output ---\n${live.output().slice(-4000)}`,
154+
label: 'package-install-local-uninstall-cleanups',
155+
what: `${method} ${path}`,
156+
},
157+
async () => {
158+
const r = await fetch(`${live.base}${path}`, {
159+
method,
160+
headers: {
161+
origin: live.base,
162+
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
163+
...(token ? { authorization: `Bearer ${token}` } : {}),
164+
},
165+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
166+
});
167+
const text = await r.text();
168+
let parsed: any = text;
169+
try { parsed = JSON.parse(text); } catch { /* keep the text */ }
170+
return { status: r.status, body: parsed };
171+
},
172+
);
173+
}
174+
175+
interface Session { token: string; userId: string }
176+
177+
async function authenticate(live: LiveStart): Promise<Session> {
178+
const res = await http(live, 'POST', '/api/v1/auth/sign-in/email', '', { email: EMAIL, password: PASSWORD });
179+
const token = res.body?.token;
180+
const userId = res.body?.user?.id;
181+
if (res.status !== 200 || typeof token !== 'string' || typeof userId !== 'string') {
182+
throw new Error(`auth answered ${res.status}: ${JSON.stringify(res.body)}\n--- output ---\n${live.output().slice(-3000)}`);
183+
}
184+
return { token, userId };
185+
}
186+
187+
/**
188+
* `os package install ./dist/objectstack.json` against the running runtime.
189+
* ⛔ Asynchronous on purpose — see the #21321 sibling: a `spawnSync` stops this
190+
* process draining the server's pipes for the whole install.
191+
*/
192+
function packageInstall(appDir: string, live: LiveStart): Promise<{ exit: number | null; output: string }> {
193+
return new Promise((done) => {
194+
const child = spawn(TSX, [CLI, 'package', 'install', './dist/objectstack.json', '--runtime', live.base, '--email', EMAIL, '--password', PASSWORD], {
195+
cwd: appDir,
196+
env: childEnv({ NO_COLOR: '1' }),
197+
stdio: ['ignore', 'pipe', 'pipe'],
198+
});
199+
let output = '';
200+
child.stdout?.on('data', (d) => { output += String(d); });
201+
child.stderr?.on('data', (d) => { output += String(d); });
202+
const timer = setTimeout(() => child.kill('SIGKILL'), 120_000);
203+
child.on('close', (code) => { clearTimeout(timer); done({ exit: code, output }); });
204+
});
205+
}
206+
207+
/** The rows of a `GET /api/v1/data/:object` list answer, whichever envelope it came in. */
208+
function rowsOf(answer: Answer): any[] {
209+
const b = answer.body?.data ?? answer.body;
210+
if (Array.isArray(b)) return b;
211+
if (Array.isArray(b?.records)) return b.records;
212+
if (Array.isArray(b?.items)) return b.items;
213+
return [];
214+
}
215+
216+
const recordOf = (a: Answer) => a.body?.data ?? a.body?.record ?? a.body;
217+
218+
/** What the data route answers about the package's grants at one moment. */
219+
interface Grants {
220+
/** `GET /data/sys_permission_set?name=…` — the projection the admin surface grants from. */
221+
sets: Answer;
222+
/** `GET /data/sys_user_permission_set?permission_set_id=…` — the user grant made before the uninstall. */
223+
bindings: Answer;
224+
}
225+
226+
async function readGrants(live: LiveStart, token: string, setId: string): Promise<Grants> {
227+
return {
228+
sets: await http(live, 'GET', `/api/v1/data/sys_permission_set?name=${PERMISSION_SET}`, token),
229+
bindings: await http(live, 'GET', `/api/v1/data/sys_user_permission_set?permission_set_id=${encodeURIComponent(setId)}`, token),
230+
};
231+
}
232+
233+
/** One order of events, recorded phase by phase; the `it`s read it. */
234+
interface Run {
235+
install?: { exit: number | null; output: string };
236+
/** The set and its grant, as they stood right before the DELETE. */
237+
before?: Grants;
238+
/** `POST /data/sys_user_permission_set` — the grant whose survival is the defect's second half. */
239+
granted?: Answer;
240+
setId?: string;
241+
uninstall?: Answer;
242+
/** Same process, right after the DELETE answered. */
243+
after?: Grants;
244+
/** A restart on the same home. */
245+
restarted?: Grants;
246+
/** The package's object after the restart — the uninstall's own effect, as the control. */
247+
object?: Answer;
248+
}
249+
250+
/**
251+
* Read the set, grant it to the operator, DELETE the package, read again.
252+
* The grant is made here, on whichever boot runs the uninstall, so it is
253+
* the same act on both orders of events.
254+
*/
255+
async function grantThenUninstall(live: LiveStart, session: Session, run: Run): Promise<void> {
256+
const sets = await http(live, 'GET', `/api/v1/data/sys_permission_set?name=${PERMISSION_SET}`, session.token);
257+
const setId = rowsOf(sets)[0]?.id;
258+
if (typeof setId !== 'string') {
259+
throw new Error(`precondition: no ${PERMISSION_SET} row to grant — ${sets.status} ${JSON.stringify(sets.body)}\n--- output ---\n${live.output().slice(-3000)}`);
260+
}
261+
run.setId = setId;
262+
run.granted = await http(live, 'POST', '/api/v1/data/sys_user_permission_set', session.token, {
263+
user_id: session.userId,
264+
permission_set_id: setId,
265+
});
266+
run.before = await readGrants(live, session.token, setId);
267+
run.uninstall = await http(live, 'DELETE', UNINSTALL, session.token);
268+
run.after = await readGrants(live, session.token, setId);
269+
}
270+
271+
async function readAfterRestart(live: LiveStart, run: Run): Promise<void> {
272+
const { token } = await authenticate(live);
273+
run.restarted = await readGrants(live, token, run.setId!);
274+
run.object = await http(live, 'GET', `/api/v1/data/${TASK}`, token);
275+
}
276+
277+
const runs: Record<'hot' | 'restarted', Run> = { hot: {}, restarted: {} };
278+
279+
beforeAll(async () => {
280+
const root = mkdtempSync(join(tmpdir(), 'install-local-uninstall-'));
281+
dirs.push(root);
282+
const appDir = join(root, 'app');
283+
mkdirSync(join(appDir, 'dist'), { recursive: true });
284+
writeFileSync(join(appDir, 'dist', 'objectstack.json'), JSON.stringify(ARTIFACT, null, 2), 'utf8');
285+
const port = randomPort();
286+
287+
// ── order 1: hot install → DELETE → restart ────────────────────────────
288+
const hotDir = join(root, 'hot');
289+
mkdirSync(hotDir, { recursive: true });
290+
const hotHome = join(hotDir, 'home');
291+
const a = await bootStart(hotDir, hotHome, port);
292+
const aSession = await authenticate(a);
293+
runs.hot.install = await packageInstall(appDir, a);
294+
await grantThenUninstall(a, aSession, runs.hot);
295+
await stopGroup(a.child);
296+
const b = await bootStart(hotDir, hotHome, port);
297+
await readAfterRestart(b, runs.hot);
298+
await stopGroup(b.child);
299+
300+
// ── order 2: install → restart → DELETE → restart ──────────────────────
301+
// The DELETE meets a package the ledger REHYDRATED, not one this process
302+
// hot-installed — the path that existed before #21322's change.
303+
const coldDir = join(root, 'cold');
304+
mkdirSync(coldDir, { recursive: true });
305+
const coldHome = join(coldDir, 'home');
306+
const c = await bootStart(coldDir, coldHome, port);
307+
await authenticate(c);
308+
runs.restarted.install = await packageInstall(appDir, c);
309+
await stopGroup(c.child);
310+
const d = await bootStart(coldDir, coldHome, port);
311+
await grantThenUninstall(d, await authenticate(d), runs.restarted);
312+
await stopGroup(d.child);
313+
const e = await bootStart(coldDir, coldHome, port);
314+
await readAfterRestart(e, runs.restarted);
315+
await stopGroup(e.child);
316+
}, 6 * BOOT_TIMEOUT_MS);
317+
318+
afterAll(async () => {
319+
for (const child of groups) await stopGroup(child);
320+
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
321+
}, 60_000);
322+
323+
describe('#21490: an install-local uninstall runs the registered uninstall cleanups', () => {
324+
for (const name of ['hot', 'restarted'] as const) {
325+
describe(`${name === 'hot' ? 'hot install' : 'install → restart'} → DELETE → restart`, () => {
326+
it('precondition: the install landed, and the set and its grant exist before the DELETE', () => {
327+
const run = runs[name];
328+
expect(run.install?.exit, run.install?.output).toBe(0);
329+
expect(run.granted?.status, JSON.stringify(run.granted?.body)).toBe(201);
330+
expect(rowsOf(run.before!.sets).map((r) => [r?.name, r?.managed_by, r?.package_id]))
331+
.toEqual([[PERMISSION_SET, 'package', APP_ID]]);
332+
expect(rowsOf(run.before!.bindings).map((r) => r?.id)).toEqual([recordOf(run.granted!)?.id]);
333+
});
334+
335+
it('the DELETE answers 200 and reports the security cleanup it ran', () => {
336+
const run = runs[name];
337+
expect(run.uninstall?.status, JSON.stringify(run.uninstall?.body)).toBe(200);
338+
const cleanups: any[] = run.uninstall?.body?.data?.cleanups ?? [];
339+
const security = cleanups.find((o) => o?.name === 'security.package-permissions');
340+
expect(security, JSON.stringify(run.uninstall?.body)).toMatchObject({ success: true });
341+
});
342+
343+
it('right after the DELETE: no package-managed set, no grant of it', () => {
344+
const run = runs[name];
345+
expect(run.after!.sets.status).toBe(200);
346+
expect(rowsOf(run.after!.sets), JSON.stringify(run.after!.sets.body)).toEqual([]);
347+
expect(run.after!.bindings.status).toBe(200);
348+
expect(rowsOf(run.after!.bindings), JSON.stringify(run.after!.bindings.body)).toEqual([]);
349+
});
350+
351+
it('after a restart: still no set and no grant, and the package object is gone', () => {
352+
const run = runs[name];
353+
expect(run.object?.status, JSON.stringify(run.object?.body)).toBe(404);
354+
expect(run.restarted!.sets.status).toBe(200);
355+
expect(rowsOf(run.restarted!.sets), JSON.stringify(run.restarted!.sets.body)).toEqual([]);
356+
expect(run.restarted!.bindings.status).toBe(200);
357+
expect(rowsOf(run.restarted!.bindings), JSON.stringify(run.restarted!.bindings.body)).toEqual([]);
358+
});
359+
});
360+
}
361+
});

0 commit comments

Comments
 (0)