|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #10243 — the BLAST-RADIUS half of the toggle card, measured over HTTP. |
| 5 | + * |
| 6 | + * ## ⛔ What this file is, and what it deliberately is not |
| 7 | + * |
| 8 | + * It **records a measurement**. It does **not** rule. Whether |
| 9 | + * `POST /automation/:name/toggle` belongs in the `manage_metadata` write set is |
| 10 | + * a product and security decision for the maintainer, and nothing here argues |
| 11 | + * either way — no severity, no recommendation. When the ruling lands, this file |
| 12 | + * is one of the two places it lands (the other is the ungated-execution audit |
| 13 | + * block in `packages/runtime/src/domains/automation-write-capability-gate.test.ts`): |
| 14 | + * a ruling that toggle IS an authoring write flips these expectations to a 403, |
| 15 | + * and the flip is the point — an unrecorded verdict cannot be revisited. |
| 16 | + * |
| 17 | + * ## The question, and why only half of it was open |
| 18 | + * |
| 19 | + * #10145 gated the automation DEFINITION writes (`POST /automation`, |
| 20 | + * `PUT /automation/:name`, `DELETE /automation/:name`) on `manage_metadata` and |
| 21 | + * deliberately left `toggle` ungated, on the rule that authoring and executing |
| 22 | + * are different questions. Two separable facts follow from that, and only the |
| 23 | + * second was ever open: |
| 24 | + * |
| 25 | + * 1. `toggle` is reachable by any authenticated caller with no authoring |
| 26 | + * capability — MEASURED and pinned by #10145's audit block. |
| 27 | + * 2. flow ENABLEMENT is environment-scoped, so one tenant's toggle reaches |
| 28 | + * every organization — asserted from the scoping #10145 measured for flow |
| 29 | + * DEFINITIONS, and never reproduced for enablement itself. |
| 30 | + * |
| 31 | + * This file is (2): toggle as tenant A, read the enabled state back as tenant B |
| 32 | + * and as the platform admin — the same three principals and the same read-back |
| 33 | + * table #10145's report used for definitions. |
| 34 | + * |
| 35 | + * ## ⚠️ The vacuity trap this harness has to stay clear of, stated up front |
| 36 | + * |
| 37 | + * `multiTenant: 'posture-only'` activates the tenancy POSTURE and no row wall |
| 38 | + * (see `BootOptions.multiTenant`) — the enterprise `@objectstack/organizations` |
| 39 | + * runtime is cloud-private and genuinely absent from this workspace. A fixture |
| 40 | + * that booted this way and asserted *isolation* would assert nothing and pass. |
| 41 | + * The mirror image is just as real and is the trap for THIS file: in a stack |
| 42 | + * with no wall, "tenant B saw tenant A's write" is true of everything, and |
| 43 | + * would prove nothing about a walled deployment. |
| 44 | + * |
| 45 | + * What keeps the measurement honest is that the bit under test never reaches |
| 46 | + * the plane a wall operates on. An organization wall scopes ROWS. The enabled |
| 47 | + * bit is not a row: `toggleFlow(name, enabled)` writes the automation engine's |
| 48 | + * in-process `flowEnabled` map, keyed by flow name and nothing else, and |
| 49 | + * `getFlowRuntimeStates()` reads that same map with no caller, no organization |
| 50 | + * and no argument at all. `it('mutates ENGINE state, not the persisted |
| 51 | + * definition')` below measures exactly that discriminator over HTTP — after the |
| 52 | + * toggle the flow's persisted `status` is still `active` while its runtime |
| 53 | + * `enabled` is `false` — and it is the leg that would fail, loudly, if |
| 54 | + * enablement ever became org-stamped state that a wall could scope. Until it |
| 55 | + * does, no wall has anything to scope, which is why the result does not depend |
| 56 | + * on the stand-in. |
| 57 | + * |
| 58 | + * ## The harness |
| 59 | + * |
| 60 | + * - app: `@objectstack/example-crm`, whose shipped `crm_convert_lead_wizard` |
| 61 | + * stands in for #10145's HotCRM `lead_auto_assignment`. |
| 62 | + * - boot: `bootStack(crm, { automation: true, multiTenant: 'posture-only' })` |
| 63 | + * — a real, non-degraded `isolated` posture (the deployment shape #10145 |
| 64 | + * measured, `OS_TENANCY_POSTURE=isolated`), with the automation service |
| 65 | + * registered so the routes resolve an engine instead of 501. |
| 66 | + * - principals: platform admin (seeded first user), and two org owners who |
| 67 | + * each create their OWN organization over |
| 68 | + * `POST /auth/organization/create` — so the two tenants carry genuinely |
| 69 | + * different `activeOrganizationId`s, which the first test asserts rather |
| 70 | + * than assumes. |
| 71 | + */ |
| 72 | + |
| 73 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 74 | +import crmStack from '@objectstack/example-crm'; |
| 75 | +import { bootStack, type VerifyStack } from '@objectstack/verify'; |
| 76 | + |
| 77 | +/** The CRM app's own shipped flow — a real definition, not one this test injects. */ |
| 78 | +const FLOW = 'crm_convert_lead_wizard'; |
| 79 | + |
| 80 | +interface RuntimeFlowState { |
| 81 | + name: string; |
| 82 | + enabled: boolean; |
| 83 | + bound: boolean; |
| 84 | + status?: string; |
| 85 | +} |
| 86 | + |
| 87 | +interface AutomationEngineShape { |
| 88 | + getFlowRuntimeStates(): RuntimeFlowState[]; |
| 89 | +} |
| 90 | + |
| 91 | +/** |
| 92 | + * Read one flow's runtime state out of `GET /automation/_status`. |
| 93 | + * |
| 94 | + * Pulls the entry out explicitly and fails on its absence rather than folding a |
| 95 | + * missing row into `enabled: false` — a `?? false` here would report "the route |
| 96 | + * stopped listing this flow" as "the flow is disabled", which is the one |
| 97 | + * confusion this whole file exists to avoid. |
| 98 | + */ |
| 99 | +async function readEnabled(stack: VerifyStack, token: string): Promise<RuntimeFlowState> { |
| 100 | + const res = await stack.apiAs(token, 'GET', '/automation/_status'); |
| 101 | + const text = await res.clone().text(); |
| 102 | + expect(res.status, `/automation/_status returned ${res.status}: ${text}`).toBe(200); |
| 103 | + const body = (await res.json()) as { data?: { flows?: RuntimeFlowState[] } }; |
| 104 | + const flows = body.data?.flows; |
| 105 | + expect(flows, `no flows array in /automation/_status: ${text}`).toBeDefined(); |
| 106 | + const entry = flows!.find((f) => f.name === FLOW); |
| 107 | + expect(entry, `flow '${FLOW}' absent from /automation/_status: ${text}`).toBeDefined(); |
| 108 | + return entry!; |
| 109 | +} |
| 110 | + |
| 111 | +describe('#10243 — cross-organization reach of POST /automation/:name/toggle', () => { |
| 112 | + let stack: VerifyStack; |
| 113 | + /** Platform admin — the seeded first user. #10145's `founder`. */ |
| 114 | + let adminToken: string; |
| 115 | + /** Tenant A org owner — the actor. #10145's `northwind`. */ |
| 116 | + let tenantAToken: string; |
| 117 | + /** Tenant B org owner — an unrelated tenant. #10145's `contoso`. */ |
| 118 | + let tenantBToken: string; |
| 119 | + let orgAId: string; |
| 120 | + let orgBId: string; |
| 121 | + |
| 122 | + beforeAll(async () => { |
| 123 | + stack = await bootStack(crmStack as never, { automation: true, multiTenant: 'posture-only' }); |
| 124 | + adminToken = await stack.signIn(); |
| 125 | + |
| 126 | + tenantAToken = await stack.signUp('tenant-a-owner@issue10243.test'); |
| 127 | + tenantBToken = await stack.signUp('tenant-b-owner@issue10243.test'); |
| 128 | + |
| 129 | + const createA = await stack.apiAs(tenantAToken, 'POST', '/auth/organization/create', { |
| 130 | + name: 'Tenant A', slug: 'tenant-a-10243', |
| 131 | + }); |
| 132 | + expect(createA.status, `org A create: ${await createA.clone().text()}`).toBe(200); |
| 133 | + orgAId = ((await createA.json()) as { id: string }).id; |
| 134 | + |
| 135 | + const createB = await stack.apiAs(tenantBToken, 'POST', '/auth/organization/create', { |
| 136 | + name: 'Tenant B', slug: 'tenant-b-10243', |
| 137 | + }); |
| 138 | + expect(createB.status, `org B create: ${await createB.clone().text()}`).toBe(200); |
| 139 | + orgBId = ((await createB.json()) as { id: string }).id; |
| 140 | + |
| 141 | + // better-auth binds the creator to the new org, but the ACTIVE org is what |
| 142 | + // rides the session into the execution context — set it explicitly so the |
| 143 | + // two tenants are org-bound in the only field that reaches `ExecutionContext`. |
| 144 | + for (const [token, slug] of [[tenantAToken, 'tenant-a-10243'], [tenantBToken, 'tenant-b-10243']] as const) { |
| 145 | + const res = await stack.apiAs(token, 'POST', '/auth/organization/set-active', { organizationSlug: slug }); |
| 146 | + expect(res.status, `set-active ${slug}: ${await res.clone().text()}`).toBe(200); |
| 147 | + } |
| 148 | + }, 180_000); |
| 149 | + |
| 150 | + afterAll(async () => { |
| 151 | + await stack?.stop?.(); |
| 152 | + }); |
| 153 | + |
| 154 | + it('guard the guard: a real walled posture, and three genuinely distinct principals', async () => { |
| 155 | + // If the stand-in ever stopped activating the posture, every read-back |
| 156 | + // below would still agree — and would be measuring a single-tenant stack. |
| 157 | + const tenancy = await stack.kernel.getServiceAsync<{ |
| 158 | + posture: string; requestedPosture: string; isolationActive: boolean; degraded: boolean; |
| 159 | + }>('tenancy'); |
| 160 | + expect(tenancy.requestedPosture).toBe('isolated'); |
| 161 | + expect(tenancy.posture).toBe('isolated'); |
| 162 | + expect(tenancy.isolationActive).toBe(true); |
| 163 | + expect(tenancy.degraded).toBe(false); |
| 164 | + |
| 165 | + // Two DIFFERENT organizations. Asserted, not assumed: if both tenants |
| 166 | + // landed in one org (or in none), "B saw A's toggle" would be a statement |
| 167 | + // about one tenant, not about a tenant wall. |
| 168 | + expect(orgAId).toBeTruthy(); |
| 169 | + expect(orgBId).toBeTruthy(); |
| 170 | + expect(orgAId).not.toBe(orgBId); |
| 171 | + |
| 172 | + const sessionOf = async (token: string) => { |
| 173 | + const res = await stack.apiAs(token, 'GET', '/auth/get-session'); |
| 174 | + expect(res.status).toBe(200); |
| 175 | + return (await res.json()) as { |
| 176 | + user: { isPlatformAdmin: boolean; positions: string[] }; |
| 177 | + session: { activeOrganizationId: string | null }; |
| 178 | + }; |
| 179 | + }; |
| 180 | + |
| 181 | + const a = await sessionOf(tenantAToken); |
| 182 | + const b = await sessionOf(tenantBToken); |
| 183 | + const admin = await sessionOf(adminToken); |
| 184 | + |
| 185 | + expect(a.session.activeOrganizationId).toBe(orgAId); |
| 186 | + expect(b.session.activeOrganizationId).toBe(orgBId); |
| 187 | + expect(a.user.isPlatformAdmin).toBe(false); |
| 188 | + expect(b.user.isPlatformAdmin).toBe(false); |
| 189 | + expect(admin.user.isPlatformAdmin).toBe(true); |
| 190 | + }); |
| 191 | + |
| 192 | + it('control: tenant A is genuinely unprivileged — the gated neighbours refuse it', async () => { |
| 193 | + // The same control #10145's report used to prove the account is not |
| 194 | + // secretly entitled. Asserts `code` AND `status` — the repo's minimum for a |
| 195 | + // refusal case, since a bare "it threw" passes for the wrong reasons. |
| 196 | + const meta = await stack.apiAs(tenantAToken, 'PUT', '/meta/object/crm_lead', { name: 'crm_lead' }); |
| 197 | + expect(meta.status).toBe(403); |
| 198 | + expect(((await meta.json()) as { error?: { code?: string } }).error?.code).toBe('FORBIDDEN'); |
| 199 | + |
| 200 | + for (const [method, path] of [['POST', '/automation'], ['DELETE', `/automation/${FLOW}`]] as const) { |
| 201 | + const body = method === 'POST' |
| 202 | + ? { name: 'probe_flow_10243', label: 'Probe', type: 'autolaunched', nodes: [], edges: [] } |
| 203 | + : undefined; |
| 204 | + const res = await stack.apiAs(tenantAToken, method, path, body); |
| 205 | + expect(res.status, `${method} ${path}`).toBe(403); |
| 206 | + expect(((await res.json()) as { error?: { code?: string } }).error?.code).toBe('PERMISSION_DENIED'); |
| 207 | + } |
| 208 | + }); |
| 209 | + |
| 210 | + it('baseline: all three principals read the shipped flow as enabled', async () => { |
| 211 | + for (const [who, token] of [['admin', adminToken], ['tenantA', tenantAToken], ['tenantB', tenantBToken]] as const) { |
| 212 | + const state = await readEnabled(stack, token); |
| 213 | + expect(state.enabled, `${who} baseline`).toBe(true); |
| 214 | + } |
| 215 | + }); |
| 216 | + |
| 217 | + it('MEASURED: tenant A toggles the flow off, and tenant B and the platform admin both read it off', async () => { |
| 218 | + const toggle = await stack.apiAs(tenantAToken, 'POST', `/automation/${FLOW}/toggle`, { enabled: false }); |
| 219 | + expect(toggle.status, `toggle as tenant A: ${await toggle.clone().text()}`).toBe(200); |
| 220 | + expect((await toggle.json()) as unknown).toMatchObject({ data: { name: FLOW, enabled: false } }); |
| 221 | + |
| 222 | + // The read-back table. Tenant B holds no membership of tenant A's |
| 223 | + // organization and the platform admin is org-less; both nevertheless |
| 224 | + // observe the actor's mutation. |
| 225 | + expect((await readEnabled(stack, tenantBToken)).enabled, 'tenant B after A toggled off').toBe(false); |
| 226 | + expect((await readEnabled(stack, adminToken)).enabled, 'platform admin after A toggled off').toBe(false); |
| 227 | + expect((await readEnabled(stack, tenantAToken)).enabled, 'tenant A after A toggled off').toBe(false); |
| 228 | + }); |
| 229 | + |
| 230 | + it('mutates ENGINE state, not the persisted definition — the bit an organization wall has nothing to scope', async () => { |
| 231 | + // Runs after the toggle above (file order is the sequence). The persisted |
| 232 | + // `status` — the flow's authored metadata, the thing an org overlay could |
| 233 | + // carry — is untouched, while the runtime `enabled` bit is off. That |
| 234 | + // divergence is where the state lives, and it is what makes the read-back |
| 235 | + // above independent of whether a row wall is installed. |
| 236 | + const state = await readEnabled(stack, adminToken); |
| 237 | + expect(state.enabled).toBe(false); |
| 238 | + expect(state.status).toBe('active'); |
| 239 | + |
| 240 | + const definition = await stack.apiAs(adminToken, 'GET', `/automation/${FLOW}`); |
| 241 | + expect(definition.status).toBe(200); |
| 242 | + const body = (await definition.json()) as { data?: { status?: string } }; |
| 243 | + expect(body.data?.status, 'the authored definition was not modified by the toggle').toBe('active'); |
| 244 | + |
| 245 | + // One engine for the whole environment: the same service instance the HTTP |
| 246 | + // route mutated, reachable from the kernel, reporting the same bit through |
| 247 | + // a method that takes no caller and no organization. |
| 248 | + const engine = await stack.kernel.getServiceAsync<AutomationEngineShape>('automation'); |
| 249 | + const again = await stack.kernel.getServiceAsync<AutomationEngineShape>('automation'); |
| 250 | + expect(engine, 'the automation service is one instance per environment').toBe(again); |
| 251 | + const fromEngine = engine.getFlowRuntimeStates().find((f) => f.name === FLOW); |
| 252 | + expect(fromEngine?.enabled, 'engine state after the HTTP toggle').toBe(false); |
| 253 | + }); |
| 254 | + |
| 255 | + it('symmetric: tenant A switches it back on, and the other two read it on again', async () => { |
| 256 | + const toggle = await stack.apiAs(tenantAToken, 'POST', `/automation/${FLOW}/toggle`, { enabled: true }); |
| 257 | + expect(toggle.status).toBe(200); |
| 258 | + |
| 259 | + expect((await readEnabled(stack, tenantBToken)).enabled, 'tenant B after A re-enabled').toBe(true); |
| 260 | + expect((await readEnabled(stack, adminToken)).enabled, 'platform admin after A re-enabled').toBe(true); |
| 261 | + }); |
| 262 | +}); |
0 commit comments