diff --git a/.changeset/21910-cascade-federated-tenant-anchor.md b/.changeset/21910-cascade-federated-tenant-anchor.md new file mode 100644 index 00000000000..c1b2270dfb1 --- /dev/null +++ b/.changeset/21910-cascade-federated-tenant-anchor.md @@ -0,0 +1,11 @@ +--- +'@objectstack/objectql': patch +--- + +Deleting an organization no longer fails with a 500 on a deployment that has a federated (ADR-0015 `external`) object bound. The engine's referential cascade no longer treats the `organization_id` the platform injects into a federated object as a reference to `sys_organization`. + +Clause-②: no + +- **What was wrong.** The registry injects `organization_id` into every object, federated ones included, and the platform provisions no storage for a federated object. The cascade's dependents probe filtered the remote table on that column, the SQL driver refused the unknown column (`INVALID_FILTER`), and the probe's failure propagated, so the delete failed. The showcase, with its federated fixture provisioned, answered every organization delete with 500. +- **What changed.** The cascade scan skips a federated object's injected tenant anchor. It asks the same `isFederatedObject` predicate as the driver-option builder and the related-record read, plus the injected-column provenance marker, so an `organization_id` the author declared on a federated object is still probed. The cascade's atomicity plan asks the same question, so it keeps counting exactly the relations the scan probes. +- **What did not change.** Any lookup an author declares on a federated object is still probed, and a probe that cannot run still fails the delete. Only a missing child table is passed over as having no dependents. diff --git a/packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts b/packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts new file mode 100644 index 00000000000..1429019ea91 --- /dev/null +++ b/packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts @@ -0,0 +1,270 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21910] The referential cascade does not treat a federated object's + * platform-INJECTED `organization_id` as a reference to `sys_organization`, + * and treats nothing else that way. + * + * The registry injects the tenant anchor (`organization_id`, a lookup to + * `sys_organization`) into every object it registers, ADR-0015 `external` ones + * included, and the platform provisions no storage for a federated object. On + * the showcase, deleting an organization ran the cascade scan's dependents + * probe against the remote `customers` table on that column. The SQL driver + * refused it (`INVALID_FILTER`, no such column), the probe's #8895 catch + * propagated the refusal, and the organization delete answered 500. + * + * What this file pins, all through `engine.delete` on a two-driver engine (the + * default one, and the remote a federated object is bound to by `datasource`): + * + * 1. the scan never reads a federated object on its injected anchor, so an + * organization delete lands while that read would be refused. A local + * object's injected anchor IS read on the same delete, which proves the + * scan ran; + * 2. an `organization_id` the AUTHOR declared on a federated object is still + * probed, and a probe failure still propagates (#8895); + * 3. any other lookup the author declares on a federated object is still + * probed, and a probe failure still propagates (#8895); + * 4. the cascade's atomicity plan agrees with the scan: an organization delete + * whose only cross-datasource "participant" was the injected anchor runs + * as one transaction, while an author-declared federated lookup still + * makes the plan cross-datasource. + * + * The seed rows are written straight into the stub's store, so no write path + * other than the delete under test runs. The door pin is + * `packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { resolveInjectedColumnProvenance } from '@objectstack/spec/data'; +import { ObjectQL } from './engine.js'; + +/** The remote datasource every federated fixture below is bound to. */ +const REMOTE = 'remote_ds'; +const PACKAGE_ID = 'test-21910'; + +type Row = Record; + +/** + * A stub driver that records every read into a shared log and can be told to + * refuse reads of one object with an exact error object. Its `find` applies + * the caller's `limit` after the filter, by presence. + */ +function makeDriver(name: string, log: { reads: string[]; begun: number }) { + const tables: Record = {}; + const failReads = new Map(); + const rowsOf = (o: string): Row[] => (tables[o] ??= []); + const matches = (row: Row, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k.startsWith('$')) continue; + const exp = v && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v; + if ((row[k] ?? null) !== (exp ?? null)) return false; + } + return true; + }; + const driver: any = { + name, version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async syncSchema() {}, + registerExternalObject() {}, + async find(o: string, ast: any) { + log.reads.push(o); + const failure = failReads.get(o); + if (failure !== undefined) throw failure; + const hit = (tables[o] ?? []).filter((r) => matches(r, ast?.where)); + return typeof ast?.limit === 'number' ? hit.slice(0, ast.limit) : hit; + }, + async findOne(o: string, ast: any) { + const [first] = await this.find(o, { ...ast, limit: 1 }); + return first ?? null; + }, + async count(o: string, ast: any) { + return (await this.find(o, { where: ast?.where })).length; + }, + async create(o: string, data: Row) { + const row = { ...data, id: String(data.id) }; + rowsOf(o).push(row); + return row; + }, + async update(o: string, id: string, data: Row) { + const rows = rowsOf(o); + const at = rows.findIndex((r) => r.id === String(id)); + if (at < 0) throw new Error(`not found ${o}/${id}`); + rows[at] = { ...rows[at], ...data, id: String(id) }; + return rows[at]; + }, + async upsert(o: string, data: Row) { return this.create(o, data); }, + async delete(o: string, id: string) { + const rows = rowsOf(o); + const at = rows.findIndex((r) => r.id === String(id)); + if (at < 0) return false; + rows.splice(at, 1); + return true; + }, + async bulkCreate() { return []; }, async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { log.begun += 1; return { id: `trx_${log.begun}` }; }, + async commit() {}, async rollback() {}, + }; + return { + driver, + failReads, + has: (o: string, id: string) => rowsOf(o).some((r) => r.id === id), + seed: (o: string, row: Row) => void rowsOf(o).push(row), + }; +} + +/** The deleted object. Its own injected anchor makes it self-referencing, harmlessly. */ +const ORGANIZATION = { + name: 'sys_organization', + label: 'Organization', + fields: { name: { name: 'name', label: 'Name', type: 'text' as const } }, +}; + +/** A LOCAL object: the registry injects `organization_id`, and storage backs it. */ +const LOCAL = { + name: 'acct', + label: 'Account', + fields: { name: { name: 'name', label: 'Name', type: 'text' as const } }, +}; + +/** Federated, as the showcase declares it: no `organization_id` of its own. */ +const FEDERATED = { + name: 'ext_customer', + label: 'External Customer', + datasource: REMOTE, + external: { remoteName: 'customers' }, + fields: { name: { name: 'name', label: 'Name', type: 'text' as const } }, +}; + +/** Federated, with an `organization_id` the AUTHOR declared: it maps a real remote column. */ +const FEDERATED_DECLARED_ANCHOR = { + name: 'ext_tenant_customer', + label: 'External Tenant Customer', + datasource: REMOTE, + external: { remoteName: 'tenant_customers' }, + fields: { + name: { name: 'name', label: 'Name', type: 'text' as const }, + organization_id: { + name: 'organization_id', + label: 'Remote Organization', + type: 'lookup' as const, + reference: 'sys_organization', + }, + }, +}; + +/** Federated, with another lookup the author declared against the organization. */ +const FEDERATED_AUTHOR_LOOKUP = { + name: 'ext_order', + label: 'External Order', + datasource: REMOTE, + external: { remoteName: 'orders' }, + fields: { + amount: { name: 'amount', label: 'Amount', type: 'number' as const }, + org_ref: { name: 'org_ref', label: 'Organization', type: 'lookup' as const, reference: 'sys_organization' }, + }, +}; + +const ORG_ID = 'org_21910'; + +/** The refusal the SQL driver answers for a filter on a column the remote does not have. */ +function unknownColumnRefusal(object: string, column: string) { + return Object.assign( + new Error(`A filter on object '${object}' names a column the database could not resolve (${column}).`), + { code: 'INVALID_FILTER', status: 400 }, + ); +} + +async function makeEngine(objects: any[]) { + const log = { reads: [] as string[], begun: 0 }; + const warnings: string[] = []; + const logger = { + debug() {}, info() {}, error() {}, + warn: (message: unknown) => void warnings.push(String(message)), + }; + const engine = new ObjectQL({ logger } as any); + const local = makeDriver('memory', log); + const remote = makeDriver(REMOTE, log); + engine.registerDriver(local.driver, true); + engine.registerDriver(remote.driver); + await engine.init(); + for (const o of objects) engine.registry.registerObject(o, PACKAGE_ID); + local.seed('sys_organization', { id: ORG_ID, name: 'Doomed Org' }); + return { engine, local, remote, log, warnings }; +} + +const NOT_ATOMIC = 'cannot run as one unit of work'; + +describe('[#21910] the cascade scan skips a federated object\'s injected tenant anchor, and nothing else', () => { + it('never probes a federated object on its injected organization_id, so the organization delete lands', async () => { + const { engine, local, remote, log } = await makeEngine([ORGANIZATION, LOCAL, FEDERATED]); + // PREMISE: the registered schema carries the platform's injected anchor. + expect(resolveInjectedColumnProvenance(engine.getSchema('ext_customer'), 'organization_id')) + .toBe('injected-unprovisioned'); + // Any read of the remote table on that column is refused, as the showcase measured. + remote.failReads.set('ext_customer', unknownColumnRefusal('ext_customer', 'organization_id')); + + log.reads.length = 0; + await engine.delete('sys_organization', { where: { id: ORG_ID } } as any); + + expect(local.has('sys_organization', ORG_ID)).toBe(false); + expect(log.reads).not.toContain('ext_customer'); + // CONTROL: the scan ran for this delete, and probed the local object's injected anchor. + expect(log.reads).toContain('acct'); + }); + + it('still probes an organization_id the AUTHOR declared on a federated object, and its failure propagates (#8895)', async () => { + const { engine, local, remote, log } = await makeEngine([ORGANIZATION, FEDERATED_DECLARED_ANCHOR]); + expect(resolveInjectedColumnProvenance(engine.getSchema('ext_tenant_customer'), 'organization_id')) + .toBe('author'); + const injected = unknownColumnRefusal('ext_tenant_customer', 'organization_id'); + remote.failReads.set('ext_tenant_customer', injected); + + log.reads.length = 0; + const err: any = await engine.delete('sys_organization', { where: { id: ORG_ID } } as any).catch((e) => e); + + expect(err).toBe(injected); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(log.reads).toContain('ext_tenant_customer'); + expect(local.has('sys_organization', ORG_ID)).toBe(true); + }); + + it('still probes any other lookup the author declared on a federated object, and its failure propagates (#8895)', async () => { + const { engine, local, remote, log } = await makeEngine([ORGANIZATION, FEDERATED_AUTHOR_LOOKUP]); + const injected = unknownColumnRefusal('ext_order', 'org_ref'); + remote.failReads.set('ext_order', injected); + + log.reads.length = 0; + const err: any = await engine.delete('sys_organization', { where: { id: ORG_ID } } as any).catch((e) => e); + + expect(err).toBe(injected); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(log.reads).toContain('ext_order'); + expect(local.has('sys_organization', ORG_ID)).toBe(true); + }); +}); + +describe('[#21910] the cascade atomicity plan agrees with the scan about who takes part', () => { + it('runs the organization delete as one transaction when the injected anchor was its only cross-datasource reference', async () => { + const { engine, local, log, warnings } = await makeEngine([ORGANIZATION, LOCAL, FEDERATED]); + + await engine.delete('sys_organization', { where: { id: ORG_ID } } as any); + + expect(local.has('sys_organization', ORG_ID)).toBe(false); + expect(log.begun).toBe(1); + expect(warnings.filter((w) => w.includes(NOT_ATOMIC))).toEqual([]); + }); + + it('CONTROL: an author-declared lookup on a federated object still makes the plan cross-datasource', async () => { + const { engine, local, log, warnings } = await makeEngine([ORGANIZATION, LOCAL, FEDERATED_AUTHOR_LOOKUP]); + + await engine.delete('sys_organization', { where: { id: ORG_ID } } as any); + + expect(local.has('sys_organization', ORG_ID)).toBe(false); + expect(log.reads).toContain('ext_order'); + expect(log.begun).toBe(0); + expect(warnings.filter((w) => w.includes(NOT_ATOMIC))).toHaveLength(1); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 48b8210911c..2073a1d4b84 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -304,7 +304,8 @@ import { withDeclaredColumnsOnly, } from './declared-read-columns.js'; // [#21777] "Is this schema the remote's?" One predicate, shared with the boot sync. -import { isFederatedObject } from './federated-object.js'; +// [#21910] And its tenant-anchor refinement, which both cascade walks ask. +import { isFederatedObject, isFederatedInjectedTenantAnchor } from './federated-object.js'; import { applyInMemoryAggregation } from './in-memory-aggregation.js'; import { resolveEngineDeleteDispatch, @@ -15832,7 +15833,7 @@ export class ObjectQL implements IObjectQLEngine { const childName = (child as any)?.name as string | undefined; const fields = (child as any)?.fields as Record | undefined; if (!childName || !fields) continue; - for (const fdef of Object.values(fields)) { + for (const [fieldName, fdef] of Object.entries(fields)) { if (!fdef || (fdef.type !== 'master_detail' && fdef.type !== 'lookup')) continue; // [#18550] The carrier is read through the ONE arbiter, so a // `reference` no reader can read REFUSES here instead of reading as @@ -15856,6 +15857,12 @@ export class ObjectQL implements IObjectQLEngine { let resolvedRef: string | undefined; try { resolvedRef = this.resolveObjectName(ref); } catch { resolvedRef = undefined; } if (ref !== name && resolvedRef !== name) continue; + // [#21910] The scan skips a federated object's injected tenant + // anchor, so this walk does too: the participant test stays the + // scan's own, as the comment above requires. A federated object this + // walk still reaches through any other relation keeps the verdict + // `'split'`, because the scan probes that relation. + if (isFederatedInjectedTenantAnchor(child, fieldName)) continue; out.push(childName); break; } @@ -16324,6 +16331,26 @@ export class ObjectQL implements IObjectQLEngine { try { resolvedRef = this.resolveObjectName(ref); } catch { resolvedRef = undefined; } if (ref !== object && resolvedRef !== object) continue; + // [#21910] A federated object's platform-INJECTED tenant anchor is not + // a reference to `sys_organization`, so it is not a relation to probe. + // On a federated object that column exists in the registered schema + // and nowhere else: the probe below was refused by the driver + // (`INVALID_FILTER`, no such column), its catch propagated the refusal + // as #8895 rules for a missing column, and every organization delete + // answered 500 on a deployment with a federated object bound. + // `buildDriverOptions` and the related-record read already refuse this + // reading of the same column. {@link isFederatedInjectedTenantAnchor} + // says why it is exactly that column, and + // {@link ObjectQL.planCascadeAtomicity} asks it too. + // + // ⛔ The catch below is deliberately NOT widened to pass a missing + // column as benign. That would invert #8895's discriminate or + // propagate for every object, not just this injected column: an + // `organization_id` the author declared on a federated object, and any + // other lookup the author declares on one, stay in the scan, and their + // probe failures still propagate. + if (isFederatedInjectedTenantAnchor(child, fieldName)) continue; + // A master-detail parent owns its children: cascade by default (the // child FK is typically required, so set_null would be invalid). Only // an explicit `restrict` deviates. A plain lookup honors its diff --git a/packages/objectql/src/federated-object.ts b/packages/objectql/src/federated-object.ts index f058111b663..8f5d20860c6 100644 --- a/packages/objectql/src/federated-object.ts +++ b/packages/objectql/src/federated-object.ts @@ -1,5 +1,8 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +import { resolveInjectedColumnProvenance } from '@objectstack/spec/data'; +import { DEFAULT_TENANT_FIELD } from './tenancy/system-write-organization.js'; + /** * Is `schema` a federated object (ADR-0015 `external`), one whose schema is * owned by the REMOTE database? @@ -31,3 +34,42 @@ export function isFederatedObject(schema: unknown): boolean { return (schema as { external?: unknown } | null | undefined)?.external != null; } + +/** + * [#21910] Is `fieldName` a federated object's platform-INJECTED tenant + * anchor: the `organization_id` lookup to `sys_organization` that the + * registry adds and the remote table does not have? + * + * `applySystemFields` injects `organization_id` into every object it + * registers, ADR-0015 `external` ones included (the #7865 ruling, direction + * B), and the platform provisions no storage for a federated object. So on + * one, that column exists in the registered schema and nowhere else, and it + * is never a reference to an organization: no remote row can hold one. The + * engine's referential cascade asks this in both of its walks, so the two + * cannot disagree about which objects take part in an organization delete: + * + * - `ObjectQL.cascadeDeleteRelations` does not probe the remote table on it + * (the probe was refused as an unknown column, and every organization + * delete answered 500); + * - `ObjectQL.planCascadeAtomicity` does not count that column as making the + * federated object a participant, so its participant test stays the scan's. + * + * Three conjuncts, and each one is the narrowing: + * + * - the column is the tenant anchor, `organization_id`. The other anchors + * the registry injects (`owner_id`, `created_by`, ...) are not this + * question; + * - the object is federated, by {@link isFederatedObject}, the same predicate + * `buildDriverOptions` and the related-record read ask; + * - the field is the platform's own definition, by the #7865 provenance + * marker. An `organization_id` the author declared answers `'author'` and + * stays a relation: it may map a real remote column, and its probe keeps + * #8895's discriminate or propagate. + */ +export function isFederatedInjectedTenantAnchor(schema: unknown, fieldName: string): boolean { + return ( + fieldName === DEFAULT_TENANT_FIELD && + isFederatedObject(schema) && + resolveInjectedColumnProvenance(schema, fieldName) === 'injected-unprovisioned' + ); +} diff --git a/packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts b/packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts new file mode 100644 index 00000000000..a9442f45913 --- /dev/null +++ b/packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts @@ -0,0 +1,133 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21910] An owner deletes an organization on a deployment with a federated + * object provisioned, through better-auth's own endpoint, and gets 200. + * + * ## What was broken + * + * Deleting a record runs the engine's referential cascade scan + * (`ObjectQL.cascadeDeleteRelations`): every registered `lookup` / + * `master_detail` field that references the deleted object is probed for + * dependents. The registry injects the platform's tenant anchor, + * `organization_id` (a lookup to `sys_organization`), into a federated + * (ADR-0015 `external`) object too, where it is registered and never + * provisioned on the remote table. The scan read that injection as a real + * reference and probed the showcase's remote `customers` table on + * `organization_id`. The SQL driver refused the filter (`INVALID_FILTER`, no + * such column), the probe's catch propagated it as #8895 rules for a missing + * column, and every organization delete answered 500 once the showcase's + * federated fixture existed. With no fixture the same delete answered 200, + * because an unprovisioned child TABLE is the probe's one benign failure. + * + * The scan now skips a federated object's platform-injected tenant anchor, + * the reading `buildDriverOptions` and the related-record read already apply. + * Nothing else is skipped: an author-declared lookup on a federated object is + * still probed, and its failure still propagates. That half is pinned at the + * seam, in `packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts`. + * + * ## Premises, asserted on the same boot so a green delete cannot be vacuous + * + * - the fixture IS provisioned: the federated object answers its seeded rows, + * so the delete cannot be passing through the missing-table branch; + * - the remote really lacks the column: a system read filtered on + * `organization_id`, the probe the scan used to run, is refused; + * - the field that probe named is the platform's injected anchor + * (`resolveInjectedColumnProvenance` answers `injected-unprovisioned`). + * + * Booted with `orgContext`, the harness's switch for the real single-org + * default-organization bootstrap: it creates the organization and seats the + * admin as its owner, which is what lets better-auth delete it. + * + * The working directory is a temporary one. The showcase's external datasource + * and its fixture both name a cwd-relative SQLite file, so this file's remote + * database is its own, never one another file left in the package directory. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack, { onEnable } from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { resolveInjectedColumnProvenance } from '@objectstack/metadata-core'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +/** The federated object the showcase ships, bound to the remote table `customers`. */ +const FEDERATED = 'showcase_ext_customer'; + +const SYSTEM_CTX = { isSystem: true }; + +async function findRows(ql: any, object: string, where: Record, limit = 50): Promise { + const rows = await ql.find(object, { where, limit, context: SYSTEM_CTX }); + return Array.isArray(rows) ? rows : (rows?.records ?? []); +} + +describe('[#21910] an organization delete with the showcase federated fixture provisioned', () => { + let stack: VerifyStack; + let ql: any; + let token: string; + let orgId: string; + let prevCwd: string; + let dir: string; + + beforeAll(async () => { + prevCwd = process.cwd(); + dir = mkdtempSync(join(tmpdir(), 'dogfood-21910-')); + process.chdir(dir); + // Provision the remote tables, exactly as `os dev` does at boot (the + // harness imports only the stack's default export, so `onEnable` never + // runs on its own). + await onEnable({ logger: { info() {}, warn() {} } } as never); + stack = await bootStack(showcaseStack, { + orgContext: true, + databaseFile: join(dir, 'showcase.db'), + }); + token = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + + const [org] = await findRows(ql, 'sys_organization', { slug: 'default' }, 1); + expect(org, 'PREMISE: the bootstrap created the default organization').toBeTruthy(); + orgId = String(org.id); + const [admin] = await findRows(ql, 'sys_user', { email: 'admin@objectos.ai' }, 1); + const seats = await findRows(ql, 'sys_member', { user_id: String(admin?.id), organization_id: orgId }, 5); + expect(seats.map((m) => m.role), 'PREMISE: the admin owns the organization').toEqual(['owner']); + }, 240_000); + + afterAll(async () => { + await stack?.stop?.(); + if (prevCwd) process.chdir(prevCwd); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('PREMISE: the fixture is provisioned, and its remote table refuses a filter on the injected organization_id', async () => { + const listed = await stack.apiAs(token, 'GET', `/data/${FEDERATED}`); + expect(listed.status, await listed.clone().text()).toBe(200); + const body: any = await listed.json(); + const rows: unknown[] = body?.records ?? body?.data ?? []; + expect(rows.length, 'the remote customers table answers its seeded rows').toBeGreaterThan(0); + + const schema = ql.getSchema(FEDERATED); + expect(schema?.external, `${FEDERATED} is federated`).toBeTruthy(); + expect(resolveInjectedColumnProvenance(schema, 'organization_id')).toBe('injected-unprovisioned'); + + // The read the cascade scan used to issue: SYSTEM identity, filtered on + // the injected column. It is refused, so a scan that still probed this + // object could not answer the delete below with 200. + const refused: any = await findRows(ql, FEDERATED, { organization_id: orgId }, 1).then( + () => null, + (e: unknown) => e, + ); + expect(refused, 'the remote has no organization_id column').not.toBeNull(); + expect(refused.code).toBe('INVALID_FILTER'); + }); + + it('the owner deletes the organization: 200, the row is gone, and the federated rows are untouched', async () => { + const before = await findRows(ql, FEDERATED, {}, 500); + + const deleted = await stack.apiAs(token, 'POST', '/auth/organization/delete', { organizationId: orgId }); + expect(deleted.status, await deleted.clone().text()).toBe(200); + + expect(await findRows(ql, 'sys_organization', { id: orgId }, 1)).toHaveLength(0); + expect((await findRows(ql, FEDERATED, {}, 500)).length).toBe(before.length); + }); +});