diff --git a/packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts b/packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts index 786ab78744..d60d80b040 100644 --- a/packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts +++ b/packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts @@ -456,6 +456,96 @@ function adapterColumn(field: string): string { return field.replace(/[A-Z]/g, (c) => '_' + c.toLowerCase()); } +/** + * Construct `@better-auth/scim` with options BOTH the pinned prerelease and the + * stable line accept, so this gate reaches its diff-printing stage instead of + * dying in collection (#11380). + * + * The gate was written down as the safety net for the `@better-auth/scim` + * migration (#3653): the day the pin moves off `1.7.0-rc.1`, it is supposed to + * name every model whose platform object is missing. It did not open at that + * moment. `scim({})` is legal on the pinned rc.1 — `options` is optional there + * and nothing is read at construction — but the stable factory's FIRST + * statement is `validateConnections(options)`, which reaches + * `options.connections.length` before any existence check. So on stable the + * call throws + * + * TypeError: Cannot read properties of undefined (reading 'length') + * + * and, because the schema is read in the describe body and in the `for` header + * below, it throws during COLLECTION — the whole file dies and prints that + * TypeError instead of the model list it promises. A migrator gets an + * uninformative crash from the guard that exists to inform them: declared, not + * enforced. + * + * `connections: []` alone is not enough on stable — an empty connection list is + * only legal when a bearer-token verifier or the managed catalog resolves + * connections, otherwise `validateConnections` throws + * `BetterAuthError: The scim plugin requires a provisioning connection, bearer + * token verifier, or managed connection catalog.` The verifier below is the + * cheapest way to satisfy that, and is never invoked: this gate only reads the + * schema the plugin declares. Both keys are inert on the pinned rc.1, whose + * factory spreads its options and reads only the keys it knows. + * + * Cheapest is not a compromise on WHAT is compared, which is the half that + * would have made this repair worthless. Measured 2026-08-23 against the + * published 1.7.1: this shape and a realistic static connection + * (`connections: [{ id, credentials: [{ type: 'bearer', id, token }] }]`) + * declare the SAME seven models with the SAME column sets, so the surface this + * gate compares is the one a configured deployment gets rather than an artefact + * of the cheapest constructor call. A connection entry is also not the smaller + * input it looks like: `validateConnections` goes on to read + * `connection.credentials.length`, so a half-specified entry throws the same + * TypeError one line further down. + * + * ⚠️ Deliberately NOT configured: `managedConnections`. The auth manager does + * not pass it, and it is what adds the conditional + * `scimManagedConnection` / `scimManagedCredential` / `scimManagedConnectionEvent` + * trio to the declared schema — so configuring it here would make this gate + * compare a model surface no ObjectStack deployment provisions. + */ +const SCIM_GATE_OPTIONS = { + connections: [], + authentication: { verifyBearerToken: () => null }, +}; + +/** + * The scim plugin, or a failure that says which half broke. + * + * Construction happens at collection time — the `for` header below has to read + * the schema to emit one `it()` per model — so a constructor incompatibility + * can only ever be a collection failure. What it does not have to be is + * illegible: a raw vendor `TypeError` reads as "the gate is broken", which is + * how the last one cost a migration round. Re-throwing with the cause attached + * keeps the vendor error verbatim and says what to do with it. + */ +function constructScim(): { schema?: Record }> } { + try { + return scim(SCIM_GATE_OPTIONS as never) as never; + } catch (cause) { + // The vendor error is spelled INTO the message rather than passed as the + // `Error` constructor's second argument: this package compiles against + // `lib: es2020`, where `ErrorOptions` does not exist, so `new Error(msg, + // { cause })` is a TS2554 that would land in plugin-auth's shrink-only + // TEST_DEBT count. `cause` is still attached for a reader that unwraps it. + const vendor = cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause); + throw Object.assign( + new Error( + 'the installed @better-auth/scim rejected this gate\'s constructor options, so the gate ' + + 'could not reach the model comparison it exists for (#11380). This is a CONSTRUCTOR ' + + 'mismatch, not a parity failure: update SCIM_GATE_OPTIONS above to a shape the installed ' + + `version accepts, then read the model diff the gate prints. Vendor error: ${vendor}`, + ), + { cause }, + ); + } +} + +/** The models `@better-auth/scim` declares on the installed version. */ +function scimSchema(): Record }> { + return constructScim().schema ?? {}; +} + /** * SCIM models with no platform object, acknowledged rather than silently * skipped. These four are SCIM **group** provisioning (`/Groups` push from the @@ -475,7 +565,7 @@ const KNOWN_UNMAPPED_MODELS = new Set([ describe('@better-auth/sso + @better-auth/scim schema ↔ platform-objects parity (#3653)', () => { const plugins: Array<{ label: string; schema: Record }> }> = [ { label: 'sso', schema: (sso() as any).schema }, - { label: 'scim', schema: (scim({} as never) as any).schema }, + { label: 'scim', schema: scimSchema() }, ]; it('are excluded from the getAuthTables() call for the reason this file states (#8122)', () => { @@ -483,7 +573,7 @@ describe('@better-auth/sso + @better-auth/scim schema ↔ platform-objects parit // wording expired silently under a version bump with every gate green // (#8224) — the drift shape the reconciliation above exists to stop, so the // reason these two sit outside that reconciliation is pinned here instead. - const derived = getAuthTables({ plugins: [sso(), scim({} as never)] } as never) as Record< + const derived = getAuthTables({ plugins: [sso(), constructScim()] } as never) as Record< string, { modelName?: string; fields?: Record } >; @@ -530,7 +620,7 @@ describe('@better-auth/sso + @better-auth/scim schema ↔ platform-objects parit for (const { label, schema } of [ { label: 'sso', schema: (sso() as any).schema as Record }> }, - { label: 'scim', schema: (scim({} as never) as any).schema as Record }> }, + { label: 'scim', schema: scimSchema() }, ]) { for (const [model, def] of Object.entries(schema ?? {})) { if (KNOWN_UNMAPPED_MODELS.has(model)) continue;