-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathbetter-auth-schema-parity.test.ts
More file actions
267 lines (250 loc) · 11.8 KB
/
Copy pathbetter-auth-schema-parity.test.ts
File metadata and controls
267 lines (250 loc) · 11.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
/**
* Drift gate: every column the INSTALLED better-auth version can write must
* exist on the platform object that backs it.
*
* ## Why this is a separate guard from ADR-0092 D7
*
* `managed-extension-fields.test.ts` derives better-auth's surface from
* `getAuthTables()` too, but it answers the opposite question: *does one of
* OUR extension fields collide with one of THEIRS?* A collision is a field
* present on both sides. This gate catches the other half — a field present
* only on better-auth's side, which D7 waves straight through because there is
* nothing to collide with. A dependency bump that ADDS a model field therefore
* built clean and failed at runtime, twice:
*
* - 1.7's `oauthAccessToken.authorizationCodeId` → 500 at the token endpoint,
* which broke platform SSO for every environment. Closed by the per-plugin
* `oauth-provider-schema-parity.test.ts`, whose check this file generalizes.
* - 1.7.0-rc.1's `team.memberCount` / `teamMember.membershipKey` → 500 on
* `organization/create` (the plugin auto-creates a default team when
* `teams.enabled`, which is the auth-manager default), leaving a committed
* org row with no team behind it. #3624.
*
* Same failure shape both times, in a model nobody had written a per-plugin
* gate for — so this covers the auth manager's WHOLE model surface at once
* rather than one more plugin.
*
* ## What it checks
*
* `getAuthTables()` merges the core models with every plugin's schema and
* applies our `fields` overrides as `fieldName`, exactly as better-auth's
* adapter resolves a column: `field.fieldName ?? key`. Two ways to fail:
*
* 1. **Unmapped model** — better-auth would write a table no platform object
* provisions.
* 2. **Unprovisioned column** — the resolved column is absent from the
* object's fields. Note that an UNMAPPED camelCase field fails here too
* (it resolves to `memberCount`, not `member_count`), so one assertion
* catches both "forgot the snake_case mapping" and "forgot the column".
*
* `@better-auth/oauth-provider` is deliberately absent: it ships as its own
* package with its own pinned version and already has the dedicated gate named
* above.
*
* `@better-auth/sso` / `@better-auth/scim` accept no `schema` option, so
* `getAuthTables()` cannot see them — they were the hole this gate shipped
* with (#3653). The second describe block below closes it by reading each
* plugin's OWN declared schema and resolving columns the way the ADAPTER
* does for a bridged model (mechanical camelCase → snake_case in
* `objectql-adapter.ts`), which is the rule that actually governs their
* writes.
*/
import { describe, expect, it } from 'vitest';
import { getAuthTables } from 'better-auth/db';
import { organization, twoFactor, admin } from 'better-auth/plugins';
import { phoneNumber } from 'better-auth/plugins/phone-number';
import { jwt } from 'better-auth/plugins/jwt';
import { deviceAuthorization } from 'better-auth/plugins/device-authorization';
import { sso } from '@better-auth/sso';
import { scim } from '@better-auth/scim';
import {
SysAccount,
SysDeviceCode,
SysInvitation,
SysJwks,
SysMember,
SysOrganization,
SysSession,
SysTeam,
SysTeamMember,
SysScimProvider,
SysSsoProvider,
SysTwoFactor,
SysUser,
SysVerification,
} from '@objectstack/platform-objects/identity';
import {
AUTH_ACCOUNT_CONFIG,
AUTH_SESSION_CONFIG,
AUTH_USER_CONFIG,
AUTH_VERIFICATION_CONFIG,
buildAdminPluginSchema,
buildDeviceAuthorizationPluginSchema,
buildJwtPluginSchema,
buildOrganizationPluginSchema,
buildPhoneNumberPluginSchema,
buildTwoFactorPluginSchema,
} from './auth-schema-config.js';
import { resolveProtocolName } from './objectql-adapter.js';
type PlatformObject = { name: string; fields?: Record<string, unknown> };
/** Object name → the platform object that provisions its physical table. */
const PLATFORM_OBJECTS: Record<string, PlatformObject> = Object.fromEntries(
([
SysUser, SysSession, SysAccount, SysVerification,
SysOrganization, SysMember, SysInvitation, SysTeam, SysTeamMember,
SysTwoFactor, SysDeviceCode, SysJwks,
// Bridged at the adapter layer rather than via a plugin `schema` option —
// see the sso/scim block at the bottom of this file (#3653).
SysSsoProvider, SysScimProvider,
] as unknown as PlatformObject[]).map((o) => [o.name, o]),
);
/**
* The model surface the auth manager actually configures — same option shapes
* it passes in `auth-manager.ts`, so the tables this derives are the tables a
* booted environment gets. Plugins that are feature-flagged off in some
* deployments are still included: the column has to exist before the flag can
* be turned on.
*/
function authTables() {
return getAuthTables({
user: AUTH_USER_CONFIG,
session: AUTH_SESSION_CONFIG,
account: AUTH_ACCOUNT_CONFIG,
verification: AUTH_VERIFICATION_CONFIG,
plugins: [
// `teams.enabled` mirrors the auth-manager default. Without it the team
// models drop out of the surface entirely and this gate would have gone
// green through #3624 — the exact hole being closed.
organization({ teams: { enabled: true }, schema: buildOrganizationPluginSchema() }),
twoFactor({ schema: buildTwoFactorPluginSchema() }),
admin({ schema: buildAdminPluginSchema() }),
phoneNumber({ schema: buildPhoneNumberPluginSchema() }),
jwt({ schema: buildJwtPluginSchema() }),
deviceAuthorization({ schema: buildDeviceAuthorizationPluginSchema() }),
],
} as never) as Record<
string,
{ modelName?: string; fields?: Record<string, { fieldName?: string }> }
>;
}
describe('better-auth schema ↔ platform-objects parity (#3624)', () => {
const tables = authTables();
it('derives a non-empty surface (the gate must not pass vacuously)', () => {
expect(Object.keys(tables).length).toBeGreaterThan(0);
// Teams are the regression under test: if the org plugin ever stops
// reporting them, every team assertion below would silently vanish.
expect(Object.keys(tables)).toContain('team');
expect(Object.keys(tables)).toContain('teamMember');
});
it('every better-auth model maps to a platform object', () => {
const unmapped = Object.entries(tables)
.map(([model, table]) => ({ model, table: table.modelName ?? model }))
.filter(({ table }) => !PLATFORM_OBJECTS[table])
.map(({ model, table }) => `${model} → ${table}`);
expect(
unmapped,
'better-auth would write tables no platform object provisions: '
+ `${unmapped.join(', ')} — declare the object in packages/platform-objects/src/identity/ `
+ 'and map it via a modelName in auth-schema-config.ts (or, if the model is intentionally '
+ 'unused, drop the plugin from this gate with a note).',
).toEqual([]);
});
for (const [model, table] of Object.entries(tables)) {
const tableName = table.modelName ?? model;
it(`every ${model} column exists on ${tableName}`, () => {
const object = PLATFORM_OBJECTS[tableName];
if (!object) return; // reported by the mapping assertion above
// better-auth always owns the primary key, whatever the object declares.
const declared = new Set(['id', ...Object.keys(object.fields ?? {})]);
const missing = Object.entries(table.fields ?? {})
.map(([key, field]) => field.fieldName ?? key)
.filter((column) => !declared.has(column));
expect(
missing,
`columns better-auth can write to ${tableName} but the platform object does not declare: `
+ `${missing.join(', ')} — add the field(s) to packages/platform-objects/src/identity/ and, `
+ 'when camelCase ≠ snake_case, a fieldName mapping in auth-schema-config.ts. A camelCase '
+ 'name here means the mapping is missing, not just the column.',
).toEqual([]);
});
}
});
// ---------------------------------------------------------------------------
// @better-auth/sso + @better-auth/scim (#3653)
// ---------------------------------------------------------------------------
/**
* These two plugins hardcode their model names and read no `schema` option, so
* `getAuthTables()` above is blind to them. The adapter bridges them instead:
* `AUTH_MODEL_TO_PROTOCOL` maps the model, and `createObjectQLAdapterFactory`
* mechanically camelCase→snake_cases every field of a bridged model on the way
* in. That mechanical rule — not any hand-written mapping — is what decides
* the column they actually write, so it is what this reproduces.
*
* Mirrors the adapter's own `camelToSnake`.
*/
function adapterColumn(field: string): string {
return field.replace(/[A-Z]/g, (c) => '_' + c.toLowerCase());
}
/**
* SCIM models with no platform object, acknowledged rather than silently
* skipped. These four are SCIM **group** provisioning (`/Groups` push from the
* IdP); ObjectStack ships only the provider row today, so an IdP pushing
* groups would write tables that do not exist — filed as its own feature gap.
*
* Pinned as an exact set on purpose: a NEW unmapped model is a build failure,
* so this list can never quietly grow the way the original hole did.
*/
const KNOWN_UNMAPPED_MODELS = new Set([
'scimGroup',
'scimGroupMember',
'scimGroupRole',
'scimGroupRoleGrant',
]);
describe('@better-auth/sso + @better-auth/scim schema ↔ platform-objects parity (#3653)', () => {
const plugins: Array<{ label: string; schema: Record<string, { fields?: Record<string, unknown> }> }> = [
{ label: 'sso', schema: (sso() as any).schema },
{ label: 'scim', schema: (scim({} as never) as any).schema },
];
it('both plugins still expose a readable schema (the gate must not pass vacuously)', () => {
for (const { label, schema } of plugins) {
expect(Object.keys(schema ?? {}).length, `${label} exposed no schema`).toBeGreaterThan(0);
}
});
it('the set of models with no platform object is exactly the acknowledged one', () => {
const unmapped = plugins
.flatMap(({ schema }) => Object.keys(schema ?? {}))
.filter((model) => !PLATFORM_OBJECTS[resolveProtocolName(model)]);
expect(
unmapped.sort(),
'a model gained or lost a platform object. A NEW name here means the plugin added a table '
+ 'nothing provisions — declare the object and map it in AUTH_MODEL_TO_PROTOCOL. A name that '
+ 'DISAPPEARED means it is now provisioned — drop it from KNOWN_UNMAPPED_MODELS so it is '
+ 'covered by the column check below.',
).toEqual([...KNOWN_UNMAPPED_MODELS].sort());
});
for (const { label, schema } of [
{ label: 'sso', schema: (sso() as any).schema as Record<string, { fields?: Record<string, unknown> }> },
{ label: 'scim', schema: (scim({} as never) as any).schema as Record<string, { fields?: Record<string, unknown> }> },
]) {
for (const [model, def] of Object.entries(schema ?? {})) {
if (KNOWN_UNMAPPED_MODELS.has(model)) continue;
const objectName = resolveProtocolName(model);
it(`every ${label}/${model} column exists on ${objectName}`, () => {
const object = PLATFORM_OBJECTS[objectName];
expect(object, `${model} must map to a platform object via AUTH_MODEL_TO_PROTOCOL`).toBeDefined();
const declared = new Set(['id', ...Object.keys(object.fields ?? {})]);
const missing = Object.keys(def.fields ?? {})
.map(adapterColumn)
.filter((column) => !declared.has(column));
expect(
missing,
`columns ${label} can write to ${objectName} but the platform object does not declare: `
+ `${missing.join(', ')} — add the field(s) to packages/platform-objects/src/identity/. `
+ 'These plugins take no schema option, so the adapter snake_cases their fields '
+ 'mechanically; there is no mapping to add, only the column.',
).toEqual([]);
});
}
}
});