Skip to content

Commit c10e9c3

Browse files
os-zhuangclaude
andauthored
test(plugin-auth): map twoFactor / jwks / deviceCode into D7 coverage (#7994) (#8121)
The 2026-08-12 ruling adopts Option B: map the three models the #7820 widening made derivable, add their COVERED_OBJECTS entries and retire the three #7770 exemptions. D7 collision coverage goes 9 -> 12 objects. MANAGED_EXTENSION_FIELDS is untouched: it doubles as the ADR-0092 D2 write whitelist, and the new coverage is proven with a synthetic registry through findCollisions() instead of by declaring fields on credential tables. Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3670cf9 commit c10e9c3

1 file changed

Lines changed: 144 additions & 37 deletions

File tree

‎packages/plugins/plugin-auth/src/managed-extension-fields.test.ts‎

Lines changed: 144 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,29 @@
7373
* - `findCollisions()` is a pure function, exercised against a SYNTHETIC
7474
* registry that declares a column only a widened plugin contributes — so the
7575
* red direction is pinned in-repo, not merely asserted in a PR description.
76+
*
77+
* ## The third axis: mapping what the widening made derivable (#7994)
78+
*
79+
* Widening the plugin set had a consequence #7820 did not spend: the models
80+
* `twoFactor` / `jwks` / `deviceCode` started being derived, while their three
81+
* objects stayed in `UNMAPPED_MANAGED_OBJECTS`. The exemptions outlived their
82+
* granted reason — registered because the plugins "are not loaded by this
83+
* call", which had become false — and were restated once in place on the
84+
* weaker reason that the models were merely unmapped. An exemption whose
85+
* justification has to be rewritten to survive is a smell, not a record.
86+
*
87+
* The 2026-08-12 ruling mapped all three and retired the exemptions, taking
88+
* coverage from nine objects to twelve, and explicitly amended the #7820
89+
* ruling's 「保持不动」 line to have scoped that card only. `sys_two_factor`
90+
* and `sys_jwks` hold credential material (`secret`, `privateKey`), which is
91+
* precisely the kind of column D7 exists to stop an extension field from
92+
* silently taking ownership of.
93+
*
94+
* Its acceptance criterion is the one that matters for any coverage change,
95+
* and is worth copying: not "the map has twelve entries" — a count derived
96+
* from the structure it checks, which cannot fail — but a SYNTHETIC COLLISION
97+
* PER TABLE that must turn this suite red, and does not when the mapping is
98+
* removed. That is the last describe block but one.
7699
*/
77100

78101
import { describe, it, expect } from 'vitest';
@@ -113,6 +136,17 @@ const MODEL_TO_OBJECT: Record<string, string> = {
113136
invitation: 'sys_invitation',
114137
team: 'sys_team',
115138
teamMember: 'sys_team_member',
139+
// The three opt-in-plugin models #7770 exempted and the 2026-08-12 ruling
140+
// mapped (#7994). Each is derivable only because #7820 widened the plugin
141+
// set above to everything `auth-manager.ts` can assemble: `twoFactor()`,
142+
// `jwt()` and `deviceAuthorization()` are the plugins that contribute them,
143+
// so deleting any of those from AUTH_MANAGER_PLUGINS silently withdraws the
144+
// object from the collision loop again. Both halves are pinned — the
145+
// coverage assertion below, and a per-table synthetic collision at the
146+
// bottom of this file.
147+
twoFactor: 'sys_two_factor',
148+
jwks: 'sys_jwks',
149+
deviceCode: 'sys_device_code',
116150
};
117151

118152
interface UnmappedManagedObject {
@@ -157,41 +191,22 @@ const UNMAPPED_MANAGED_OBJECTS: Record<string, UnmappedManagedObject> = {
157191
noBetterAuthColumns: true,
158192
},
159193

160-
// ── Opt-in core plugins: derived, but deliberately not mapped ─────────────
161-
// #7770 registered these three because this file's getAuthTables() call did
162-
// not load their plugins. #7820 widened that call to the auth manager's whole
163-
// set, so the models ARE derived now — the reasons below are restated to say
164-
// so rather than keep claiming an absence that stopped being true. What did
165-
// NOT change is the disposition: mapping them is a coverage EXPANSION #7770
166-
// deferred and the 2026-08-12 ruling kept unmoved, so `MODEL_TO_OBJECT` still
167-
// does not name them and `betterAuthFieldsByObject()` still drops them.
194+
// ── sys_two_factor / sys_jwks / sys_device_code are NO LONGER HERE ────────
195+
// #7770 exempted all three because this file's getAuthTables() call did not
196+
// load their plugins. #7820 widened the call, which made that reason false,
197+
// and the exemptions survived one restatement on a weaker reason: the models
198+
// were derived, merely unmapped. The 2026-08-12 ruling (#7994) ended that —
199+
// 「map twoFactor / jwks / deviceCode into MODEL_TO_OBJECT, add the three
200+
// COVERED_OBJECTS entries, and retire their three #7770 exemptions」 —
201+
// explicitly amending the #7820 ruling's 「保持不动」 line to have scoped
202+
// that card only. Coverage went 9 → 12 objects.
168203
//
169-
// The safety note is the load-bearing half and is untouched: this costs
170-
// nothing while no extension field is declared on them, and the moment one
171-
// is, the "nothing is silently skipped" assertion below turns red and the
172-
// mapping has to be done properly. Column-level parity for all three is
173-
// covered meanwhile by `better-auth-schema-parity.test.ts`.
174-
sys_two_factor: {
175-
reason:
176-
"Owned by better-auth's opt-in `twoFactor` plugin (model `twoFactor`). The plugin is in this "
177-
+ "file's getAuthTables() call since #7820, but MODEL_TO_OBJECT deliberately does not map the "
178-
+ 'object — mapping it is a coverage expansion, not part of that fix. No extension field is '
179-
+ 'declared on it.',
180-
},
181-
sys_device_code: {
182-
reason:
183-
"Owned by better-auth's opt-in `deviceAuthorization` plugin (model `deviceCode`). The plugin "
184-
+ "is in this file's getAuthTables() call since #7820, but MODEL_TO_OBJECT deliberately does "
185-
+ 'not map the object — mapping it is a coverage expansion, not part of that fix. No extension '
186-
+ 'field is declared on it.',
187-
},
188-
sys_jwks: {
189-
reason:
190-
"Owned by better-auth's opt-in `jwt` plugin (model `jwks`). The plugin is in this file's "
191-
+ 'getAuthTables() call since #7820, but MODEL_TO_OBJECT deliberately does not map the object '
192-
+ '— mapping it is a coverage expansion, not part of that fix. No extension field is declared '
193-
+ 'on it.',
194-
},
204+
// ⛔ Do not re-add them here to silence a failure. An exemption is what makes
205+
// the collision loop skip a table, and these three hold credential-adjacent
206+
// columns (`twoFactor.secret`, `jwks.privateKey`): re-exempting reopens
207+
// exactly the invisible-hole shape #7770 was filed for. If the real fix is
208+
// to stop deriving one of them, remove its plugin from AUTH_MANAGER_PLUGINS
209+
// and say why — the coverage assertion will then demand this list change too.
195210

196211
// ── Plugins getAuthTables() cannot ADDRESS as an ObjectStack object (#3653) ─
197212
// Both plugins are now in the call (#7820), so their models do appear in the
@@ -231,7 +246,7 @@ const UNMAPPED_MANAGED_OBJECTS: Record<string, UnmappedManagedObject> = {
231246
const MAPPED_OBJECTS: readonly string[] = Object.values(MODEL_TO_OBJECT);
232247

233248
/**
234-
* The nine objects this guard has real collision coverage for, written out as
249+
* The twelve objects this guard has real collision coverage for, written out as
235250
* a LITERAL rather than derived from `MODEL_TO_OBJECT`.
236251
*
237252
* Deriving it would have made the pin below unable to see the regression it
@@ -240,6 +255,12 @@ const MAPPED_OBJECTS: readonly string[] = Object.values(MODEL_TO_OBJECT);
240255
* collision loop. Measured — an ablation that removed `teamMember` left a
241256
* derived version of this pin green and was caught only by the accounting
242257
* assertion. Two independent lists, so one of them has to be wrong out loud.
258+
*
259+
* ⚠️ This list is a COUNT, and a count certifies nothing on its own: it says
260+
* which objects the loop reaches, not that reaching them judges anything. The
261+
* assertion that the three objects added in #7994 are really judged is the
262+
* per-table synthetic collision at the bottom of this file, which fails the
263+
* moment a mapping is deleted.
243264
*/
244265
const COVERED_OBJECTS: readonly string[] = [
245266
'sys_user',
@@ -251,6 +272,10 @@ const COVERED_OBJECTS: readonly string[] = [
251272
'sys_invitation',
252273
'sys_team',
253274
'sys_team_member',
275+
// #7994 — the three opt-in-plugin tables, mapped by the 2026-08-12 ruling.
276+
'sys_two_factor',
277+
'sys_jwks',
278+
'sys_device_code',
254279
];
255280

256281
const HERE = dirname(fileURLToPath(import.meta.url));
@@ -472,14 +497,14 @@ describe('managed extension fields (ADR-0105 D7)', () => {
472497
expect(byObject.sys_user?.size ?? 0).toBeGreaterThan(0);
473498
});
474499

475-
it('keeps the nine mapped models covered, exactly (coverage cannot shrink)', () => {
500+
it('keeps the twelve mapped models covered, exactly (coverage cannot shrink)', () => {
476501
// Two ways an object can silently withdraw from the collision loop, and
477502
// this pins both against the same literal: the LIBRARY stops emitting the
478503
// model, or the MAP stops naming it. Either is the #7770 shape arriving
479504
// as a regression rather than as a pre-existing absence.
480505
expect(
481506
[...MAPPED_OBJECTS].sort(),
482-
'MODEL_TO_OBJECT no longer maps exactly the nine covered objects — a deleted mapping '
507+
'MODEL_TO_OBJECT no longer maps exactly the twelve covered objects — a deleted mapping '
483508
+ 'withdraws an object from the collision loop as surely as a missing one ever did.',
484509
).toEqual([...COVERED_OBJECTS].sort());
485510
expect(
@@ -740,6 +765,88 @@ describe('the collision rule goes red on a FUTURE overlap (#7820)', () => {
740765
});
741766
});
742767

768+
describe('the three newly mapped tables are really judged (#7994)', () => {
769+
// The 2026-08-12 ruling's acceptance criterion, stated as tests rather than
770+
// as a count. "Coverage goes 9 → 12" is satisfied by the COVERED_OBJECTS pin
771+
// above — but that pin only proves the collision loop REACHES these tables.
772+
// What follows proves it JUDGES them: each case names a column better-auth
773+
// really owns on that table at the pinned version and requires the rule to
774+
// report it.
775+
//
776+
// These fail the moment a mapping is deleted, which is what makes them worth
777+
// having: with `twoFactor` out of MODEL_TO_OBJECT, `byObject.sys_two_factor`
778+
// is undefined, `findCollisions`'s `if (!owned) continue` swallows the
779+
// synthetic field, the call returns `[]` and every expectation here goes red.
780+
// (Verified by ablation before this landed, in both directions.)
781+
//
782+
// Deliberately driven through a SYNTHETIC registry rather than by adding
783+
// entries to `MANAGED_EXTENSION_FIELDS`: that map is also the ADR-0092 D2
784+
// write whitelist, so a column added there widens what a generic write
785+
// surface may touch. On `sys_two_factor.secret` or `sys_jwks.private_key`
786+
// that is a security change, and D7 coverage must never be bought with one.
787+
const byObject = betterAuthFieldsByObject();
788+
789+
const OWNED: Array<[object_: string, snake: string, camel: string, plugin: string]> = [
790+
['sys_two_factor', 'backup_codes', 'backupCodes', 'twoFactor'],
791+
['sys_jwks', 'private_key', 'privateKey', 'jwt'],
792+
['sys_device_code', 'user_code', 'userCode', 'deviceAuthorization'],
793+
];
794+
795+
for (const [object_, snake, camel, plugin] of OWNED) {
796+
it(`reports a synthetic collision on ${object_} (both spellings)`, () => {
797+
const why =
798+
`${object_} was mapped into MODEL_TO_OBJECT by the 2026-08-12 ruling so that a platform `
799+
+ `extension field colliding with better-auth's own column is caught here. A green result `
800+
+ `means the table is back OUTSIDE the collision loop — most likely its mapping was `
801+
+ `deleted, or the ${plugin} plugin left AUTH_MANAGER_PLUGINS so no model is derived for `
802+
+ `it. Either way this guard is answering about a table it is not looking at, which is the `
803+
+ `#7770 defect. Do NOT "fix" this by re-adding an UNMAPPED_MANAGED_OBJECTS exemption.`;
804+
expect(findCollisions({ [object_]: new Set([snake]) }, byObject), why)
805+
.toEqual([`${object_}.${snake}`]);
806+
// better-auth authors camelCase; `toSnakeCase` records both spellings, so
807+
// a registry that happened to use the library's own spelling must not
808+
// slip past a snake_case-only comparison.
809+
expect(findCollisions({ [object_]: new Set([camel]) }, byObject), why)
810+
.toEqual([`${object_}.${camel}`]);
811+
});
812+
}
813+
814+
it('stays silent on plausible extension names these tables do NOT own', () => {
815+
// Non-vacuity, per table: a rule that reported every field would satisfy
816+
// the three cases above while making the real registry assertion useless.
817+
// Each name below is one a future ObjectStack extension might genuinely
818+
// take, and none is in better-auth's surface at the pinned version.
819+
expect(findCollisions({ sys_two_factor: new Set(['mfa_policy_id']) }, byObject)).toEqual([]);
820+
expect(findCollisions({ sys_jwks: new Set(['rotation_note']) }, byObject)).toEqual([]);
821+
expect(findCollisions({ sys_device_code: new Set(['approved_by_id']) }, byObject)).toEqual([]);
822+
});
823+
824+
it('derives a credential-bearing column set for each of the three', () => {
825+
// The ② lens of the card: these tables are why the expansion was worth
826+
// doing. If better-auth ever moves `secret` / `privateKey` / `deviceCode`
827+
// off these models, the collision cases above would start passing for the
828+
// wrong reason (nothing owned, nothing to collide with) — this says so
829+
// first, and names the column that moved.
830+
expect(byObject.sys_two_factor?.has('secret'), 'twoFactor.secret').toBe(true);
831+
expect(byObject.sys_jwks?.has('private_key'), 'jwks.privateKey').toBe(true);
832+
expect(byObject.sys_device_code?.has('device_code'), 'deviceCode.deviceCode').toBe(true);
833+
});
834+
835+
it('carries no exemption for the three tables any more', () => {
836+
// The ruling's second half, in-repo. The stale-entry assertion above
837+
// already fails on a mapped-AND-exempt object; this states the intent
838+
// directly, so the next reader sees a removal that was decided rather
839+
// than an entry someone lost track of.
840+
for (const object_ of ['sys_two_factor', 'sys_jwks', 'sys_device_code']) {
841+
expect(
842+
UNMAPPED_MANAGED_OBJECTS[object_],
843+
`${object_} is exempt again. An exemption is what makes the collision loop skip a table, `
844+
+ `so re-adding one here silently un-does the 2026-08-12 ruling's coverage expansion.`,
845+
).toBeUndefined();
846+
}
847+
});
848+
});
849+
743850
describe('sys_api_key exemption premise (#7770)', () => {
744851
it('better-auth still ships no apiKey plugin, so sys_api_key has no model to collide with', async () => {
745852
// This is the whole warrant for `noBetterAuthColumns: true` on

0 commit comments

Comments
 (0)