|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #7204 — a `group`-posture report returns the caller's membership union, the |
| 5 | + * same row set the caller sees interactively. |
| 6 | + * |
| 7 | + * `executeReport` used to rebuild a five-field projection of the caller's |
| 8 | + * execution envelope (`userId` / `tenantId` / `positions` / `permissions` / |
| 9 | + * `isSystem`) before handing it to the engine read that produces the report. |
| 10 | + * `accessible_org_ids` was not in that projection, and `buildDriverOptions` |
| 11 | + * reads it BY NAME (`engine.ts`, ADR-0105 D2 / #3623) to widen the driver's |
| 12 | + * native tenant scope to the caller's whole membership set under the `group` |
| 13 | + * posture. Absent, drivers "fall back to equality: fail toward isolation" — so |
| 14 | + * the report silently returned FEWER rows than the identical interactive query, |
| 15 | + * with no error and nothing in the output saying so. |
| 16 | + * |
| 17 | + * WHY THIS FILE REFUSES TO STUB THE ENGINE. The defect is invisible one layer |
| 18 | + * up: a fake engine that records the context it was handed can only assert that |
| 19 | + * a key is present, and "the key is on the object" is exactly what the previous |
| 20 | + * shape of this bug looked like from inside the service. The consumer is the |
| 21 | + * REAL `buildDriverOptions` → real `@objectstack/driver-sql` native scope, so |
| 22 | + * the assertions below land on ROW SETS: the union of the rows in both orgs, or |
| 23 | + * the equality subset. Backend is better-sqlite3 `:memory:`, the canonical |
| 24 | + * in-repo integration stack (PR #5715). |
| 25 | + * |
| 26 | + * The posture matrix is load-bearing, not decoration. `group` is the only |
| 27 | + * posture the widening applies to; `isolated`, "no provider wired" and "group |
| 28 | + * with an empty accessible set" must all still collapse to active-org equality |
| 29 | + * after the fix, or the change traded under-reporting for exposure. |
| 30 | + */ |
| 31 | + |
| 32 | +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; |
| 33 | +import { ObjectKernel } from '@objectstack/core'; |
| 34 | +import { ObjectQLPlugin } from '@objectstack/objectql'; |
| 35 | +import type { IDataEngine } from '@objectstack/spec/contracts'; |
| 36 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 37 | +import { SysSavedReport, SysReportSchedule } from '@objectstack/platform-objects/audit'; |
| 38 | +import { ReportService, type ReportEngine, type ReportEmail } from './report-service.js'; |
| 39 | + |
| 40 | +/** |
| 41 | + * A tenant-scoped business object. `organization_id` is what makes the driver's |
| 42 | + * native tenant scope engage at all (`isTenancyDisabled` / the SQL driver's |
| 43 | + * tenant column), and `day` is a READ-TIME formula field: `applyFormulaPlan` |
| 44 | + * evaluates it with `execCtx.timezone`, which the same projection also dropped. |
| 45 | + */ |
| 46 | +const account = { |
| 47 | + name: 'account', |
| 48 | + label: 'Account', |
| 49 | + fields: { |
| 50 | + organization_id: { name: 'organization_id', label: 'Org', type: 'text' }, |
| 51 | + name: { name: 'name', label: 'Name', type: 'text' }, |
| 52 | + day: { |
| 53 | + name: 'day', |
| 54 | + label: 'Calendar day', |
| 55 | + type: 'formula', |
| 56 | + expression: { dialect: 'cel', source: 'today()' }, |
| 57 | + }, |
| 58 | + }, |
| 59 | +}; |
| 60 | + |
| 61 | +/** The caller: active org `org_a`, membership union `org_a` + `org_b`. */ |
| 62 | +const GROUP_CTX = { |
| 63 | + userId: 'u1', |
| 64 | + tenantId: 'org_a', |
| 65 | + positions: [], |
| 66 | + permissions: [], |
| 67 | + posture: 'MEMBER', |
| 68 | + accessible_org_ids: ['org_a', 'org_b'], |
| 69 | +} as any; |
| 70 | + |
| 71 | +const ids = (rows: any[]): string[] => rows.map((r) => String(r.id)).sort(); |
| 72 | + |
| 73 | +/** |
| 74 | + * The engine surface this harness drives. `getService('objectql')` is declared |
| 75 | + * as the data-plane contract `IDataEngine`; the boot-time knobs below |
| 76 | + * (`registerDriver`, `registry`, `syncSchemas`) and the posture provider |
| 77 | + * plugin-security wires in production sit outside it, so they are named here |
| 78 | + * rather than erased with `any`. |
| 79 | + */ |
| 80 | +interface TestEngine extends IDataEngine { |
| 81 | + registerDriver(driver: unknown, isDefault?: boolean): void; |
| 82 | + registry: { registerObject(def: unknown, packageId: string, namespace: string): void }; |
| 83 | + syncSchemas(): Promise<unknown>; |
| 84 | + setTenancyPostureProvider(provider: () => string | undefined): void; |
| 85 | + destroy(): Promise<void>; |
| 86 | +} |
| 87 | + |
| 88 | +describe('#7204 a group-posture report reads the caller\'s whole membership union', () => { |
| 89 | + let objectql: TestEngine | undefined; |
| 90 | + let svc: ReportService; |
| 91 | + let email: ReportEmail & { _sent: any[] }; |
| 92 | + |
| 93 | + afterEach(async () => { |
| 94 | + vi.useRealTimers(); |
| 95 | + try { await objectql?.destroy(); } catch { /* noop */ } |
| 96 | + }); |
| 97 | + |
| 98 | + beforeEach(async () => { |
| 99 | + const kernel = new ObjectKernel({ logger: { level: 'error' } }); |
| 100 | + await kernel.use(new ObjectQLPlugin()); |
| 101 | + await kernel.bootstrap(); |
| 102 | + objectql = kernel.getService<IDataEngine>('objectql') as TestEngine; |
| 103 | + |
| 104 | + // The engine's own `init()` ran during bootstrap, before this driver |
| 105 | + // existed, so the connect the engine would have done is done here. |
| 106 | + const driver = new SqlDriver({ |
| 107 | + client: 'better-sqlite3', |
| 108 | + connection: { filename: ':memory:' }, |
| 109 | + useNullAsDefault: true, |
| 110 | + }); |
| 111 | + await driver.connect(); |
| 112 | + engine().registerDriver(driver, true); |
| 113 | + for (const def of [account, SysSavedReport, SysReportSchedule]) { |
| 114 | + engine().registry.registerObject(def, 'reports-test', 'reports-test'); |
| 115 | + } |
| 116 | + await engine().syncSchemas(); |
| 117 | + |
| 118 | + // Two rows in the active org, one in the other org the caller belongs to. |
| 119 | + for (const row of [ |
| 120 | + { id: 'a1', organization_id: 'org_a', name: 'A1' }, |
| 121 | + { id: 'a2', organization_id: 'org_a', name: 'A2' }, |
| 122 | + { id: 'b1', organization_id: 'org_b', name: 'B1' }, |
| 123 | + ]) { |
| 124 | + await engine().insert('account', row, { context: { isSystem: true } }); |
| 125 | + } |
| 126 | + |
| 127 | + const sent: any[] = []; |
| 128 | + email = { |
| 129 | + _sent: sent, |
| 130 | + async send(input) { sent.push(input); return { status: 'sent' as const }; }, |
| 131 | + }; |
| 132 | + svc = new ReportService({ |
| 133 | + engine: engine() as unknown as ReportEngine, |
| 134 | + email, |
| 135 | + // A scheduled run executes as the report's OWNER (#2849 / #2980) — the |
| 136 | + // resolver hands back a real RLS-bearing envelope, membership set and all. |
| 137 | + resolveOwnerContext: async (ownerId: string) => |
| 138 | + (ownerId === 'u1' ? ({ ...GROUP_CTX } as any) : null), |
| 139 | + }); |
| 140 | + }); |
| 141 | + |
| 142 | + /** What plugin-security's wiring reports in a deployment of this posture. */ |
| 143 | + const posture = (p: string | undefined) => engine().setTenancyPostureProvider(() => p); |
| 144 | + |
| 145 | + /** The booted engine — narrowed once so every call site stays typed. */ |
| 146 | + const engine = (): TestEngine => objectql as TestEngine; |
| 147 | + |
| 148 | + const saveAccountReport = async (format = 'csv') => |
| 149 | + svc.saveReport( |
| 150 | + { name: 'Accounts', object: 'account', query: {}, format } as any, |
| 151 | + GROUP_CTX, |
| 152 | + ); |
| 153 | + |
| 154 | + describe('group posture', () => { |
| 155 | + beforeEach(() => posture('group')); |
| 156 | + |
| 157 | + it('a SAVED report returns the same rows as the identical interactive query', async () => { |
| 158 | + const interactive = await engine().find('account', {}, { context: GROUP_CTX }); |
| 159 | + expect(ids(interactive), 'the interactive baseline is the union').toEqual(['a1', 'a2', 'b1']); |
| 160 | + |
| 161 | + const report = await svc.run((await saveAccountReport()).id, GROUP_CTX); |
| 162 | + |
| 163 | + // The card's exact claim: the saved-report path used to return FEWER rows |
| 164 | + // (['a1','a2'] — active-org equality) than the interactive query above. |
| 165 | + expect(ids(report.rows)).toEqual(ids(interactive)); |
| 166 | + expect(report.rowCount).toBe(3); |
| 167 | + }); |
| 168 | + |
| 169 | + it('an AD-HOC report returns the union too', async () => { |
| 170 | + const report = await svc.runAdHoc( |
| 171 | + { name: 'Ad hoc', object: 'account', query: {} } as any, |
| 172 | + GROUP_CTX, |
| 173 | + ); |
| 174 | + expect(ids(report.rows)).toEqual(['a1', 'a2', 'b1']); |
| 175 | + }); |
| 176 | + |
| 177 | + it('a SCHEDULED run emails the owner the union, not the active org', async () => { |
| 178 | + const report = await saveAccountReport(); |
| 179 | + await svc.scheduleReport( |
| 180 | + { reportId: report.id, recipients: ['ops@example.com'], format: 'csv', intervalMinutes: 60 }, |
| 181 | + GROUP_CTX, |
| 182 | + ); |
| 183 | + |
| 184 | + const result = await svc.dispatchDue(new Date(Date.now() + 3 * 60 * 60 * 1000)); |
| 185 | + expect(result, 'the sweep must actually fire').toMatchObject({ fired: 1, failed: 0 }); |
| 186 | + |
| 187 | + const csv: string = email._sent[email._sent.length - 1]?.attachments?.[0]?.content ?? ''; |
| 188 | + const dataRows = csv.split('\r\n').slice(1).filter(Boolean); |
| 189 | + expect(dataRows).toHaveLength(3); |
| 190 | + expect(csv).toContain('B1'); |
| 191 | + }); |
| 192 | + |
| 193 | + it('the report also sees rows the ACTIVE org has none of', async () => { |
| 194 | + // Nothing in org_a at all: under equality the report is empty, under the |
| 195 | + // union it is the one org_b row. Isolates the widening from "the active |
| 196 | + // org happened to hold most of the rows". |
| 197 | + const report = await svc.runAdHoc( |
| 198 | + { name: 'B only', object: 'account', query: { filter: { name: 'B1' } } } as any, |
| 199 | + GROUP_CTX, |
| 200 | + ); |
| 201 | + expect(ids(report.rows)).toEqual(['b1']); |
| 202 | + }); |
| 203 | + |
| 204 | + it('an EMPTY accessible set still collapses to active-org equality (fail toward isolation)', async () => { |
| 205 | + const ctx = { ...GROUP_CTX, accessible_org_ids: [] }; |
| 206 | + const interactive = await engine().find('account', {}, { context: ctx }); |
| 207 | + const report = await svc.runAdHoc({ name: 'r', object: 'account', query: {} } as any, ctx); |
| 208 | + expect(ids(report.rows)).toEqual(['a1', 'a2']); |
| 209 | + expect(ids(report.rows)).toEqual(ids(interactive)); |
| 210 | + }); |
| 211 | + |
| 212 | + it('forwards the business timezone, so a read-time formula field resolves the caller\'s calendar day', async () => { |
| 213 | + // 20:00Z is the day BEFORE in UTC and the day AFTER in UTC+14, so the two |
| 214 | + // timezones disagree deterministically at this instant. `today()` is a |
| 215 | + // read-time formula evaluated by `applyFormulaPlan` with `execCtx.timezone` |
| 216 | + // — dropped by the same projection, and observable on the row's VALUE. |
| 217 | + vi.useFakeTimers({ shouldAdvanceTime: true }); |
| 218 | + vi.setSystemTime(new Date('2026-08-10T20:00:00Z')); |
| 219 | + const ctx = { ...GROUP_CTX, timezone: 'Pacific/Kiritimati' }; |
| 220 | + |
| 221 | + const interactive = await engine().find('account', { where: { id: 'a1' } }, { context: ctx }); |
| 222 | + const report = await svc.runAdHoc( |
| 223 | + { name: 'r', object: 'account', query: { filter: { id: 'a1' } } } as any, |
| 224 | + ctx, |
| 225 | + ); |
| 226 | + |
| 227 | + const dayOf = (v: unknown): string => |
| 228 | + String(v instanceof Date ? v.toISOString() : v).slice(0, 10); |
| 229 | + expect(dayOf(interactive[0]?.day), 'UTC+14 is already on the 11th').toBe('2026-08-11'); |
| 230 | + expect(dayOf((report.rows[0] as any)?.day)).toBe(dayOf(interactive[0]?.day)); |
| 231 | + }); |
| 232 | + }); |
| 233 | + |
| 234 | + describe('every other posture is unchanged — the widening is group-only', () => { |
| 235 | + it('isolated posture: the report stays at active-org equality', async () => { |
| 236 | + posture('isolated'); |
| 237 | + const interactive = await engine().find('account', {}, { context: GROUP_CTX }); |
| 238 | + const report = await svc.runAdHoc({ name: 'r', object: 'account', query: {} } as any, GROUP_CTX); |
| 239 | + expect(ids(report.rows)).toEqual(['a1', 'a2']); |
| 240 | + expect(ids(report.rows)).toEqual(ids(interactive)); |
| 241 | + }); |
| 242 | + |
| 243 | + it('no posture provider (no enforcement layer): equality, never widened', async () => { |
| 244 | + const interactive = await engine().find('account', {}, { context: GROUP_CTX }); |
| 245 | + const report = await svc.runAdHoc({ name: 'r', object: 'account', query: {} } as any, GROUP_CTX); |
| 246 | + expect(ids(report.rows)).toEqual(['a1', 'a2']); |
| 247 | + expect(ids(report.rows)).toEqual(ids(interactive)); |
| 248 | + }); |
| 249 | + }); |
| 250 | +}); |
0 commit comments