|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #4818 — `os serve` must tell an operator WHICH of two different things went |
| 5 | + * wrong with the enterprise multi-org runtime, over the REAL CLI process. |
| 6 | + * |
| 7 | + * The defect: `importFromHost('@objectstack/organizations')` and |
| 8 | + * `kernel.use(new mod.OrganizationsPlugin())` shared a single `try`, so an |
| 9 | + * error the plugin threw while CONSTRUCTING or MOUNTING was reported as |
| 10 | + * "@objectstack/organizations could not be loaded" — i.e. as an ABSENT package |
| 11 | + * — offered `OS_ALLOW_DEGRADED_TENANCY=1` as the way out, and, when that was |
| 12 | + * already set, was downgraded to a warning and the boot continued. Two facts |
| 13 | + * with opposite remedies had one diagnosis: |
| 14 | + * |
| 15 | + * | fact | remedy | OS_ALLOW_DEGRADED_TENANCY | |
| 16 | + * |---------------------|-------------------------|---------------------------| |
| 17 | + * | package absent | install it / go single | applies (operator accepts | |
| 18 | + * | | | the missing capability) | |
| 19 | + * | plugin refused | whatever it reported | does NOT apply | |
| 20 | + * |
| 21 | + * The fix classifies by WHICH STAGE THREW — never by the error's shape, since |
| 22 | + * the package is loaded through `importFromHost` and the CLI may hold a |
| 23 | + * different module instance than the plugin does, and since the framework must |
| 24 | + * not encode any of the plugin's private refusal semantics. |
| 25 | + * |
| 26 | + * WHY THIS FILE SPAWNS THE CLI (same reason as its neighbour |
| 27 | + * `serve-organizations-host-resolution.e2e.test.ts`): every other test of the |
| 28 | + * walled postures hands the plugin in as `extraPlugins` or mocks the module, |
| 29 | + * which bypasses the CLI's own load/mount sequence — the only thing under test |
| 30 | + * here. The fixtures stand in for the closed-source enterprise package: one app |
| 31 | + * simply does not ship it, another ships a version whose plugin throws on |
| 32 | + * construction (the shape cloud#1020 gave its license gate). What is asserted |
| 33 | + * is the CLI's CLASSIFICATION and its message, not any enterprise semantics. |
| 34 | + */ |
| 35 | + |
| 36 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 37 | +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; |
| 38 | +import { tmpdir } from 'node:os'; |
| 39 | +import { join } from 'node:path'; |
| 40 | +import { runServe, randomPort } from './helpers/serve-process.js'; |
| 41 | + |
| 42 | +const CONFIG = ` |
| 43 | +export default { |
| 44 | + manifest: { |
| 45 | + id: 'com.example.orgmount', |
| 46 | + namespace: 'orgmount', |
| 47 | + version: '1.0.0', |
| 48 | + type: 'app', |
| 49 | + name: 'Organizations Mount-Failure Fixture', |
| 50 | + }, |
| 51 | + objects: [{ |
| 52 | + name: 'orgmount_task', |
| 53 | + label: 'Task', |
| 54 | + sharingModel: 'private', |
| 55 | + fields: { |
| 56 | + title: { type: 'text', label: 'Title' }, |
| 57 | + }, |
| 58 | + }], |
| 59 | +}; |
| 60 | +`; |
| 61 | + |
| 62 | +/** What the refusing fixture throws — asserted verbatim below. */ |
| 63 | +const REFUSAL_MESSAGE = 'organizations runtime declined to mount in this deployment (fixture)'; |
| 64 | +const REFUSAL_CODE = 'FIXTURE_MOUNT_REFUSED'; |
| 65 | + |
| 66 | +/** |
| 67 | + * A resolvable `@objectstack/organizations` whose plugin refuses at CONSTRUCTION |
| 68 | + * — the shape cloud#1020 gave its enterprise entitlement gate. Note the error |
| 69 | + * carries a structured `code`: the CLI prints it generically and must never |
| 70 | + * branch on its value. |
| 71 | + */ |
| 72 | +const REFUSING_ORGANIZATIONS = ` |
| 73 | +export class OrganizationsPlugin { |
| 74 | + constructor() { |
| 75 | + const err = new Error(${JSON.stringify(REFUSAL_MESSAGE)}); |
| 76 | + err.code = ${JSON.stringify(REFUSAL_CODE)}; |
| 77 | + throw err; |
| 78 | + } |
| 79 | +} |
| 80 | +`; |
| 81 | + |
| 82 | +/** App that does NOT ship the package — the import stage fails. */ |
| 83 | +let appAbsent: string; |
| 84 | +/** App that ships a package whose plugin refuses — the mount stage fails. */ |
| 85 | +let appRefusing: string; |
| 86 | + |
| 87 | +function writeApp(prefix: string, organizationsSource: string | null): string { |
| 88 | + const dir = mkdtempSync(join(tmpdir(), prefix)); |
| 89 | + writeFileSync(join(dir, 'objectstack.config.ts'), CONFIG, 'utf8'); |
| 90 | + writeFileSync( |
| 91 | + join(dir, 'package.json'), |
| 92 | + JSON.stringify( |
| 93 | + { |
| 94 | + name: 'orgmount-fixture', |
| 95 | + private: true, |
| 96 | + type: 'module', |
| 97 | + ...(organizationsSource ? { dependencies: { '@objectstack/organizations': '*' } } : {}), |
| 98 | + }, |
| 99 | + null, |
| 100 | + 2, |
| 101 | + ), |
| 102 | + 'utf8', |
| 103 | + ); |
| 104 | + if (organizationsSource) { |
| 105 | + const pkgDir = join(dir, 'node_modules', '@objectstack', 'organizations'); |
| 106 | + mkdirSync(pkgDir, { recursive: true }); |
| 107 | + writeFileSync( |
| 108 | + join(pkgDir, 'package.json'), |
| 109 | + JSON.stringify({ |
| 110 | + name: '@objectstack/organizations', |
| 111 | + version: '0.0.0-fixture', |
| 112 | + type: 'module', |
| 113 | + main: 'index.js', |
| 114 | + }), |
| 115 | + 'utf8', |
| 116 | + ); |
| 117 | + writeFileSync(join(pkgDir, 'index.js'), organizationsSource, 'utf8'); |
| 118 | + } |
| 119 | + return dir; |
| 120 | +} |
| 121 | + |
| 122 | +beforeAll(() => { |
| 123 | + appAbsent = writeApp('os-org-mount-absent-', null); |
| 124 | + appRefusing = writeApp('os-org-mount-refusing-', REFUSING_ORGANIZATIONS); |
| 125 | +}); |
| 126 | + |
| 127 | +afterAll(() => { |
| 128 | + for (const dir of [appAbsent, appRefusing]) { |
| 129 | + if (dir) rmSync(dir, { recursive: true, force: true }); |
| 130 | + } |
| 131 | +}); |
| 132 | + |
| 133 | +/** Auth must be wired for the organizations block to be reached at all. */ |
| 134 | +const SERVE_ENV = { |
| 135 | + OS_AUTH_SECRET: 'org-mount-failure-e2e-secret', |
| 136 | + OS_TENANCY_POSTURE: 'isolated', |
| 137 | +}; |
| 138 | + |
| 139 | +const BANNER = 'Press Ctrl+C to stop'; |
| 140 | +const BANNER_RE = /Press Ctrl\+C to stop/; |
| 141 | + |
| 142 | +function seenOf(stdout: string, stderr: string): string { |
| 143 | + return `\n--- stdout ---\n${stdout.slice(-4000)}\n--- stderr ---\n${stderr.slice(-4000)}`; |
| 144 | +} |
| 145 | + |
| 146 | +describe('os serve — organizations import stage vs mount stage (#4818)', () => { |
| 147 | + describe('import stage fails — the package is ABSENT (behaviour must be unchanged)', () => { |
| 148 | + it( |
| 149 | + 'refuses to boot with the ADR-0093 D5 "could not be loaded" diagnosis', |
| 150 | + async () => { |
| 151 | + const port = randomPort(); |
| 152 | + const { stdout, stderr } = await runServe(appAbsent, ['--port', port], { |
| 153 | + waitFor: BANNER_RE, |
| 154 | + env: { ...SERVE_ENV, OS_ALLOW_DEGRADED_TENANCY: undefined }, |
| 155 | + timeoutMs: 240_000, |
| 156 | + }); |
| 157 | + const seen = seenOf(stdout, stderr); |
| 158 | + |
| 159 | + expect(stderr, `the D5 fail-fast did not fire${seen}`).toMatch( |
| 160 | + /FATAL: tenancy posture 'isolated' was requested/, |
| 161 | + ); |
| 162 | + // This wording is CORRECT here and must survive the stage split: the |
| 163 | + // package really is not on this machine. |
| 164 | + expect(stderr, `the absent-package diagnosis was lost${seen}`).toMatch(/could not be loaded/); |
| 165 | + // …and the escape hatch is still offered on the path it belongs to. |
| 166 | + expect(stderr).toMatch(/set OS_ALLOW_DEGRADED_TENANCY=1 to boot/); |
| 167 | + expect(stdout, `serve served traffic without the wall${seen}`).not.toContain(BANNER); |
| 168 | + }, |
| 169 | + 300_000, |
| 170 | + ); |
| 171 | + |
| 172 | + it( |
| 173 | + 'boots degraded when the operator explicitly sets OS_ALLOW_DEGRADED_TENANCY=1', |
| 174 | + async () => { |
| 175 | + // The escape hatch keeps its one legitimate meaning: "the capability is |
| 176 | + // absent and I accept running without it". Pinned so the stage split |
| 177 | + // cannot regress it. |
| 178 | + const port = randomPort(); |
| 179 | + const { stdout, stderr } = await runServe(appAbsent, ['--port', port], { |
| 180 | + waitFor: BANNER_RE, |
| 181 | + env: { ...SERVE_ENV, OS_ALLOW_DEGRADED_TENANCY: '1' }, |
| 182 | + timeoutMs: 240_000, |
| 183 | + }); |
| 184 | + const seen = seenOf(stdout, stderr); |
| 185 | + |
| 186 | + expect(stdout, `serve never reached its banner${seen}`).toContain(BANNER); |
| 187 | + expect(stderr, `the degraded boot was not branded${seen}`).toMatch(/DEGRADED TENANCY/); |
| 188 | + expect(stderr, `the degraded opt-in still fired the fail-fast${seen}`).not.toMatch(/✖ FATAL/); |
| 189 | + }, |
| 190 | + 300_000, |
| 191 | + ); |
| 192 | + }); |
| 193 | + |
| 194 | + describe('mount stage fails — the package is PRESENT and its plugin refused', () => { |
| 195 | + it( |
| 196 | + "surfaces the plugin's own error verbatim and exits, without the absent-package wording", |
| 197 | + async () => { |
| 198 | + const port = randomPort(); |
| 199 | + const { stdout, stderr } = await runServe(appRefusing, ['--port', port], { |
| 200 | + waitFor: BANNER_RE, |
| 201 | + env: { ...SERVE_ENV, OS_ALLOW_DEGRADED_TENANCY: undefined }, |
| 202 | + timeoutMs: 240_000, |
| 203 | + }); |
| 204 | + const seen = seenOf(stdout, stderr); |
| 205 | + |
| 206 | + // D5's posture is unchanged: isolation was requested and cannot be |
| 207 | + // delivered, so the boot still dies. |
| 208 | + expect(stdout, `serve served traffic without the wall${seen}`).not.toContain(BANNER); |
| 209 | + expect(stderr, `no fail-fast fired for a refusing plugin${seen}`).toMatch(/✖ FATAL/); |
| 210 | + |
| 211 | + // The crux: the operator is told the package IS there, and reads the |
| 212 | + // plugin's own words — not a fabricated cause, and not a module |
| 213 | + // resolution wild goose chase. |
| 214 | + expect(stderr, `the mount refusal was misreported as an absent package${seen}`).not.toMatch( |
| 215 | + /could not be loaded/, |
| 216 | + ); |
| 217 | + expect(stderr, `the plugin's message was not surfaced verbatim${seen}`).toContain(REFUSAL_MESSAGE); |
| 218 | + expect(stderr, `the plugin's structured code was not surfaced${seen}`).toContain(REFUSAL_CODE); |
| 219 | + expect(stderr, `the message does not say the package was found${seen}`).toMatch( |
| 220 | + /WAS found and loaded/, |
| 221 | + ); |
| 222 | + // Honest remaining alternative, and no dead-end suggestion. |
| 223 | + expect(stderr).toMatch(/OS_TENANCY_POSTURE=single/); |
| 224 | + expect(stderr, `the escape hatch was offered on a path it cannot fix${seen}`).toMatch( |
| 225 | + /OS_ALLOW_DEGRADED_TENANCY does NOT apply/, |
| 226 | + ); |
| 227 | + }, |
| 228 | + 300_000, |
| 229 | + ); |
| 230 | + |
| 231 | + it( |
| 232 | + 'still exits 1 when OS_ALLOW_DEGRADED_TENANCY=1 is set — the hatch must not swallow a refusal', |
| 233 | + async () => { |
| 234 | + // THE issue. Before the fix this booted with a warning: an env var |
| 235 | + // silently overrode whatever gate the plugin was enforcing. |
| 236 | + const port = randomPort(); |
| 237 | + const { stdout, stderr } = await runServe(appRefusing, ['--port', port], { |
| 238 | + waitFor: BANNER_RE, |
| 239 | + env: { ...SERVE_ENV, OS_ALLOW_DEGRADED_TENANCY: '1' }, |
| 240 | + timeoutMs: 240_000, |
| 241 | + }); |
| 242 | + const seen = seenOf(stdout, stderr); |
| 243 | + |
| 244 | + expect( |
| 245 | + stdout, |
| 246 | + `OS_ALLOW_DEGRADED_TENANCY swallowed a plugin refusal and served traffic${seen}`, |
| 247 | + ).not.toContain(BANNER); |
| 248 | + expect(stderr, `the refusal did not fail fast under the escape hatch${seen}`).toMatch(/✖ FATAL/); |
| 249 | + expect(stderr, `the refusal was downgraded to a degraded-boot warning${seen}`).not.toMatch( |
| 250 | + /DEGRADED TENANCY \(OS_ALLOW_DEGRADED_TENANCY=1\)/, |
| 251 | + ); |
| 252 | + expect(stderr, `the plugin's message was not surfaced verbatim${seen}`).toContain(REFUSAL_MESSAGE); |
| 253 | + }, |
| 254 | + 300_000, |
| 255 | + ); |
| 256 | + }); |
| 257 | +}); |
0 commit comments