-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathauth-manager.optional-plugin-isolation.test.ts
More file actions
219 lines (197 loc) · 7.96 KB
/
Copy pathauth-manager.optional-plugin-isolation.test.ts
File metadata and controls
219 lines (197 loc) · 7.96 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
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
//
// Regression tests for optional-plugin isolation in AuthManager.buildPluginList.
//
// The 15.1.0 incident: `@better-auth/oauth-provider` (resolved to 1.6.23 by
// downstream installs while the workspace tested 1.7.0-rc.1) threw
// `Cannot set properties of undefined (setting 'modelName')` during plugin
// construction. Because the better-auth instance is built lazily per request,
// that single optional plugin took down EVERY auth endpoint with a 500.
//
// Unlike auth-manager.test.ts (which mocks `better-auth` itself), this file
// runs the REAL better-auth against its in-memory adapter and only wraps two
// plugin factories in controllable failure switches — so "sign-up returns
// 200" is asserted against the genuine request pipeline.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AuthManager } from './auth-manager';
const failures = vi.hoisted(() => ({ oauthProvider: false, bearer: false }));
vi.mock('@better-auth/oauth-provider', async (importOriginal) => {
const actual = await importOriginal<Record<string, any>>();
return {
...actual,
oauthProvider: (...args: any[]) => {
if (failures.oauthProvider) {
// Byte-for-byte the 15.1.0 incident error (1.6/1.7 family mix).
throw new TypeError("Cannot set properties of undefined (setting 'modelName')");
}
return actual.oauthProvider(...args);
},
};
});
vi.mock('better-auth/plugins/bearer', async (importOriginal) => {
const actual = await importOriginal<Record<string, any>>();
return {
...actual,
bearer: (...args: any[]) => {
if (failures.bearer) throw new TypeError('bearer exploded (simulated)');
return actual.bearer(...args);
},
};
});
/**
* Minimal in-memory IDataEngine covering exactly the surface the ObjectQL
* adapter drives (insert / findOne / find / count / update / delete with the
* eq/$ne/$in/$gt/$gte/$lt/$lte/$regex filter shapes convertWhere produces),
* so the REAL sign-up pipeline can persist users/sessions/accounts.
*/
const createMemoryEngine = () => {
const tables = new Map<string, any[]>();
const rows = (name: string) => {
if (!tables.has(name)) tables.set(name, []);
return tables.get(name)!;
};
const eq = (a: any, b: any) =>
a instanceof Date || b instanceof Date
? new Date(a as any).getTime() === new Date(b as any).getTime()
: a === b;
const matches = (row: any, where: Record<string, any> = {}) =>
Object.entries(where).every(([k, v]) => {
const actual = row[k];
if (v && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Date)) {
if ('$ne' in v) return !eq(actual, v.$ne);
if ('$in' in v) return (v.$in as any[]).some((x) => eq(actual, x));
if ('$gt' in v) return actual > v.$gt;
if ('$gte' in v) return actual >= v.$gte;
if ('$lt' in v) return actual < v.$lt;
if ('$lte' in v) return actual <= v.$lte;
if ('$regex' in v) return new RegExp(String(v.$regex)).test(String(actual ?? ''));
}
return eq(actual, v);
});
let seq = 0;
return {
async insert(name: string, data: any) {
const row = { id: data.id ?? `row_${++seq}`, ...data };
rows(name).push(row);
return { ...row };
},
async findOne(name: string, q: any = {}) {
return rows(name).find((r) => matches(r, q.where)) ?? null;
},
async find(name: string, q: any = {}) {
let out = rows(name).filter((r) => matches(r, q.where));
const order = q.orderBy?.[0];
if (order) {
out = [...out].sort(
(a, b) => (a[order.field] > b[order.field] ? 1 : -1) * (order.order === 'desc' ? -1 : 1),
);
}
if (q.offset) out = out.slice(q.offset);
if (q.limit) out = out.slice(0, q.limit);
return out.map((r) => ({ ...r }));
},
async count(name: string, q: any = {}) {
return rows(name).filter((r) => matches(r, q.where)).length;
},
async update(name: string, patch: any) {
const row = rows(name).find((r) => r.id === patch.id);
if (!row) return null;
Object.assign(row, patch);
return { ...row };
},
async delete(name: string, q: any = {}) {
const table = rows(name);
const keep = table.filter((r) => !matches(r, q.where));
tables.set(name, keep);
return table.length - keep.length;
},
};
};
describe('AuthManager – optional better-auth plugin isolation', () => {
let errorSpy: ReturnType<typeof vi.spyOn>;
const makeManager = () =>
new AuthManager({
secret: 'test-secret-at-least-32-chars-long!!',
baseUrl: 'http://localhost:3000',
dataEngine: createMemoryEngine() as any,
plugins: { oidcProvider: true },
});
const signUp = (manager: AuthManager, email: string) =>
manager.handleRequest(
new Request('http://localhost:3000/api/v1/auth/sign-up/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
password: 'S3cure!Passw0rd-isolation',
name: 'Isolation Test',
}),
}),
);
beforeEach(() => {
failures.oauthProvider = false;
failures.bearer = false;
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
errorSpy.mockRestore();
});
it('healthy control: oidcProvider constructs for real and sign-up returns 200', async () => {
const manager = makeManager();
const response = await signUp(manager, 'healthy@example.com');
expect(response.status).toBe(200);
expect(manager.getDegradedAuthFeatures()).toEqual([]);
});
it('15.1.0 regression: a throwing oauthProvider is skipped and sign-up still returns 200', async () => {
failures.oauthProvider = true;
const manager = makeManager();
const response = await signUp(manager, 'degraded@example.com');
expect(response.status).toBe(200);
const body: any = await response.json();
expect(body?.user?.email).toBe('degraded@example.com');
// The failure is recorded (jwt + oauthProvider land atomically, so the
// whole oidcProvider unit is what degrades)…
expect(manager.getDegradedAuthFeatures()).toEqual([
expect.objectContaining({
feature: 'oidcProvider',
error: expect.stringContaining('modelName'),
}),
]);
// …and loudly: one actionable console.error naming the feature.
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('Optional auth feature "oidcProvider" failed to initialize'),
expect.any(TypeError),
);
});
it('get-session also survives a degraded optional plugin', async () => {
failures.oauthProvider = true;
const manager = makeManager();
const response = await manager.handleRequest(
new Request('http://localhost:3000/api/v1/auth/get-session'),
);
expect(response.status).toBe(200);
});
it('core plugin (bearer) still fails hard — no fail-open for security-bearing plugins', async () => {
failures.bearer = true;
const manager = makeManager();
await expect(signUp(manager, 'core@example.com')).rejects.toThrow(
'bearer exploded (simulated)',
);
// Core failures are NOT recorded as degraded features — the instance
// never came up at all.
expect(manager.getDegradedAuthFeatures()).toEqual([]);
});
it('degraded state resets when the instance is rebuilt with the fault gone', async () => {
failures.oauthProvider = true;
const manager = makeManager();
await signUp(manager, 'rebuild-a@example.com');
expect(manager.getDegradedAuthFeatures()).toHaveLength(1);
// Simulate the operator fixing the underlying issue + a config-driven
// rebuild (applyConfigPatch resets the lazy instance).
failures.oauthProvider = false;
manager.applyConfigPatch({});
const response = await signUp(manager, 'rebuild-b@example.com');
expect(response.status).toBe(200);
expect(manager.getDegradedAuthFeatures()).toEqual([]);
});
});