|
30 | 30 | * before the uninstall, so "no binding" is read off a row that existed, not off |
31 | 31 | * an empty table. |
32 | 32 | * |
| 33 | + * A third order of events measures the re-seed window — DELETE, then a hot |
| 34 | + * install of ANOTHER package, then restart — and records, as `it.fails`, the |
| 35 | + * defect it found there; the block above that `describe` says what it is. |
| 36 | + * |
33 | 37 | * ## Spawn shape |
34 | 38 | * |
35 | 39 | * Shared with `package-install-local-boot-steps.integration.test.ts` (#21322): |
@@ -84,6 +88,22 @@ const ARTIFACT = { |
84 | 88 | }], |
85 | 89 | }; |
86 | 90 |
|
| 91 | +/** |
| 92 | + * A second, unrelated package for the re-seed order of events: its hot install |
| 93 | + * announces `metadata:reloaded`, which re-runs plugin-security's |
| 94 | + * declared-permission seeding over every package the running kernel still holds. |
| 95 | + */ |
| 96 | +const OTHER_APP_ID = 'com.example.notesapp'; |
| 97 | +const OTHER_ARTIFACT = { |
| 98 | + manifest: { id: OTHER_APP_ID, namespace: 'notes_app', version: '0.1.0', type: 'app', name: 'Notes App' }, |
| 99 | + objects: [{ |
| 100 | + name: 'notes_app_note', |
| 101 | + label: 'Note', |
| 102 | + sharingModel: 'public_read_write', |
| 103 | + fields: { name: { type: 'text', label: 'Name' } }, |
| 104 | + }], |
| 105 | +}; |
| 106 | + |
87 | 107 | const groups: ChildProcess[] = []; |
88 | 108 | const dirs: string[] = []; |
89 | 109 |
|
@@ -245,6 +265,10 @@ interface Run { |
245 | 265 | restarted?: Grants; |
246 | 266 | /** The package's object after the restart — the uninstall's own effect, as the control. */ |
247 | 267 | object?: Answer; |
| 268 | + /** Re-seed order only: the hot install of {@link OTHER_APP_ID} after the DELETE. */ |
| 269 | + otherInstall?: { exit: number | null; output: string }; |
| 270 | + /** Re-seed order only: same process, right after that second install. */ |
| 271 | + afterOtherInstall?: Grants; |
248 | 272 | } |
249 | 273 |
|
250 | 274 | /** |
@@ -274,14 +298,17 @@ async function readAfterRestart(live: LiveStart, run: Run): Promise<void> { |
274 | 298 | run.object = await http(live, 'GET', `/api/v1/data/${TASK}`, token); |
275 | 299 | } |
276 | 300 |
|
277 | | -const runs: Record<'hot' | 'restarted', Run> = { hot: {}, restarted: {} }; |
| 301 | +const runs: Record<'hot' | 'restarted' | 'reseed', Run> = { hot: {}, restarted: {}, reseed: {} }; |
278 | 302 |
|
279 | 303 | beforeAll(async () => { |
280 | 304 | const root = mkdtempSync(join(tmpdir(), 'install-local-uninstall-')); |
281 | 305 | dirs.push(root); |
282 | 306 | const appDir = join(root, 'app'); |
283 | 307 | mkdirSync(join(appDir, 'dist'), { recursive: true }); |
284 | 308 | writeFileSync(join(appDir, 'dist', 'objectstack.json'), JSON.stringify(ARTIFACT, null, 2), 'utf8'); |
| 309 | + const otherAppDir = join(root, 'other-app'); |
| 310 | + mkdirSync(join(otherAppDir, 'dist'), { recursive: true }); |
| 311 | + writeFileSync(join(otherAppDir, 'dist', 'objectstack.json'), JSON.stringify(OTHER_ARTIFACT, null, 2), 'utf8'); |
285 | 312 | const port = randomPort(); |
286 | 313 |
|
287 | 314 | // ── order 1: hot install → DELETE → restart ──────────────────────────── |
@@ -313,7 +340,24 @@ beforeAll(async () => { |
313 | 340 | const e = await bootStart(coldDir, coldHome, port); |
314 | 341 | await readAfterRestart(e, runs.restarted); |
315 | 342 | await stopGroup(e.child); |
316 | | -}, 6 * BOOT_TIMEOUT_MS); |
| 343 | + |
| 344 | + // ── order 3: hot install → DELETE → hot install of ANOTHER package → restart ── |
| 345 | + // The DELETE does not withdraw the package from the running kernel, so it is |
| 346 | + // still registered when the second install announces `metadata:reloaded`. |
| 347 | + const reseedDir = join(root, 'reseed'); |
| 348 | + mkdirSync(reseedDir, { recursive: true }); |
| 349 | + const reseedHome = join(reseedDir, 'home'); |
| 350 | + const f = await bootStart(reseedDir, reseedHome, port); |
| 351 | + const fSession = await authenticate(f); |
| 352 | + runs.reseed.install = await packageInstall(appDir, f); |
| 353 | + await grantThenUninstall(f, fSession, runs.reseed); |
| 354 | + runs.reseed.otherInstall = await packageInstall(otherAppDir, f); |
| 355 | + runs.reseed.afterOtherInstall = await readGrants(f, fSession.token, runs.reseed.setId!); |
| 356 | + await stopGroup(f.child); |
| 357 | + const g = await bootStart(reseedDir, reseedHome, port); |
| 358 | + await readAfterRestart(g, runs.reseed); |
| 359 | + await stopGroup(g.child); |
| 360 | +}, 8 * BOOT_TIMEOUT_MS); |
317 | 361 |
|
318 | 362 | afterAll(async () => { |
319 | 363 | for (const child of groups) await stopGroup(child); |
@@ -358,4 +402,51 @@ describe('#21490: an install-local uninstall runs the registered uninstall clean |
358 | 402 | }); |
359 | 403 | }); |
360 | 404 | } |
| 405 | + |
| 406 | + // ── The re-seed window: MEASURED RED, reported for filing, not fixed here ── |
| 407 | + // |
| 408 | + // This DELETE leaves the package registered in the running kernel until the |
| 409 | + // next restart (the response's own note says so), and plugin-security's |
| 410 | + // `metadata:reloaded` subscriber re-runs the declared-permission seeding over |
| 411 | + // every package the kernel holds. So another package's hot install before |
| 412 | + // that restart re-projects the uninstalled package's set as a fresh |
| 413 | + // `managed_by: package` row, and the restart leaves it orphaned: the package |
| 414 | + // is gone, its set is not. The grant does NOT come back — the cleanup deleted |
| 415 | + // the binding and the seeding writes none — and that half is pinned plainly. |
| 416 | + // |
| 417 | + // The two set readings are `it.fails`: each turns red the day its half is |
| 418 | + // fixed, which is the cue to promote it to a plain assertion. |
| 419 | + describe('hot install → DELETE → hot install of another package → restart (the re-seed window)', () => { |
| 420 | + it('precondition: both installs landed, and the DELETE revoked the set and its grant', () => { |
| 421 | + const run = runs.reseed; |
| 422 | + expect(run.install?.exit, run.install?.output).toBe(0); |
| 423 | + expect(run.otherInstall?.exit, run.otherInstall?.output).toBe(0); |
| 424 | + expect(run.granted?.status, JSON.stringify(run.granted?.body)).toBe(201); |
| 425 | + expect(rowsOf(run.before!.sets).map((r) => [r?.name, r?.managed_by, r?.package_id])) |
| 426 | + .toEqual([[PERMISSION_SET, 'package', APP_ID]]); |
| 427 | + expect(run.uninstall?.status, JSON.stringify(run.uninstall?.body)).toBe(200); |
| 428 | + expect(rowsOf(run.after!.sets), JSON.stringify(run.after!.sets.body)).toEqual([]); |
| 429 | + expect(rowsOf(run.after!.bindings), JSON.stringify(run.after!.bindings.body)).toEqual([]); |
| 430 | + }); |
| 431 | + |
| 432 | + it('the grant stays revoked through the other install and the restart, and the package object is gone', () => { |
| 433 | + const run = runs.reseed; |
| 434 | + for (const answer of [run.afterOtherInstall!.sets, run.afterOtherInstall!.bindings, run.restarted!.sets, run.restarted!.bindings]) { |
| 435 | + expect(answer.status, JSON.stringify(answer.body)).toBe(200); |
| 436 | + } |
| 437 | + expect(rowsOf(run.afterOtherInstall!.bindings), JSON.stringify(run.afterOtherInstall!.bindings.body)).toEqual([]); |
| 438 | + expect(rowsOf(run.restarted!.bindings), JSON.stringify(run.restarted!.bindings.body)).toEqual([]); |
| 439 | + expect(run.object?.status, JSON.stringify(run.object?.body)).toBe(404); |
| 440 | + }); |
| 441 | + |
| 442 | + it.fails('KNOWN-BROKEN: the other package\'s hot install re-projects the uninstalled package\'s set (promote to a plain assertion once fixed)', () => { |
| 443 | + const run = runs.reseed; |
| 444 | + expect(rowsOf(run.afterOtherInstall!.sets), JSON.stringify(run.afterOtherInstall!.sets.body)).toEqual([]); |
| 445 | + }); |
| 446 | + |
| 447 | + it.fails('KNOWN-BROKEN: that re-projected set survives the restart as an orphan row (promote to a plain assertion once fixed)', () => { |
| 448 | + const run = runs.reseed; |
| 449 | + expect(rowsOf(run.restarted!.sets), JSON.stringify(run.restarted!.sets.body)).toEqual([]); |
| 450 | + }); |
| 451 | + }); |
361 | 452 | }); |
0 commit comments