canopycms-auth-dev 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +324 -0
- package/dist/UserSwitcherButton.d.ts +4 -0
- package/dist/UserSwitcherButton.js +23 -0
- package/dist/UserSwitcherButton.js.map +1 -0
- package/dist/UserSwitcherModal.d.ts +10 -0
- package/dist/UserSwitcherModal.js +19 -0
- package/dist/UserSwitcherModal.js.map +1 -0
- package/dist/cache-writer.d.ts +22 -0
- package/dist/cache-writer.js +42 -0
- package/dist/cache-writer.js.map +1 -0
- package/dist/client.d.ts +18 -0
- package/dist/client.js +32 -0
- package/dist/client.js.map +1 -0
- package/dist/cookie-utils.d.ts +20 -0
- package/dist/cookie-utils.js +39 -0
- package/dist/cookie-utils.js.map +1 -0
- package/dist/dev-plugin.d.ts +71 -0
- package/dist/dev-plugin.js +168 -0
- package/dist/dev-plugin.js.map +1 -0
- package/dist/dev-plugin.test.d.ts +1 -0
- package/dist/dev-plugin.test.js +382 -0
- package/dist/dev-plugin.test.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/jwt-verifier.d.ts +12 -0
- package/dist/jwt-verifier.js +41 -0
- package/dist/jwt-verifier.js.map +1 -0
- package/package.json +62 -0
- package/src/UserSwitcherButton.tsx +46 -0
- package/src/UserSwitcherModal.tsx +63 -0
- package/src/cache-writer.ts +61 -0
- package/src/client.ts +34 -0
- package/src/cookie-utils.ts +44 -0
- package/src/dev-plugin.test.ts +470 -0
- package/src/dev-plugin.ts +241 -0
- package/src/index.ts +11 -0
- package/src/jwt-verifier.ts +46 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
DevAuthPlugin,
|
|
4
|
+
createDevAuthPlugin,
|
|
5
|
+
DEFAULT_USERS,
|
|
6
|
+
DEFAULT_GROUPS,
|
|
7
|
+
DEV_ADMIN_USER_ID,
|
|
8
|
+
} from './dev-plugin'
|
|
9
|
+
import type { DevUser, DevGroup } from './dev-plugin'
|
|
10
|
+
import type { AuthenticationResult } from 'canopycms/auth'
|
|
11
|
+
|
|
12
|
+
// Type guard to assert successful authentication
|
|
13
|
+
function assertSuccess(result: AuthenticationResult): asserts result is AuthenticationResult & {
|
|
14
|
+
success: true
|
|
15
|
+
user: NonNullable<AuthenticationResult['user']>
|
|
16
|
+
} {
|
|
17
|
+
expect(result.success).toBe(true)
|
|
18
|
+
if (!result.success || !result.user) {
|
|
19
|
+
throw new Error('Authentication failed')
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('DevAuthPlugin', () => {
|
|
24
|
+
describe('authenticate', () => {
|
|
25
|
+
it('returns default user when no headers provided', async () => {
|
|
26
|
+
const plugin = new DevAuthPlugin({})
|
|
27
|
+
const result = await plugin.authenticate(new Headers())
|
|
28
|
+
|
|
29
|
+
assertSuccess(result)
|
|
30
|
+
expect(result.user.userId).toBe('dev_user1_2nK8mP4xL9') // user1
|
|
31
|
+
expect(result.user.name).toBe('User One')
|
|
32
|
+
expect(result.user.email).toBe('user1@localhost.dev')
|
|
33
|
+
expect(result.user.externalGroups).toEqual(['team-a', 'team-b'])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('authenticates via X-Test-User header', async () => {
|
|
37
|
+
const plugin = new DevAuthPlugin({})
|
|
38
|
+
const headers = new Headers({ 'X-Test-User': 'admin' })
|
|
39
|
+
const result = await plugin.authenticate(headers)
|
|
40
|
+
|
|
41
|
+
assertSuccess(result)
|
|
42
|
+
expect(result.user.userId).toBe('dev_admin_3xY6zW1qR5') // admin1
|
|
43
|
+
expect(result.user.name).toBe('Admin One')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('authenticates via x-dev-user-id header', async () => {
|
|
47
|
+
const plugin = new DevAuthPlugin({})
|
|
48
|
+
const headers = new Headers({ 'x-dev-user-id': 'dev_user2_7qR3tY6wN2' })
|
|
49
|
+
const result = await plugin.authenticate(headers)
|
|
50
|
+
|
|
51
|
+
assertSuccess(result)
|
|
52
|
+
expect(result.user.userId).toBe('dev_user2_7qR3tY6wN2') // user2
|
|
53
|
+
expect(result.user.name).toBe('User Two')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('authenticates via canopy-dev-user cookie', async () => {
|
|
57
|
+
const plugin = new DevAuthPlugin({})
|
|
58
|
+
const headers = new Headers({
|
|
59
|
+
cookie: 'canopy-dev-user=dev_user3_5vS1pM8kJ4; other=value',
|
|
60
|
+
})
|
|
61
|
+
const result = await plugin.authenticate(headers)
|
|
62
|
+
|
|
63
|
+
assertSuccess(result)
|
|
64
|
+
expect(result.user.userId).toBe('dev_user3_5vS1pM8kJ4') // user3
|
|
65
|
+
expect(result.user.name).toBe('User Three')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('prioritizes X-Test-User over cookie', async () => {
|
|
69
|
+
const plugin = new DevAuthPlugin({})
|
|
70
|
+
const headers = new Headers({
|
|
71
|
+
'X-Test-User': 'admin',
|
|
72
|
+
cookie: 'canopy-dev-user=dev_user1_2nK8mP4xL9',
|
|
73
|
+
})
|
|
74
|
+
const result = await plugin.authenticate(headers)
|
|
75
|
+
|
|
76
|
+
assertSuccess(result)
|
|
77
|
+
expect(result.user.userId).toBe('dev_admin_3xY6zW1qR5') // admin1 from header
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('maps test user keys to dev user IDs', async () => {
|
|
81
|
+
const plugin = new DevAuthPlugin({})
|
|
82
|
+
|
|
83
|
+
const testCases = [
|
|
84
|
+
{ key: 'admin', expectedId: 'dev_admin_3xY6zW1qR5' },
|
|
85
|
+
{ key: 'editor', expectedId: 'dev_user1_2nK8mP4xL9' },
|
|
86
|
+
{ key: 'viewer', expectedId: 'dev_user2_7qR3tY6wN2' },
|
|
87
|
+
{ key: 'reviewer', expectedId: 'dev_reviewer_9aB4cD2eF7' },
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
for (const { key, expectedId } of testCases) {
|
|
91
|
+
const headers = new Headers({ 'X-Test-User': key })
|
|
92
|
+
const result = await plugin.authenticate(headers)
|
|
93
|
+
|
|
94
|
+
assertSuccess(result)
|
|
95
|
+
expect(result.user.userId).toBe(expectedId)
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('returns failure for unknown user ID', async () => {
|
|
100
|
+
const plugin = new DevAuthPlugin({})
|
|
101
|
+
const headers = new Headers({ 'x-dev-user-id': 'unknown_user' })
|
|
102
|
+
const result = await plugin.authenticate(headers)
|
|
103
|
+
|
|
104
|
+
expect(result.success).toBe(false)
|
|
105
|
+
if (!result.success) {
|
|
106
|
+
expect(result.error).toContain('Dev user not found')
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('uses custom default user when specified', async () => {
|
|
111
|
+
const plugin = new DevAuthPlugin({
|
|
112
|
+
defaultUserId: 'dev_admin_3xY6zW1qR5', // admin1
|
|
113
|
+
})
|
|
114
|
+
const result = await plugin.authenticate(new Headers())
|
|
115
|
+
|
|
116
|
+
assertSuccess(result)
|
|
117
|
+
expect(result.user.userId).toBe('dev_admin_3xY6zW1qR5')
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('searchUsers', () => {
|
|
122
|
+
it('returns all users when query is empty', async () => {
|
|
123
|
+
const plugin = new DevAuthPlugin({})
|
|
124
|
+
const results = await plugin.searchUsers('')
|
|
125
|
+
|
|
126
|
+
expect(results).toHaveLength(5)
|
|
127
|
+
expect(results[0].id).toBe('dev_user1_2nK8mP4xL9')
|
|
128
|
+
expect(results[0].name).toBe('User One')
|
|
129
|
+
expect(results[0].email).toBe('user1@localhost.dev')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('filters users by name', async () => {
|
|
133
|
+
const plugin = new DevAuthPlugin({})
|
|
134
|
+
const results = await plugin.searchUsers('admin')
|
|
135
|
+
|
|
136
|
+
expect(results).toHaveLength(1)
|
|
137
|
+
expect(results[0].name).toBe('Admin One')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('filters users by email', async () => {
|
|
141
|
+
const plugin = new DevAuthPlugin({})
|
|
142
|
+
const results = await plugin.searchUsers('reviewer1@')
|
|
143
|
+
|
|
144
|
+
expect(results).toHaveLength(1)
|
|
145
|
+
expect(results[0].id).toBe('dev_reviewer_9aB4cD2eF7')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('is case insensitive', async () => {
|
|
149
|
+
const plugin = new DevAuthPlugin({})
|
|
150
|
+
const results = await plugin.searchUsers('USER')
|
|
151
|
+
|
|
152
|
+
expect(results.length).toBeGreaterThan(0)
|
|
153
|
+
expect(results.some((u) => u.name.toLowerCase().includes('user'))).toBe(true)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('respects limit parameter', async () => {
|
|
157
|
+
const plugin = new DevAuthPlugin({})
|
|
158
|
+
const results = await plugin.searchUsers('', 2)
|
|
159
|
+
|
|
160
|
+
expect(results).toHaveLength(2)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('works with custom users', async () => {
|
|
164
|
+
const customUsers: DevUser[] = [
|
|
165
|
+
{
|
|
166
|
+
userId: 'custom_1',
|
|
167
|
+
name: 'Custom User',
|
|
168
|
+
email: 'custom@test.com',
|
|
169
|
+
externalGroups: [],
|
|
170
|
+
},
|
|
171
|
+
]
|
|
172
|
+
const plugin = new DevAuthPlugin({ users: customUsers })
|
|
173
|
+
const results = await plugin.searchUsers('custom')
|
|
174
|
+
|
|
175
|
+
expect(results).toHaveLength(1)
|
|
176
|
+
expect(results[0].id).toBe('custom_1')
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe('getUserMetadata', () => {
|
|
181
|
+
it('returns user metadata for valid user', async () => {
|
|
182
|
+
const plugin = new DevAuthPlugin({})
|
|
183
|
+
const metadata = await plugin.getUserMetadata('dev_user1_2nK8mP4xL9')
|
|
184
|
+
|
|
185
|
+
expect(metadata).toEqual({
|
|
186
|
+
id: 'dev_user1_2nK8mP4xL9',
|
|
187
|
+
name: 'User One',
|
|
188
|
+
email: 'user1@localhost.dev',
|
|
189
|
+
avatarUrl: undefined,
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('returns null for unknown user', async () => {
|
|
194
|
+
const plugin = new DevAuthPlugin({})
|
|
195
|
+
const metadata = await plugin.getUserMetadata('unknown')
|
|
196
|
+
|
|
197
|
+
expect(metadata).toBeNull()
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('includes avatarUrl when present', async () => {
|
|
201
|
+
const customUsers: DevUser[] = [
|
|
202
|
+
{
|
|
203
|
+
userId: 'user_1',
|
|
204
|
+
name: 'User',
|
|
205
|
+
email: 'user@test.com',
|
|
206
|
+
avatarUrl: 'https://example.com/avatar.jpg',
|
|
207
|
+
externalGroups: [],
|
|
208
|
+
},
|
|
209
|
+
]
|
|
210
|
+
const plugin = new DevAuthPlugin({ users: customUsers })
|
|
211
|
+
const metadata = await plugin.getUserMetadata('user_1')
|
|
212
|
+
|
|
213
|
+
expect(metadata?.avatarUrl).toBe('https://example.com/avatar.jpg')
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
describe('getGroupMetadata', () => {
|
|
218
|
+
it('returns group metadata for valid group', async () => {
|
|
219
|
+
const plugin = new DevAuthPlugin({})
|
|
220
|
+
const metadata = await plugin.getGroupMetadata('team-a')
|
|
221
|
+
|
|
222
|
+
expect(metadata).toEqual({
|
|
223
|
+
id: 'team-a',
|
|
224
|
+
name: 'Team A',
|
|
225
|
+
description: 'Team A',
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('returns null for unknown group', async () => {
|
|
230
|
+
const plugin = new DevAuthPlugin({})
|
|
231
|
+
const metadata = await plugin.getGroupMetadata('unknown')
|
|
232
|
+
|
|
233
|
+
expect(metadata).toBeNull()
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('works with custom groups', async () => {
|
|
237
|
+
const customGroups: DevGroup[] = [
|
|
238
|
+
{ id: 'team-x', name: 'Team X', description: 'Custom team' },
|
|
239
|
+
]
|
|
240
|
+
const plugin = new DevAuthPlugin({ groups: customGroups })
|
|
241
|
+
const metadata = await plugin.getGroupMetadata('team-x')
|
|
242
|
+
|
|
243
|
+
expect(metadata).toEqual({
|
|
244
|
+
id: 'team-x',
|
|
245
|
+
name: 'Team X',
|
|
246
|
+
description: 'Custom team',
|
|
247
|
+
})
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
describe('listGroups', () => {
|
|
252
|
+
it('returns all groups by default', async () => {
|
|
253
|
+
const plugin = new DevAuthPlugin({})
|
|
254
|
+
const groups = await plugin.listGroups()
|
|
255
|
+
|
|
256
|
+
expect(groups).toHaveLength(3)
|
|
257
|
+
expect(groups.map((g) => g.id)).toEqual(['team-a', 'team-b', 'team-c'])
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('respects limit parameter', async () => {
|
|
261
|
+
const plugin = new DevAuthPlugin({})
|
|
262
|
+
const groups = await plugin.listGroups(2)
|
|
263
|
+
|
|
264
|
+
expect(groups).toHaveLength(2)
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
describe('searchExternalGroups', () => {
|
|
269
|
+
it('returns all groups when query is empty', async () => {
|
|
270
|
+
const plugin = new DevAuthPlugin({})
|
|
271
|
+
const results = await plugin.searchExternalGroups('')
|
|
272
|
+
|
|
273
|
+
expect(results).toHaveLength(3)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('filters groups by name', async () => {
|
|
277
|
+
const plugin = new DevAuthPlugin({})
|
|
278
|
+
const results = await plugin.searchExternalGroups('team a')
|
|
279
|
+
|
|
280
|
+
expect(results).toHaveLength(1)
|
|
281
|
+
expect(results[0].id).toBe('team-a')
|
|
282
|
+
expect(results[0].name).toBe('Team A')
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('is case insensitive', async () => {
|
|
286
|
+
const plugin = new DevAuthPlugin({})
|
|
287
|
+
const results = await plugin.searchExternalGroups('TEAM')
|
|
288
|
+
|
|
289
|
+
expect(results.length).toBeGreaterThan(0)
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
describe('custom configuration', () => {
|
|
294
|
+
it('accepts custom users and groups', () => {
|
|
295
|
+
const customUsers: DevUser[] = [
|
|
296
|
+
{
|
|
297
|
+
userId: 'custom_1',
|
|
298
|
+
name: 'Custom',
|
|
299
|
+
email: 'custom@test.com',
|
|
300
|
+
externalGroups: ['custom-group'],
|
|
301
|
+
},
|
|
302
|
+
]
|
|
303
|
+
const customGroups: DevGroup[] = [{ id: 'custom-group', name: 'Custom Group' }]
|
|
304
|
+
|
|
305
|
+
const plugin = new DevAuthPlugin({
|
|
306
|
+
users: customUsers,
|
|
307
|
+
groups: customGroups,
|
|
308
|
+
defaultUserId: 'custom_1',
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
expect(plugin).toBeInstanceOf(DevAuthPlugin)
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
describe('createDevAuthPlugin factory', () => {
|
|
316
|
+
beforeEach(() => {
|
|
317
|
+
vi.spyOn(console, 'info').mockImplementation(() => {})
|
|
318
|
+
})
|
|
319
|
+
afterEach(() => {
|
|
320
|
+
vi.restoreAllMocks()
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('creates plugin with default config', () => {
|
|
324
|
+
const plugin = createDevAuthPlugin()
|
|
325
|
+
expect(plugin).toBeInstanceOf(DevAuthPlugin)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('creates plugin with custom config', () => {
|
|
329
|
+
const plugin = createDevAuthPlugin({
|
|
330
|
+
defaultUserId: 'dev_admin_3xY6zW1qR5',
|
|
331
|
+
})
|
|
332
|
+
expect(plugin).toBeInstanceOf(DevAuthPlugin)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('works without any arguments', () => {
|
|
336
|
+
const plugin = createDevAuthPlugin()
|
|
337
|
+
expect(plugin).toBeInstanceOf(DevAuthPlugin)
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
describe('DEFAULT_USERS', () => {
|
|
342
|
+
it('exports default users', () => {
|
|
343
|
+
expect(DEFAULT_USERS).toHaveLength(5)
|
|
344
|
+
expect(DEFAULT_USERS[0].userId).toBe('dev_user1_2nK8mP4xL9')
|
|
345
|
+
expect(DEFAULT_USERS[4].userId).toBe('dev_admin_3xY6zW1qR5')
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('has correct user structure', () => {
|
|
349
|
+
DEFAULT_USERS.forEach((user) => {
|
|
350
|
+
expect(user).toHaveProperty('userId')
|
|
351
|
+
expect(user).toHaveProperty('name')
|
|
352
|
+
expect(user).toHaveProperty('email')
|
|
353
|
+
expect(user).toHaveProperty('externalGroups')
|
|
354
|
+
expect(Array.isArray(user.externalGroups)).toBe(true)
|
|
355
|
+
})
|
|
356
|
+
})
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
describe('DEFAULT_GROUPS', () => {
|
|
360
|
+
it('exports default groups', () => {
|
|
361
|
+
expect(DEFAULT_GROUPS).toHaveLength(3)
|
|
362
|
+
expect(DEFAULT_GROUPS.map((g) => g.id)).toEqual(['team-a', 'team-b', 'team-c'])
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it('has correct group structure', () => {
|
|
366
|
+
DEFAULT_GROUPS.forEach((group) => {
|
|
367
|
+
expect(group).toHaveProperty('id')
|
|
368
|
+
expect(group).toHaveProperty('name')
|
|
369
|
+
expect(group).toHaveProperty('description')
|
|
370
|
+
})
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
describe('cookie parsing', () => {
|
|
375
|
+
it('extracts cookie from single cookie string', async () => {
|
|
376
|
+
const plugin = new DevAuthPlugin({})
|
|
377
|
+
const headers = new Headers({
|
|
378
|
+
cookie: 'canopy-dev-user=dev_admin_3xY6zW1qR5',
|
|
379
|
+
})
|
|
380
|
+
const result = await plugin.authenticate(headers)
|
|
381
|
+
|
|
382
|
+
assertSuccess(result)
|
|
383
|
+
expect(result.user.userId).toBe('dev_admin_3xY6zW1qR5')
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('extracts cookie from multiple cookies', async () => {
|
|
387
|
+
const plugin = new DevAuthPlugin({})
|
|
388
|
+
const headers = new Headers({
|
|
389
|
+
cookie: 'session=abc123; canopy-dev-user=dev_user2_7qR3tY6wN2; other=value',
|
|
390
|
+
})
|
|
391
|
+
const result = await plugin.authenticate(headers)
|
|
392
|
+
|
|
393
|
+
assertSuccess(result)
|
|
394
|
+
expect(result.user.userId).toBe('dev_user2_7qR3tY6wN2')
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it('extracts cookie without semicolon separator', async () => {
|
|
398
|
+
const plugin = new DevAuthPlugin({})
|
|
399
|
+
const headers = new Headers({
|
|
400
|
+
cookie: 'canopy-dev-user=dev_reviewer_9aB4cD2eF7',
|
|
401
|
+
})
|
|
402
|
+
const result = await plugin.authenticate(headers)
|
|
403
|
+
|
|
404
|
+
assertSuccess(result)
|
|
405
|
+
expect(result.user.userId).toBe('dev_reviewer_9aB4cD2eF7')
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('returns default user when cookie not found', async () => {
|
|
409
|
+
const plugin = new DevAuthPlugin({})
|
|
410
|
+
const headers = new Headers({
|
|
411
|
+
cookie: 'session=abc123; other=value',
|
|
412
|
+
})
|
|
413
|
+
const result = await plugin.authenticate(headers)
|
|
414
|
+
|
|
415
|
+
assertSuccess(result)
|
|
416
|
+
expect(result.user.userId).toBe('dev_user1_2nK8mP4xL9') // default
|
|
417
|
+
})
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
describe('auto-bootstrap admin', () => {
|
|
421
|
+
let originalEnv: string | undefined
|
|
422
|
+
|
|
423
|
+
beforeEach(() => {
|
|
424
|
+
originalEnv = process.env.CANOPY_BOOTSTRAP_ADMIN_IDS
|
|
425
|
+
vi.spyOn(console, 'info').mockImplementation(() => {})
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
afterEach(() => {
|
|
429
|
+
vi.restoreAllMocks()
|
|
430
|
+
if (originalEnv === undefined) {
|
|
431
|
+
delete process.env.CANOPY_BOOTSTRAP_ADMIN_IDS
|
|
432
|
+
} else {
|
|
433
|
+
process.env.CANOPY_BOOTSTRAP_ADMIN_IDS = originalEnv
|
|
434
|
+
}
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it('auto-sets CANOPY_BOOTSTRAP_ADMIN_IDS when not already set', () => {
|
|
438
|
+
delete process.env.CANOPY_BOOTSTRAP_ADMIN_IDS
|
|
439
|
+
createDevAuthPlugin()
|
|
440
|
+
expect(process.env.CANOPY_BOOTSTRAP_ADMIN_IDS).toBe(DEV_ADMIN_USER_ID)
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
it('does not override existing CANOPY_BOOTSTRAP_ADMIN_IDS', () => {
|
|
444
|
+
process.env.CANOPY_BOOTSTRAP_ADMIN_IDS = 'custom_user_id'
|
|
445
|
+
createDevAuthPlugin()
|
|
446
|
+
expect(process.env.CANOPY_BOOTSTRAP_ADMIN_IDS).toBe('custom_user_id')
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
it('can be disabled via autoBootstrapAdmin: false', () => {
|
|
450
|
+
delete process.env.CANOPY_BOOTSTRAP_ADMIN_IDS
|
|
451
|
+
createDevAuthPlugin({ autoBootstrapAdmin: false })
|
|
452
|
+
expect(process.env.CANOPY_BOOTSTRAP_ADMIN_IDS).toBeUndefined()
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
it('skips auto-bootstrap when custom users do not include admin user', () => {
|
|
456
|
+
delete process.env.CANOPY_BOOTSTRAP_ADMIN_IDS
|
|
457
|
+
createDevAuthPlugin({
|
|
458
|
+
users: [
|
|
459
|
+
{
|
|
460
|
+
userId: 'custom_1',
|
|
461
|
+
name: 'Custom',
|
|
462
|
+
email: 'custom@test.com',
|
|
463
|
+
externalGroups: [],
|
|
464
|
+
},
|
|
465
|
+
],
|
|
466
|
+
})
|
|
467
|
+
expect(process.env.CANOPY_BOOTSTRAP_ADMIN_IDS).toBeUndefined()
|
|
468
|
+
})
|
|
469
|
+
})
|
|
470
|
+
})
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import type { AuthPlugin } from 'canopycms/auth'
|
|
2
|
+
import type { UserSearchResult, GroupMetadata, AuthenticationResult } from 'canopycms/auth'
|
|
3
|
+
import { extractHeaders } from 'canopycms/auth'
|
|
4
|
+
import type { CanopyUserId, CanopyGroupId } from 'canopycms'
|
|
5
|
+
import { getDevUserCookieFromHeaders } from './cookie-utils'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* WARNING: This plugin is for development and testing only!
|
|
9
|
+
* Do not use in production environments.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface DevUser {
|
|
13
|
+
userId: CanopyUserId
|
|
14
|
+
name: string
|
|
15
|
+
email: string
|
|
16
|
+
avatarUrl?: string
|
|
17
|
+
externalGroups: CanopyGroupId[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DevGroup {
|
|
21
|
+
id: CanopyGroupId
|
|
22
|
+
name: string
|
|
23
|
+
description?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const DEV_ADMIN_USER_ID: CanopyUserId = 'dev_admin_3xY6zW1qR5'
|
|
27
|
+
|
|
28
|
+
export interface DevAuthConfig {
|
|
29
|
+
/**
|
|
30
|
+
* Custom mock users. If not provided, uses default users.
|
|
31
|
+
*/
|
|
32
|
+
users?: DevUser[]
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Custom mock groups. If not provided, uses default groups.
|
|
36
|
+
*/
|
|
37
|
+
groups?: DevGroup[]
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Default user ID when no user is selected.
|
|
41
|
+
* @default 'dev_user1_2nK8mP4xL9' (user1)
|
|
42
|
+
*/
|
|
43
|
+
defaultUserId?: CanopyUserId
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Whether to auto-set CANOPY_BOOTSTRAP_ADMIN_IDS for the admin dev user
|
|
47
|
+
* when the env var is not already set. Defaults to true.
|
|
48
|
+
*/
|
|
49
|
+
autoBootstrapAdmin?: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const DEFAULT_USERS: DevUser[] = [
|
|
53
|
+
{
|
|
54
|
+
userId: 'dev_user1_2nK8mP4xL9',
|
|
55
|
+
name: 'User One',
|
|
56
|
+
email: 'user1@localhost.dev',
|
|
57
|
+
externalGroups: ['team-a', 'team-b'],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
userId: 'dev_user2_7qR3tY6wN2',
|
|
61
|
+
name: 'User Two',
|
|
62
|
+
email: 'user2@localhost.dev',
|
|
63
|
+
externalGroups: ['team-b'],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
userId: 'dev_user3_5vS1pM8kJ4',
|
|
67
|
+
name: 'User Three',
|
|
68
|
+
email: 'user3@localhost.dev',
|
|
69
|
+
externalGroups: ['team-c'],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
userId: 'dev_reviewer_9aB4cD2eF7',
|
|
73
|
+
name: 'Reviewer One',
|
|
74
|
+
email: 'reviewer1@localhost.dev',
|
|
75
|
+
externalGroups: ['team-a'],
|
|
76
|
+
// Note: 'Reviewers' membership comes from internal groups file, not auth plugin
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
userId: DEV_ADMIN_USER_ID,
|
|
80
|
+
name: 'Admin One',
|
|
81
|
+
email: 'admin1@localhost.dev',
|
|
82
|
+
externalGroups: ['team-a', 'team-b', 'team-c'],
|
|
83
|
+
// Note: Does NOT include 'Admins' - that's applied by bootstrap admin config or auto-bootstrap
|
|
84
|
+
},
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
export const DEFAULT_GROUPS: DevGroup[] = [
|
|
88
|
+
{ id: 'team-a', name: 'Team A', description: 'Team A' },
|
|
89
|
+
{ id: 'team-b', name: 'Team B', description: 'Team B' },
|
|
90
|
+
{ id: 'team-c', name: 'Team C', description: 'Team C' },
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Dev authentication plugin implementation for CanopyCMS.
|
|
95
|
+
* Supports both cookie-based (UI) and header-based (tests) authentication.
|
|
96
|
+
*/
|
|
97
|
+
export class DevAuthPlugin implements AuthPlugin {
|
|
98
|
+
private users: DevUser[]
|
|
99
|
+
private groups: DevGroup[]
|
|
100
|
+
private defaultUserId: CanopyUserId
|
|
101
|
+
|
|
102
|
+
constructor(config: DevAuthConfig = {}) {
|
|
103
|
+
this.users = config.users ?? DEFAULT_USERS
|
|
104
|
+
this.groups = config.groups ?? DEFAULT_GROUPS
|
|
105
|
+
this.defaultUserId = config.defaultUserId ?? 'dev_user1_2nK8mP4xL9'
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async authenticate(context: unknown): Promise<AuthenticationResult> {
|
|
109
|
+
// 1. Extract headers using extractHeaders() helper
|
|
110
|
+
const headers = extractHeaders(context)
|
|
111
|
+
if (!headers) {
|
|
112
|
+
return { success: false, error: 'Invalid context' }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 2. Check X-Test-User header (for test-app compatibility) FIRST
|
|
116
|
+
let userId = headers.get('X-Test-User')
|
|
117
|
+
|
|
118
|
+
// 3. If no test header, check x-dev-user-id header OR canopy-dev-user cookie
|
|
119
|
+
if (!userId) {
|
|
120
|
+
userId = headers.get('x-dev-user-id') ?? getDevUserCookieFromHeaders(headers)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 4. Fall back to default user
|
|
124
|
+
if (!userId) {
|
|
125
|
+
userId = this.defaultUserId
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 5. Map test user keys to dev user IDs for test compatibility
|
|
129
|
+
const userIdMapped = this.mapTestUserKey(userId)
|
|
130
|
+
|
|
131
|
+
// 6. Find user in config
|
|
132
|
+
const user = this.users.find((u) => u.userId === userIdMapped)
|
|
133
|
+
if (!user) {
|
|
134
|
+
return { success: false, error: `Dev user not found: ${userId}` }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 7. Return AuthenticationResult with externalGroups
|
|
138
|
+
return {
|
|
139
|
+
success: true,
|
|
140
|
+
user: {
|
|
141
|
+
userId: user.userId,
|
|
142
|
+
email: user.email,
|
|
143
|
+
name: user.name,
|
|
144
|
+
avatarUrl: user.avatarUrl,
|
|
145
|
+
externalGroups: user.externalGroups,
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Map test-app user keys to dev user IDs for backward compatibility
|
|
152
|
+
*/
|
|
153
|
+
private mapTestUserKey(key: string): CanopyUserId {
|
|
154
|
+
const testUserMap: Record<string, CanopyUserId> = {
|
|
155
|
+
admin: DEV_ADMIN_USER_ID, // admin1
|
|
156
|
+
editor: 'dev_user1_2nK8mP4xL9', // user1
|
|
157
|
+
viewer: 'dev_user2_7qR3tY6wN2', // user2
|
|
158
|
+
reviewer: 'dev_reviewer_9aB4cD2eF7', // reviewer1
|
|
159
|
+
}
|
|
160
|
+
return testUserMap[key] ?? key
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async searchUsers(query: string, limit?: number): Promise<UserSearchResult[]> {
|
|
164
|
+
const lowerQuery = query.toLowerCase()
|
|
165
|
+
const filtered = this.users.filter(
|
|
166
|
+
(u) =>
|
|
167
|
+
u.name.toLowerCase().includes(lowerQuery) || u.email.toLowerCase().includes(lowerQuery),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
const results = filtered.slice(0, limit)
|
|
171
|
+
return results.map((u) => ({
|
|
172
|
+
id: u.userId,
|
|
173
|
+
name: u.name,
|
|
174
|
+
email: u.email,
|
|
175
|
+
avatarUrl: u.avatarUrl,
|
|
176
|
+
}))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async getUserMetadata(userId: CanopyUserId): Promise<UserSearchResult | null> {
|
|
180
|
+
const user = this.users.find((u) => u.userId === userId)
|
|
181
|
+
if (!user) return null
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
id: user.userId,
|
|
185
|
+
name: user.name,
|
|
186
|
+
email: user.email,
|
|
187
|
+
avatarUrl: user.avatarUrl,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async getGroupMetadata(groupId: CanopyGroupId): Promise<GroupMetadata | null> {
|
|
192
|
+
const group = this.groups.find((g) => g.id === groupId)
|
|
193
|
+
if (!group) return null
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
id: group.id,
|
|
197
|
+
name: group.name,
|
|
198
|
+
description: group.description,
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async listGroups(limit?: number): Promise<GroupMetadata[]> {
|
|
203
|
+
const groups = limit ? this.groups.slice(0, limit) : this.groups
|
|
204
|
+
return groups.map((g) => ({
|
|
205
|
+
id: g.id,
|
|
206
|
+
name: g.name,
|
|
207
|
+
description: g.description,
|
|
208
|
+
}))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async searchExternalGroups(query: string): Promise<Array<{ id: CanopyGroupId; name: string }>> {
|
|
212
|
+
const lowerQuery = query.toLowerCase()
|
|
213
|
+
return this.groups
|
|
214
|
+
.filter((g) => g.name.toLowerCase().includes(lowerQuery))
|
|
215
|
+
.map((g) => ({
|
|
216
|
+
id: g.id,
|
|
217
|
+
name: g.name,
|
|
218
|
+
}))
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Factory function for creating dev auth plugin.
|
|
224
|
+
* By default, auto-sets CANOPY_BOOTSTRAP_ADMIN_IDS to the admin dev user
|
|
225
|
+
* if the env var is not already set. Disable with { autoBootstrapAdmin: false }.
|
|
226
|
+
*/
|
|
227
|
+
export function createDevAuthPlugin(config?: DevAuthConfig): AuthPlugin {
|
|
228
|
+
const shouldAutoBootstrap = config?.autoBootstrapAdmin ?? true
|
|
229
|
+
if (shouldAutoBootstrap && !process.env.CANOPY_BOOTSTRAP_ADMIN_IDS) {
|
|
230
|
+
const users = config?.users ?? DEFAULT_USERS
|
|
231
|
+
const adminUser = users.find((u) => u.userId === DEV_ADMIN_USER_ID)
|
|
232
|
+
if (adminUser) {
|
|
233
|
+
process.env.CANOPY_BOOTSTRAP_ADMIN_IDS = adminUser.userId
|
|
234
|
+
console.info(
|
|
235
|
+
`CanopyCMS dev-auth: Auto-configured ${adminUser.name} (${adminUser.userId}) as bootstrap admin. ` +
|
|
236
|
+
`Set CANOPY_BOOTSTRAP_ADMIN_IDS env var or pass autoBootstrapAdmin: false to override.`,
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return new DevAuthPlugin(config ?? {})
|
|
241
|
+
}
|