|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// #5301 — DevPlugin enforces ADR-0093 D5: a stack that REQUESTED the |
| 4 | +// organization wall must not serve traffic without it. |
| 5 | +// |
| 6 | +// Before this, `objectstack serve` and `DevPlugin` gave OPPOSITE answers to one |
| 7 | +// fact on one machine. Walled posture requested, enterprise |
| 8 | +// `@objectstack/organizations` absent: |
| 9 | +// |
| 10 | +// objectstack serve → refuses to boot (unless OS_ALLOW_DEGRADED_TENANCY=1) |
| 11 | +// DevPlugin → one logger.warn, then boots and serves UNWALLED, |
| 12 | +// with nobody having consented to the degradation |
| 13 | +// |
| 14 | +// D5 is a property of the DEPLOYMENT, not of one entrypoint, so the dev |
| 15 | +// assembly path owes the same answer. #5262 made this MORE reachable, not less: |
| 16 | +// before it, a dev stack setting only `OS_TENANCY_POSTURE` never entered the |
| 17 | +// branch at all, so the warn-only path was dead code for the documented |
| 18 | +// configuration. After it, that stack enters, fails to load, and took the |
| 19 | +// fail-open path — which is what this file now forbids. |
| 20 | +// |
| 21 | +// ── What is observed, and why it is honest ────────────────────────────────── |
| 22 | +// `@objectstack/organizations` is a cloud-private enterprise package genuinely |
| 23 | +// absent from this workspace, so the dynamic import genuinely fails and the |
| 24 | +// real stage-1 catch runs — no stubbing of the thing under test. That makes |
| 25 | +// this file the faithful witness for the ABSENT-package half of #4818's split. |
| 26 | +// The PRESENT-but-refusing half needs the package to resolve, so it lives in |
| 27 | +// `dev-plugin-tenancy-mount-refusal.test.ts`, which mocks it. |
| 28 | + |
| 29 | +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
| 30 | + |
| 31 | +// #3060 — same treatment as the sibling suites: init() dynamically imports ~10 |
| 32 | +// real workspace packages, whose vite transforms alone can blow the test |
| 33 | +// timeout under a parallel `pnpm test`. Each factory throws the shape an absent |
| 34 | +// package produces, so the graceful-degradation branches run for real with zero |
| 35 | +// module resolution on the hot path. `@objectstack/organizations` is |
| 36 | +// deliberately NOT listed: it is really absent, and its real failure is the |
| 37 | +// signal this file reads. |
| 38 | +vi.mock('@objectstack/objectql', () => { throw Object.assign(new Error("Cannot find package '@objectstack/objectql'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); |
| 39 | +vi.mock('@objectstack/runtime', () => { throw Object.assign(new Error("Cannot find package '@objectstack/runtime'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); |
| 40 | +vi.mock('@objectstack/driver-memory', () => { throw Object.assign(new Error("Cannot find package '@objectstack/driver-memory'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); |
| 41 | +vi.mock('@objectstack/service-i18n', () => { throw Object.assign(new Error("Cannot find package '@objectstack/service-i18n'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); |
| 42 | +vi.mock('@objectstack/service-storage', () => { throw Object.assign(new Error("Cannot find package '@objectstack/service-storage'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); |
| 43 | +vi.mock('@objectstack/service-realtime', () => { throw Object.assign(new Error("Cannot find package '@objectstack/service-realtime'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); |
| 44 | +vi.mock('@objectstack/plugin-auth', () => { throw Object.assign(new Error("Cannot find package '@objectstack/plugin-auth'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); |
| 45 | +vi.mock('@objectstack/plugin-security', () => { throw Object.assign(new Error("Cannot find package '@objectstack/plugin-security'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); |
| 46 | +vi.mock('@objectstack/plugin-hono-server', () => { throw Object.assign(new Error("Cannot find package '@objectstack/plugin-hono-server'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); |
| 47 | +vi.mock('@objectstack/rest', () => { throw Object.assign(new Error("Cannot find package '@objectstack/rest'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); |
| 48 | +vi.mock('@objectstack/setup', () => { throw Object.assign(new Error("Cannot find package '@objectstack/setup'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); |
| 49 | +vi.mock('@objectstack/account', () => { throw Object.assign(new Error("Cannot find package '@objectstack/account'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); |
| 50 | + |
| 51 | +import { DevPlugin } from './dev-plugin'; |
| 52 | + |
| 53 | +const OLD_POSTURE = process.env.OS_TENANCY_POSTURE; |
| 54 | +const OLD_LEGACY = process.env.OS_MULTI_ORG_ENABLED; |
| 55 | +const OLD_NODE_ENV = process.env.NODE_ENV; |
| 56 | +const OLD_DEGRADED = process.env.OS_ALLOW_DEGRADED_TENANCY; |
| 57 | + |
| 58 | +const makeCtx = () => { |
| 59 | + const registered = new Map<string, unknown>(); |
| 60 | + return { |
| 61 | + logger: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, |
| 62 | + getService: vi.fn((name: string) => { |
| 63 | + if (registered.has(name)) return registered.get(name); |
| 64 | + throw new Error('not found'); |
| 65 | + }), |
| 66 | + getServices: vi.fn(() => new Map()), |
| 67 | + registerService: vi.fn((name: string, svc: unknown) => registered.set(name, svc)), |
| 68 | + hook: vi.fn(), |
| 69 | + trigger: vi.fn(), |
| 70 | + getKernel: vi.fn(), |
| 71 | + } as any; |
| 72 | +}; |
| 73 | + |
| 74 | +/** Boot DevPlugin under a tenancy configuration; never swallows. */ |
| 75 | +const init = async (env: { posture?: string; legacy?: string; degraded?: string }) => { |
| 76 | + if (env.posture === undefined) delete process.env.OS_TENANCY_POSTURE; |
| 77 | + else process.env.OS_TENANCY_POSTURE = env.posture; |
| 78 | + if (env.legacy === undefined) delete process.env.OS_MULTI_ORG_ENABLED; |
| 79 | + else process.env.OS_MULTI_ORG_ENABLED = env.legacy; |
| 80 | + if (env.degraded === undefined) delete process.env.OS_ALLOW_DEGRADED_TENANCY; |
| 81 | + else process.env.OS_ALLOW_DEGRADED_TENANCY = env.degraded; |
| 82 | + |
| 83 | + const ctx = makeCtx(); |
| 84 | + await new DevPlugin({ seedAdminUser: false }).init(ctx); |
| 85 | + const lines = [ |
| 86 | + ...ctx.logger.warn.mock.calls, |
| 87 | + ...ctx.logger.info.mock.calls, |
| 88 | + ...ctx.logger.error.mock.calls, |
| 89 | + ].map((c: unknown[]) => String(c[0])); |
| 90 | + return { ctx, lines }; |
| 91 | +}; |
| 92 | + |
| 93 | +beforeEach(() => { |
| 94 | + delete process.env.OS_TENANCY_POSTURE; |
| 95 | + delete process.env.OS_MULTI_ORG_ENABLED; |
| 96 | + delete process.env.OS_ALLOW_DEGRADED_TENANCY; |
| 97 | + process.env.NODE_ENV = 'development'; |
| 98 | +}); |
| 99 | +afterEach(() => { |
| 100 | + if (OLD_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE; |
| 101 | + else process.env.OS_TENANCY_POSTURE = OLD_POSTURE; |
| 102 | + if (OLD_LEGACY === undefined) delete process.env.OS_MULTI_ORG_ENABLED; |
| 103 | + else process.env.OS_MULTI_ORG_ENABLED = OLD_LEGACY; |
| 104 | + if (OLD_NODE_ENV === undefined) delete process.env.NODE_ENV; |
| 105 | + else process.env.NODE_ENV = OLD_NODE_ENV; |
| 106 | + if (OLD_DEGRADED === undefined) delete process.env.OS_ALLOW_DEGRADED_TENANCY; |
| 107 | + else process.env.OS_ALLOW_DEGRADED_TENANCY = OLD_DEGRADED; |
| 108 | + vi.restoreAllMocks(); |
| 109 | +}); |
| 110 | + |
| 111 | +describe('#5301 — stage 1 (package ABSENT): D5 fail-fast unless the operator opted in', () => { |
| 112 | + it('walled posture + absent enterprise package + no hatch → REFUSES to init', async () => { |
| 113 | + // THE regression. This exact configuration used to emit one warning and |
| 114 | + // boot on, serving traffic with the organization wall inactive. |
| 115 | + await expect(init({ posture: 'isolated' })).rejects.toThrow(/ADR-0093 D5/); |
| 116 | + }); |
| 117 | + |
| 118 | + it('`group` is walled too — it refuses on the same terms as `isolated`', async () => { |
| 119 | + // `group` has no legacy-boolean spelling at all, so it is the posture most |
| 120 | + // likely to reach here by the documented configuration alone. |
| 121 | + await expect(init({ posture: 'group' })).rejects.toThrow(/ADR-0093 D5/); |
| 122 | + }); |
| 123 | + |
| 124 | + it('the legacy boolean requests the wall too, and is refused the same way', async () => { |
| 125 | + await expect(init({ legacy: 'true' })).rejects.toThrow(/ADR-0093 D5/); |
| 126 | + }); |
| 127 | + |
| 128 | + it('the refusal names the requested posture and every way out', async () => { |
| 129 | + // A refusal that does not say how to get past it just moves the operator's |
| 130 | + // problem from "no wall" to "no boot and no idea why". |
| 131 | + const err = await init({ posture: 'isolated' }).catch((e: Error) => e); |
| 132 | + const msg = (err as Error).message; |
| 133 | + expect(msg).toContain("posture 'isolated'"); |
| 134 | + expect(msg).toContain('@objectstack/organizations'); |
| 135 | + expect(msg).toContain('OS_TENANCY_POSTURE=single'); |
| 136 | + expect(msg).toContain('OS_ALLOW_DEGRADED_TENANCY=1'); |
| 137 | + // The framework's own words about WHY, not just what: D5 is the authority. |
| 138 | + expect(msg).toContain('must not serve traffic without it'); |
| 139 | + }); |
| 140 | + |
| 141 | + it('throws rather than exiting the process — DevPlugin is a library', async () => { |
| 142 | + // The distinction #5301 turns on. serve.ts must `process.exit(1)` because |
| 143 | + // its guard sits inside a broad AuthPlugin `try` that swallows throws. |
| 144 | + // DevPlugin has no claim on the host process: embedders (tests, scripts, a |
| 145 | + // parent app) are entitled to catch this, and the boot chain does not |
| 146 | + // swallow it — `kernel.use()` only registers, `initPluginWithTimeout` does |
| 147 | + // not catch, `bootstrap()` rethrows. Consistent with the same file's |
| 148 | + // `assertNotProduction()`. That this assertion can run AT ALL is the proof: |
| 149 | + // a `process.exit(1)` would take the test runner down with it. |
| 150 | + const exit = vi.spyOn(process, 'exit').mockImplementation(((): never => { |
| 151 | + throw new Error('process.exit must not be called from a library plugin'); |
| 152 | + }) as any); |
| 153 | + await expect(init({ posture: 'isolated' })).rejects.toThrow(/ADR-0093 D5/); |
| 154 | + expect(exit).not.toHaveBeenCalled(); |
| 155 | + }); |
| 156 | + |
| 157 | + it('with OS_ALLOW_DEGRADED_TENANCY=1 it boots degraded, and says so', async () => { |
| 158 | + // The hatch's whole meaning: "the capability is ABSENT and I accept the |
| 159 | + // degradation". Boot continues — but branded, never silent. |
| 160 | + const run = await init({ posture: 'isolated', degraded: '1' }); |
| 161 | + const warning = run.lines.find((l) => l.includes('@objectstack/organizations')); |
| 162 | + expect(warning).toBeDefined(); |
| 163 | + expect(warning).toContain('DEGRADED TENANCY'); |
| 164 | + expect(warning).toContain("posture 'isolated'"); |
| 165 | + // The line must stay honest about what is NOT being enforced. |
| 166 | + expect(warning).toContain('organization wall INACTIVE'); |
| 167 | + expect(warning).toContain('ADR-0093 D5'); |
| 168 | + }); |
| 169 | + |
| 170 | + it('the hatch shares the OS_ALLOW_* family truthiness, exactly as serve.ts reads it', async () => { |
| 171 | + // `resolveAllowDegradedTenancy()` — the SAME resolver serve.ts calls, so the |
| 172 | + // two entrypoints can never drift on what "opted in" means. A hand-rolled |
| 173 | + // `=== '1'` here would have made `true`/`on`/`yes` work for serve and fail |
| 174 | + // for dev, on one machine, from one .env file. |
| 175 | + for (const truthy of ['1', 'true', 'on', 'yes', 'YES', ' True ']) { |
| 176 | + // Resolving AT ALL is the assertion: it means init() ran to completion |
| 177 | + // instead of refusing. (The helper resolves with its captured log lines.) |
| 178 | + await expect(init({ posture: 'isolated', degraded: truthy })).resolves.toBeTruthy(); |
| 179 | + } |
| 180 | + for (const falsy of ['0', 'false', 'off', 'no', '']) { |
| 181 | + await expect(init({ posture: 'isolated', degraded: falsy })).rejects.toThrow(/ADR-0093 D5/); |
| 182 | + } |
| 183 | + }); |
| 184 | +}); |
| 185 | + |
| 186 | +describe('#5301 — unwalled postures are untouched', () => { |
| 187 | + it('single-org dev stacks never enter the branch, and never refuse', async () => { |
| 188 | + // The guard must not become a tax on the default configuration: a stack |
| 189 | + // that never asked for the wall is not degraded by not having one. |
| 190 | + for (const env of [{ posture: 'single' }, { legacy: 'false' }, {}]) { |
| 191 | + const run = await init(env); |
| 192 | + expect(run.lines.some((l) => l.includes('@objectstack/organizations'))).toBe(false); |
| 193 | + expect(run.lines.some((l) => l.includes('ADR-0093 D5'))).toBe(false); |
| 194 | + } |
| 195 | + }); |
| 196 | + |
| 197 | + it('a single posture does not need the hatch to boot', async () => { |
| 198 | + await expect(init({ posture: 'single' })).resolves.toBeTruthy(); |
| 199 | + }); |
| 200 | +}); |
0 commit comments