|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #10673 — the delivery dispatchers' predicate writes are classified, not |
| 5 | + * silenced. |
| 6 | + * |
| 7 | + * ## What was measured |
| 8 | + * On a walled deployment (`OS_TENANCY_POSTURE=isolated`) the SQL driver's |
| 9 | + * `auditMissingTenant` gate printed, for both delivery objects: |
| 10 | + * |
| 11 | + * [tenant-audit] updateMany on tenant-scoped object "sys_http_delivery" |
| 12 | + * without options.tenantId — writes will not be tenant-isolated. |
| 13 | + * [tenant-audit] updateMany on tenant-scoped object "sys_notification_delivery" |
| 14 | + * without options.tenantId — writes will not be tenant-isolated. |
| 15 | + * |
| 16 | + * The audit is right that the writes are environment-wide. The fix is the |
| 17 | + * classification it demands, not the quiet: each `multi: true` write on the |
| 18 | + * claim path is declared a global dispatcher sweep (`bypassTenantAudit`), with |
| 19 | + * the warrant in `outbox-dispatcher-scope.ts`. See that file for why a |
| 20 | + * `tenantId` is not merely absent but unavailable and unwanted here. |
| 21 | + * |
| 22 | + * ## Why this harness rather than the composed boot |
| 23 | + * The card's repro is an EE image booted under docker compose, which this |
| 24 | + * checkout cannot run. What stands in its place is the instrument's OWN |
| 25 | + * criterion, exercised end to end: a real `SqlDriver` on better-sqlite3, real |
| 26 | + * `syncSchemas()` (so `organization_id` is really provisioned and |
| 27 | + * `resolveTenantField` really answers), the real `OS_TENANCY_POSTURE` read, |
| 28 | + * the production `SqlHttpOutbox` / `SqlNotificationOutbox`, and the driver's |
| 29 | + * own logger as the assertion surface — the same substitution |
| 30 | + * `sql-driver-tenant-audit-posture.test.ts` makes. |
| 31 | + * |
| 32 | + * ## The vacuity traps closed here, explicitly |
| 33 | + * 1. **A green that means "the audit was never armed".** Every test that |
| 34 | + * asserts silence first asserts the gate's own preconditions are live — |
| 35 | + * `resolveTenantField(object) === 'organization_id'` — and then performs a |
| 36 | + * deliberately unscoped `multi: true` write on the SAME object through the |
| 37 | + * SAME driver and requires the warning to appear. Without that positive |
| 38 | + * control an object that stopped being tenant-scoped, a posture that |
| 39 | + * stopped resolving, or a typo in the matcher would all read as a fix. |
| 40 | + * 2. **A "fix" that touches nothing.** Silence is cheap for an implementation |
| 41 | + * that claims no rows. Every claim assertion pins the ROWS: both |
| 42 | + * organizations' rows move, and their `organization_id` survives the write |
| 43 | + * — the cross-organization reach is the operation's semantics, so a |
| 44 | + * regression to per-organization scoping must go red here. |
| 45 | + */ |
| 46 | + |
| 47 | +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; |
| 48 | +import { ObjectQL } from '@objectstack/objectql'; |
| 49 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 50 | +import { SqlHttpOutbox } from './sql-http-outbox.js'; |
| 51 | +import { SqlNotificationOutbox, DELIVERY_OBJECT } from './sql-outbox.js'; |
| 52 | +import { HttpDelivery, SYS_HTTP_DELIVERY } from './objects/http-delivery.object.js'; |
| 53 | +import { NotificationDelivery } from './objects/notification-delivery.object.js'; |
| 54 | + |
| 55 | +const OLD_POSTURE = process.env.OS_TENANCY_POSTURE; |
| 56 | +const OLD_AUDIT = process.env.OS_TENANT_AUDIT; |
| 57 | + |
| 58 | +let engine: ObjectQL; |
| 59 | +let driver: SqlDriver; |
| 60 | +let warns: Array<{ msg: string; meta: any }>; |
| 61 | + |
| 62 | +/** The audit line the card quotes, matched on object + op. */ |
| 63 | +const auditedUpdateMany = (object: string): boolean => |
| 64 | + warns.some((w) => w.msg.includes(`[tenant-audit] updateMany on tenant-scoped object "${object}"`)); |
| 65 | + |
| 66 | +beforeEach(async () => { |
| 67 | + // The posture is read LIVE by `isMultiTenantMode()` (#5262), so setting it |
| 68 | + // here really does arm the gate for the writes below. |
| 69 | + process.env.OS_TENANCY_POSTURE = 'isolated'; |
| 70 | + delete process.env.OS_TENANT_AUDIT; |
| 71 | + |
| 72 | + driver = new SqlDriver({ |
| 73 | + client: 'better-sqlite3', |
| 74 | + connection: { filename: ':memory:' }, |
| 75 | + useNullAsDefault: true, |
| 76 | + }); |
| 77 | + warns = []; |
| 78 | + (driver as any).logger = { warn: (msg: string, meta: any) => warns.push({ msg, meta }) }; |
| 79 | + |
| 80 | + engine = new ObjectQL(); |
| 81 | + engine.registerDriver(driver, true); |
| 82 | + await engine.init(); |
| 83 | + engine.registry.registerObject(HttpDelivery as any, '@objectstack/service-messaging'); |
| 84 | + engine.registry.registerObject(NotificationDelivery as any, '@objectstack/service-messaging'); |
| 85 | + await engine.syncSchemas(); |
| 86 | +}); |
| 87 | + |
| 88 | +afterEach(async () => { |
| 89 | + try { await engine?.destroy(); } catch { /* noop */ } |
| 90 | + if (OLD_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE; |
| 91 | + else process.env.OS_TENANCY_POSTURE = OLD_POSTURE; |
| 92 | + if (OLD_AUDIT === undefined) delete process.env.OS_TENANT_AUDIT; |
| 93 | + else process.env.OS_TENANT_AUDIT = OLD_AUDIT; |
| 94 | +}); |
| 95 | + |
| 96 | +/** |
| 97 | + * The positive control. An unscoped predicate write on `object`, issued |
| 98 | + * directly through the engine with no `bypassTenantAudit`, MUST produce the |
| 99 | + * audit line — otherwise a silent run proves nothing about the code under |
| 100 | + * test. Deliberately run AFTER the assertion it guards: the gate throttles one |
| 101 | + * warning per `${object}:${op}`, so this only fires if the production path |
| 102 | + * consumed no `updateMany` warning of its own. |
| 103 | + */ |
| 104 | +async function controlUnscopedUpdateMany(object: string): Promise<void> { |
| 105 | + // A PREDICATE write (no `id`), so it routes through `driver.updateMany` |
| 106 | + // exactly as the production path does; matching zero rows is fine — the |
| 107 | + // audit fires before the statement runs. |
| 108 | + await engine.update(object, { attempts: 99 }, { where: { status: '__control_no_such_status__' }, multi: true } as any); |
| 109 | + expect( |
| 110 | + auditedUpdateMany(object), |
| 111 | + `positive control failed: an unscoped multi:true write on ${object} produced no [tenant-audit] ` |
| 112 | + + 'line, so this file cannot distinguish "classified" from "audit not armed"', |
| 113 | + ).toBe(true); |
| 114 | +} |
| 115 | + |
| 116 | +async function seedHttpRow(id: string, org: string, over: Record<string, unknown> = {}): Promise<void> { |
| 117 | + const now = new Date(); |
| 118 | + await engine.insert(SYS_HTTP_DELIVERY, { |
| 119 | + id, |
| 120 | + source: 'test', |
| 121 | + ref_id: id, |
| 122 | + dedup_key: id, |
| 123 | + url: 'https://receiver.example/hook', |
| 124 | + method: 'POST', |
| 125 | + payload_json: '{}', |
| 126 | + partition_key: 0, |
| 127 | + status: 'pending', |
| 128 | + attempts: 0, |
| 129 | + organization_id: org, |
| 130 | + created_at: now, |
| 131 | + updated_at: now, |
| 132 | + ...over, |
| 133 | + } as any); |
| 134 | +} |
| 135 | + |
| 136 | +// ─────────────────────────────────────────────────────────────────────────── |
| 137 | +describe('sys_http_delivery — the dispatcher claim path is a classified global sweep', () => { |
| 138 | + it('claims across organizations without a tenant-audit finding', async () => { |
| 139 | + // The gate's own precondition: this object really is tenant-scoped. |
| 140 | + expect((driver as any).resolveTenantField(SYS_HTTP_DELIVERY)).toBe('organization_id'); |
| 141 | + |
| 142 | + await seedHttpRow('h_a', 'org_a'); |
| 143 | + await seedHttpRow('h_b', 'org_b'); |
| 144 | + |
| 145 | + const outbox = new SqlHttpOutbox(engine as any, { partitionCount: 1 }); |
| 146 | + const claimed = await outbox.claim({ nodeId: 'node1', limit: 10, claimTtlMs: 60_000 }); |
| 147 | + |
| 148 | + // ① the classified write is silent… |
| 149 | + expect(auditedUpdateMany(SYS_HTTP_DELIVERY)).toBe(false); |
| 150 | + // ② …and it still reaches every organization, which is the point. |
| 151 | + expect(claimed.map((c) => c.id).sort()).toEqual(['h_a', 'h_b']); |
| 152 | + const rows = (await engine.find(SYS_HTTP_DELIVERY, { where: {} })) as any[]; |
| 153 | + expect(rows.map((r) => `${r.id}:${r.organization_id}:${r.status}`).sort()).toEqual([ |
| 154 | + 'h_a:org_a:in_flight', |
| 155 | + 'h_b:org_b:in_flight', |
| 156 | + ]); |
| 157 | + |
| 158 | + await controlUnscopedUpdateMany(SYS_HTTP_DELIVERY); |
| 159 | + }); |
| 160 | + |
| 161 | + it('reaps a crashed node\'s in_flight rows in every organization, without a finding', async () => { |
| 162 | + const stale = Date.now() - 10 * 60_000; |
| 163 | + await seedHttpRow('h_a', 'org_a', { status: 'in_flight', claimed_by: 'dead_node', claimed_at: stale }); |
| 164 | + await seedHttpRow('h_b', 'org_b', { status: 'in_flight', claimed_by: 'dead_node', claimed_at: stale }); |
| 165 | + |
| 166 | + const outbox = new SqlHttpOutbox(engine as any, { partitionCount: 1 }); |
| 167 | + const claimed = await outbox.claim({ nodeId: 'node2', limit: 10, claimTtlMs: 60_000 }); |
| 168 | + |
| 169 | + expect(auditedUpdateMany(SYS_HTTP_DELIVERY)).toBe(false); |
| 170 | + // Both organizations' abandoned rows were recovered AND re-claimed by |
| 171 | + // the live node — a per-organization reap would have stranded one. |
| 172 | + expect(claimed.map((c) => c.id).sort()).toEqual(['h_a', 'h_b']); |
| 173 | + const rows = (await engine.find(SYS_HTTP_DELIVERY, { where: {} })) as any[]; |
| 174 | + expect(rows.every((r) => r.claimed_by === 'node2')).toBe(true); |
| 175 | + |
| 176 | + await controlUnscopedUpdateMany(SYS_HTTP_DELIVERY); |
| 177 | + }); |
| 178 | +}); |
| 179 | + |
| 180 | +// ─────────────────────────────────────────────────────────────────────────── |
| 181 | +describe('sys_notification_delivery — the dispatcher claim path is a classified global sweep', () => { |
| 182 | + it('claims across organizations without a tenant-audit finding', async () => { |
| 183 | + expect((driver as any).resolveTenantField(DELIVERY_OBJECT)).toBe('organization_id'); |
| 184 | + |
| 185 | + const outbox = new SqlNotificationOutbox(engine as any, { partitionCount: 1 }); |
| 186 | + const idA = await outbox.enqueue({ |
| 187 | + notificationId: 'n_a', recipientId: 'u_a', channel: 'inbox', organizationId: 'org_a', payload: {}, |
| 188 | + } as any); |
| 189 | + const idB = await outbox.enqueue({ |
| 190 | + notificationId: 'n_b', recipientId: 'u_b', channel: 'inbox', organizationId: 'org_b', payload: {}, |
| 191 | + } as any); |
| 192 | + |
| 193 | + const claimed = await outbox.claim({ nodeId: 'node1', limit: 10, claimTtlMs: 60_000 }); |
| 194 | + |
| 195 | + expect(auditedUpdateMany(DELIVERY_OBJECT)).toBe(false); |
| 196 | + expect(claimed.map((c) => c.id).sort()).toEqual([idA, idB].sort()); |
| 197 | + // The organization stamped at enqueue survives the sweep untouched — |
| 198 | + // the sweep moves `status`, never a row's tenant. |
| 199 | + expect(claimed.map((c) => c.organizationId).sort()).toEqual(['org_a', 'org_b']); |
| 200 | + |
| 201 | + await controlUnscopedUpdateMany(DELIVERY_OBJECT); |
| 202 | + }); |
| 203 | + |
| 204 | + it('collapses a digest window across organizations without a finding', async () => { |
| 205 | + const outbox = new SqlNotificationOutbox(engine as any, { partitionCount: 1 }); |
| 206 | + await outbox.enqueue({ |
| 207 | + notificationId: 'n_a', recipientId: 'u_a', channel: 'inbox', organizationId: 'org_a', |
| 208 | + payload: {}, digestKey: 'u_a|inbox|w1', |
| 209 | + } as any); |
| 210 | + await outbox.enqueue({ |
| 211 | + notificationId: 'n_b', recipientId: 'u_b', channel: 'inbox', organizationId: 'org_b', |
| 212 | + payload: {}, digestKey: 'u_b|inbox|w1', |
| 213 | + } as any); |
| 214 | + |
| 215 | + const claimed = await outbox.claimDigest({ nodeId: 'node1', limit: 10, claimTtlMs: 60_000 }); |
| 216 | + |
| 217 | + expect(auditedUpdateMany(DELIVERY_OBJECT)).toBe(false); |
| 218 | + expect(claimed.map((c) => c.organizationId).sort()).toEqual(['org_a', 'org_b']); |
| 219 | + |
| 220 | + await controlUnscopedUpdateMany(DELIVERY_OBJECT); |
| 221 | + }); |
| 222 | +}); |
0 commit comments