@startsimpli/auth 0.4.8 → 0.4.11
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/package.json +10 -9
- package/src/__tests__/auth-client-oauth-register.test.ts +264 -0
- package/src/__tests__/auth-functions.test.ts +190 -0
- package/src/__tests__/setup.ts +38 -0
- package/src/__tests__/useauth-shape-contract.test.ts +68 -0
- package/src/client/auth-client.ts +339 -8
- package/src/client/auth-context.tsx +106 -6
- package/src/client/functions.ts +120 -37
- package/src/client/use-auth.ts +19 -0
- package/src/server/middleware.ts +11 -7
- package/src/types/index.ts +6 -0
- package/src/utils/token.ts +14 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/auth",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.11",
|
|
4
4
|
"description": "Shared authentication package for StartSimpli Next.js apps",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -29,17 +29,18 @@
|
|
|
29
29
|
"clean": "rm -rf dist"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
|
-
"
|
|
33
|
-
"
|
|
32
|
+
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
33
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"@types/node": "^20.
|
|
37
|
-
"@types/react": "^
|
|
38
|
-
"@vitest/ui": "^
|
|
39
|
-
"
|
|
36
|
+
"@types/node": "^20.19.39",
|
|
37
|
+
"@types/react": "^19.2.14",
|
|
38
|
+
"@vitest/ui": "^4.1.5",
|
|
39
|
+
"jsdom": "^29.0.2",
|
|
40
|
+
"@types/jsdom": "^21.1.7",
|
|
40
41
|
"tsup": "^8.5.1",
|
|
41
|
-
"typescript": "^
|
|
42
|
-
"vitest": "^
|
|
42
|
+
"typescript": "^6.0.3",
|
|
43
|
+
"vitest": "^4.1.5"
|
|
43
44
|
},
|
|
44
45
|
"keywords": [
|
|
45
46
|
"authentication",
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract tests for AuthClient register + OAuth support (bead gkat).
|
|
3
|
+
*
|
|
4
|
+
* These tests define the target API that unblocks raise-simpli's provider
|
|
5
|
+
* swap (bead q6vf). They are written to fail until the implementation lands.
|
|
6
|
+
*
|
|
7
|
+
* Scope of this file:
|
|
8
|
+
* - AuthClient.register({ email, password, passwordConfirm, name? })
|
|
9
|
+
* - AuthClient.signInWithGoogle(redirectTo?)
|
|
10
|
+
* - AuthClient.completeGoogleCallback(code, state)
|
|
11
|
+
*
|
|
12
|
+
* A sibling test file (auth-context-oauth-register.test.tsx) covers the same
|
|
13
|
+
* features through the AuthProvider/useAuth surface.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
17
|
+
import { AuthClient } from '../client/auth-client'
|
|
18
|
+
|
|
19
|
+
function makeToken(exp: number): string {
|
|
20
|
+
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
|
21
|
+
const body = btoa(JSON.stringify({ tokenType: 'access', exp, iat: exp - 3600, jti: 'test', userId: '123' }))
|
|
22
|
+
return `${header}.${body}.signature`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const VALID_TOKEN = makeToken(Math.floor(Date.now() / 1000) + 3600)
|
|
26
|
+
|
|
27
|
+
function makeConfig() {
|
|
28
|
+
return { apiBaseUrl: 'http://localhost:8001' }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function userPayload(overrides: Record<string, unknown> = {}) {
|
|
32
|
+
return {
|
|
33
|
+
id: 'user-1',
|
|
34
|
+
email: 'new@example.com',
|
|
35
|
+
first_name: 'New',
|
|
36
|
+
last_name: 'User',
|
|
37
|
+
is_email_verified: false,
|
|
38
|
+
created_at: '2026-04-20T00:00:00Z',
|
|
39
|
+
updated_at: '2026-04-20T00:00:00Z',
|
|
40
|
+
...overrides,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('AuthClient.register (contract)', () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
vi.restoreAllMocks()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('POSTs { email, password, password_confirm, name } to /api/v1/auth/register/ and returns a Session', async () => {
|
|
50
|
+
const client = new AuthClient(makeConfig())
|
|
51
|
+
const mockFetch = vi.fn()
|
|
52
|
+
.mockResolvedValueOnce({
|
|
53
|
+
ok: true,
|
|
54
|
+
status: 200,
|
|
55
|
+
json: async () => ({
|
|
56
|
+
access: VALID_TOKEN,
|
|
57
|
+
user: userPayload({ email: 'new@example.com', first_name: 'New', last_name: 'User' }),
|
|
58
|
+
}),
|
|
59
|
+
})
|
|
60
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
61
|
+
|
|
62
|
+
const session = await client.register({
|
|
63
|
+
email: 'new@example.com',
|
|
64
|
+
password: 'SecurePass1!',
|
|
65
|
+
passwordConfirm: 'SecurePass1!',
|
|
66
|
+
name: 'New User',
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// Request shape
|
|
70
|
+
const call = mockFetch.mock.calls[0]
|
|
71
|
+
expect(call[0]).toContain('/api/v1/auth/register/')
|
|
72
|
+
expect(call[1].method).toBe('POST')
|
|
73
|
+
const body = JSON.parse(call[1].body as string)
|
|
74
|
+
expect(body.email).toBe('new@example.com')
|
|
75
|
+
expect(body.password).toBe('SecurePass1!')
|
|
76
|
+
// Backend expects snake_case for confirmation field
|
|
77
|
+
expect(body.password_confirm).toBe('SecurePass1!')
|
|
78
|
+
expect(body.name).toBe('New User')
|
|
79
|
+
|
|
80
|
+
// Return shape
|
|
81
|
+
expect(session.accessToken).toBe(VALID_TOKEN)
|
|
82
|
+
expect(session.user.email).toBe('new@example.com')
|
|
83
|
+
expect(session.user.firstName).toBe('New')
|
|
84
|
+
expect(session.user.lastName).toBe('User')
|
|
85
|
+
expect(session.expiresAt).toBeGreaterThan(Date.now())
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('throws a readable error when backend rejects (e.g. email already exists)', async () => {
|
|
89
|
+
const client = new AuthClient(makeConfig())
|
|
90
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
|
|
91
|
+
ok: false,
|
|
92
|
+
status: 400,
|
|
93
|
+
json: async () => ({
|
|
94
|
+
error: 'VALIDATION_ERROR',
|
|
95
|
+
detail: { email: ['user with this email already exists.'] },
|
|
96
|
+
}),
|
|
97
|
+
}))
|
|
98
|
+
|
|
99
|
+
await expect(
|
|
100
|
+
client.register({ email: 'taken@example.com', password: 'x', passwordConfirm: 'x' })
|
|
101
|
+
).rejects.toThrow(/already exists/i)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('fetches /me/ when the register response has no user payload', async () => {
|
|
105
|
+
const client = new AuthClient(makeConfig())
|
|
106
|
+
vi.stubGlobal('fetch', vi.fn()
|
|
107
|
+
// register response: access only, no user
|
|
108
|
+
.mockResolvedValueOnce({
|
|
109
|
+
ok: true,
|
|
110
|
+
status: 200,
|
|
111
|
+
json: async () => ({ access: VALID_TOKEN }),
|
|
112
|
+
})
|
|
113
|
+
// fallback /me/
|
|
114
|
+
.mockResolvedValueOnce({
|
|
115
|
+
ok: true,
|
|
116
|
+
status: 200,
|
|
117
|
+
json: async () => ({ user: userPayload({ email: 'fetched@example.com' }) }),
|
|
118
|
+
}))
|
|
119
|
+
|
|
120
|
+
const session = await client.register({
|
|
121
|
+
email: 'fetched@example.com',
|
|
122
|
+
password: 'SecurePass1!',
|
|
123
|
+
passwordConfirm: 'SecurePass1!',
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
expect(session.user.email).toBe('fetched@example.com')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('starts the refresh timer on successful register (same as login)', async () => {
|
|
130
|
+
const client = new AuthClient(makeConfig())
|
|
131
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
132
|
+
ok: true,
|
|
133
|
+
status: 200,
|
|
134
|
+
json: async () => ({ access: VALID_TOKEN, user: userPayload() }),
|
|
135
|
+
}))
|
|
136
|
+
|
|
137
|
+
await client.register({ email: 'new@example.com', password: 'x', passwordConfirm: 'x' })
|
|
138
|
+
|
|
139
|
+
// The refresh timer is a private member — asserting via internal state
|
|
140
|
+
// is brittle, but the behavior we care about is observable: getSession
|
|
141
|
+
// should return a non-null session after register.
|
|
142
|
+
const session = client.getSession()
|
|
143
|
+
expect(session).not.toBeNull()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('mirrors the access token into module-level storage (functions.ts getAccessToken)', async () => {
|
|
147
|
+
const client = new AuthClient(makeConfig())
|
|
148
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
149
|
+
ok: true,
|
|
150
|
+
status: 200,
|
|
151
|
+
json: async () => ({ access: VALID_TOKEN, user: userPayload() }),
|
|
152
|
+
}))
|
|
153
|
+
|
|
154
|
+
const { getAccessToken, setAccessToken } = await import('../client/functions')
|
|
155
|
+
setAccessToken(null) // start clean
|
|
156
|
+
|
|
157
|
+
await client.register({ email: 'new@example.com', password: 'x', passwordConfirm: 'x' })
|
|
158
|
+
|
|
159
|
+
// @startsimpli/api's FetchWrapper reads via this module-level getter.
|
|
160
|
+
// Without the mirror, useAuth().session would have a token but API calls
|
|
161
|
+
// would go out unauthenticated.
|
|
162
|
+
expect(getAccessToken()).toBe(VALID_TOKEN)
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
describe('AuthClient.signInWithGoogle (contract)', () => {
|
|
167
|
+
beforeEach(() => {
|
|
168
|
+
vi.restoreAllMocks()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('GETs /api/v1/auth/oauth/google/initiate/ (optionally with redirect_to) and returns the auth URL', async () => {
|
|
172
|
+
const client = new AuthClient(makeConfig())
|
|
173
|
+
const mockFetch = vi.fn().mockResolvedValueOnce({
|
|
174
|
+
ok: true,
|
|
175
|
+
status: 200,
|
|
176
|
+
json: async () => ({ auth_url: 'https://accounts.google.com/o/oauth2/v2/auth?...' }),
|
|
177
|
+
})
|
|
178
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
179
|
+
|
|
180
|
+
const url = await client.signInWithGoogle('/dashboard')
|
|
181
|
+
|
|
182
|
+
const call = mockFetch.mock.calls[0]
|
|
183
|
+
expect(call[0]).toContain('/api/v1/auth/oauth/google/initiate/')
|
|
184
|
+
// Method may be GET or POST depending on implementation — just verify it
|
|
185
|
+
// round-trips the redirect target if the implementation supports it.
|
|
186
|
+
expect(url).toBe('https://accounts.google.com/o/oauth2/v2/auth?...')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('throws when the backend fails to produce an auth URL', async () => {
|
|
190
|
+
const client = new AuthClient(makeConfig())
|
|
191
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
|
|
192
|
+
ok: false,
|
|
193
|
+
status: 500,
|
|
194
|
+
json: async () => ({ detail: 'OAuth provider unavailable' }),
|
|
195
|
+
}))
|
|
196
|
+
|
|
197
|
+
await expect(
|
|
198
|
+
client.signInWithGoogle()
|
|
199
|
+
).rejects.toThrow(/OAuth|unavailable/i)
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe('AuthClient.completeGoogleCallback (contract)', () => {
|
|
204
|
+
beforeEach(() => {
|
|
205
|
+
vi.restoreAllMocks()
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('exchanges code+state at /api/v1/auth/oauth/google/callback/ and returns a Session', async () => {
|
|
209
|
+
const client = new AuthClient(makeConfig())
|
|
210
|
+
const mockFetch = vi.fn().mockResolvedValueOnce({
|
|
211
|
+
ok: true,
|
|
212
|
+
status: 200,
|
|
213
|
+
json: async () => ({
|
|
214
|
+
access: VALID_TOKEN,
|
|
215
|
+
user: userPayload({ email: 'oauth@example.com', first_name: 'OAuth', last_name: 'User' }),
|
|
216
|
+
}),
|
|
217
|
+
})
|
|
218
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
219
|
+
|
|
220
|
+
const session = await client.completeGoogleCallback('test-code', 'test-state')
|
|
221
|
+
|
|
222
|
+
const call = mockFetch.mock.calls[0]
|
|
223
|
+
const url = typeof call[0] === 'string' ? call[0] : call[0].url
|
|
224
|
+
expect(url).toContain('/api/v1/auth/oauth/google/callback/')
|
|
225
|
+
expect(url).toContain('code=test-code')
|
|
226
|
+
expect(url).toContain('state=test-state')
|
|
227
|
+
|
|
228
|
+
expect(session.accessToken).toBe(VALID_TOKEN)
|
|
229
|
+
expect(session.user.email).toBe('oauth@example.com')
|
|
230
|
+
expect(session.expiresAt).toBeGreaterThan(Date.now())
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('throws a readable error when Google rejects the exchange (invalid/expired state)', async () => {
|
|
234
|
+
const client = new AuthClient(makeConfig())
|
|
235
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
|
|
236
|
+
ok: false,
|
|
237
|
+
status: 400,
|
|
238
|
+
json: async () => ({ detail: 'Invalid or expired state parameter' }),
|
|
239
|
+
}))
|
|
240
|
+
|
|
241
|
+
await expect(
|
|
242
|
+
client.completeGoogleCallback('bad-code', 'bad-state')
|
|
243
|
+
).rejects.toThrow(/state|invalid|expired/i)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('populates session from /me/ when callback response has no user payload', async () => {
|
|
247
|
+
const client = new AuthClient(makeConfig())
|
|
248
|
+
vi.stubGlobal('fetch', vi.fn()
|
|
249
|
+
.mockResolvedValueOnce({
|
|
250
|
+
ok: true,
|
|
251
|
+
status: 200,
|
|
252
|
+
json: async () => ({ access: VALID_TOKEN }),
|
|
253
|
+
})
|
|
254
|
+
.mockResolvedValueOnce({
|
|
255
|
+
ok: true,
|
|
256
|
+
status: 200,
|
|
257
|
+
json: async () => ({ user: userPayload({ email: 'oauth-me@example.com' }) }),
|
|
258
|
+
}))
|
|
259
|
+
|
|
260
|
+
const session = await client.completeGoogleCallback('ok-code', 'ok-state')
|
|
261
|
+
|
|
262
|
+
expect(session.user.email).toBe('oauth-me@example.com')
|
|
263
|
+
})
|
|
264
|
+
})
|
|
@@ -213,6 +213,196 @@ describe('authFetch session expiration on unrecoverable 401', () => {
|
|
|
213
213
|
});
|
|
214
214
|
});
|
|
215
215
|
|
|
216
|
+
describe('verifyEmail error extraction (nested DRF validation shape)', () => {
|
|
217
|
+
beforeEach(() => {
|
|
218
|
+
vi.clearAllMocks();
|
|
219
|
+
mockSessionStorage.clear();
|
|
220
|
+
mockLocalStorage.clear();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('extracts nested detail.token[0] string from validation error', async () => {
|
|
224
|
+
mockFetch.mockImplementation(() =>
|
|
225
|
+
Promise.resolve({
|
|
226
|
+
ok: false,
|
|
227
|
+
status: 400,
|
|
228
|
+
json: async () => ({
|
|
229
|
+
error: 'VALIDATION_ERROR',
|
|
230
|
+
detail: { token: ['Invalid verification link. Please check the link or request a new one.'] },
|
|
231
|
+
}),
|
|
232
|
+
})
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
let caught: Error | null = null;
|
|
236
|
+
try {
|
|
237
|
+
const { verifyEmail } = await import('../client/functions');
|
|
238
|
+
await verifyEmail('bad-token');
|
|
239
|
+
} catch (err) {
|
|
240
|
+
caught = err as Error;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
expect(caught).not.toBeNull();
|
|
244
|
+
expect(caught!.message).toBe('Invalid verification link. Please check the link or request a new one.');
|
|
245
|
+
expect(caught!.message).not.toBe('[object Object]');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('falls back to fallback text when body has no extractable string', async () => {
|
|
249
|
+
mockFetch.mockImplementation(() =>
|
|
250
|
+
Promise.resolve({
|
|
251
|
+
ok: false,
|
|
252
|
+
status: 500,
|
|
253
|
+
json: async () => ({}),
|
|
254
|
+
})
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
let caught: Error | null = null;
|
|
258
|
+
try {
|
|
259
|
+
const { verifyEmail } = await import('../client/functions');
|
|
260
|
+
await verifyEmail('bad-token');
|
|
261
|
+
} catch (err) {
|
|
262
|
+
caught = err as Error;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
expect(caught!.message).toBe('Failed to verify email');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('handles string detail (standard DRF shape)', async () => {
|
|
269
|
+
mockFetch.mockImplementation(() =>
|
|
270
|
+
Promise.resolve({
|
|
271
|
+
ok: false,
|
|
272
|
+
status: 400,
|
|
273
|
+
json: async () => ({ detail: 'Token has expired.' }),
|
|
274
|
+
})
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
let caught: Error | null = null;
|
|
278
|
+
try {
|
|
279
|
+
const { verifyEmail } = await import('../client/functions');
|
|
280
|
+
await verifyEmail('expired');
|
|
281
|
+
} catch (err) {
|
|
282
|
+
caught = err as Error;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
expect(caught!.message).toBe('Token has expired.');
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe('extractApiError prefers human-readable error string over code fields', () => {
|
|
290
|
+
it('returns "error" message (not "code") for { error, code } shape', async () => {
|
|
291
|
+
// Re-import so we pick up the current module for each test run
|
|
292
|
+
const { extractApiError } = await import('../client/functions')
|
|
293
|
+
const msg = extractApiError(
|
|
294
|
+
{
|
|
295
|
+
error: 'No active account found with the given credentials',
|
|
296
|
+
code: 'unauthorized',
|
|
297
|
+
statusCode: 401,
|
|
298
|
+
},
|
|
299
|
+
'Login failed'
|
|
300
|
+
)
|
|
301
|
+
expect(msg).toBe('No active account found with the given credentials')
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('prefers detail.string over error', async () => {
|
|
305
|
+
const { extractApiError } = await import('../client/functions')
|
|
306
|
+
const msg = extractApiError({ detail: 'Account is locked', error: 'locked' }, 'x')
|
|
307
|
+
expect(msg).toBe('Account is locked')
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('returns nested detail.field[0] when detail is an object', async () => {
|
|
311
|
+
const { extractApiError } = await import('../client/functions')
|
|
312
|
+
const msg = extractApiError({ detail: { token: ['Invalid token'] } }, 'x')
|
|
313
|
+
expect(msg).toBe('Invalid token')
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('ignores code/statusCode/timestamp fields when looking for a message', async () => {
|
|
317
|
+
const { extractApiError } = await import('../client/functions')
|
|
318
|
+
const msg = extractApiError(
|
|
319
|
+
{ code: 'unauthorized', statusCode: 401, timestamp: '2026-01-01' },
|
|
320
|
+
'fallback'
|
|
321
|
+
)
|
|
322
|
+
expect(msg).toBe('fallback')
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('falls back when no message fields exist', async () => {
|
|
326
|
+
const { extractApiError } = await import('../client/functions')
|
|
327
|
+
expect(extractApiError({}, 'Login failed')).toBe('Login failed')
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
describe('refreshAccessToken — transient vs permanent failures', () => {
|
|
332
|
+
beforeEach(() => {
|
|
333
|
+
vi.clearAllMocks()
|
|
334
|
+
mockSessionStorage.clear()
|
|
335
|
+
mockLocalStorage.clear()
|
|
336
|
+
setAccessToken(VALID_TOKEN)
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('returns null and CLEARS the access token on 401 (session dead)', async () => {
|
|
340
|
+
const { refreshAccessToken } = await import('../client/functions')
|
|
341
|
+
mockFetch.mockImplementation(() =>
|
|
342
|
+
Promise.resolve({
|
|
343
|
+
ok: false,
|
|
344
|
+
status: 401,
|
|
345
|
+
json: async () => ({}),
|
|
346
|
+
})
|
|
347
|
+
)
|
|
348
|
+
const result = await refreshAccessToken()
|
|
349
|
+
expect(result).toBeNull()
|
|
350
|
+
expect(getAccessToken()).toBeNull()
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('returns null and CLEARS the access token on 403 (session rejected)', async () => {
|
|
354
|
+
const { refreshAccessToken } = await import('../client/functions')
|
|
355
|
+
mockFetch.mockImplementation(() =>
|
|
356
|
+
Promise.resolve({
|
|
357
|
+
ok: false,
|
|
358
|
+
status: 403,
|
|
359
|
+
json: async () => ({}),
|
|
360
|
+
})
|
|
361
|
+
)
|
|
362
|
+
const result = await refreshAccessToken()
|
|
363
|
+
expect(result).toBeNull()
|
|
364
|
+
expect(getAccessToken()).toBeNull()
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('THROWS TransientRefreshError on 500 (backend down) and KEEPS the access token', async () => {
|
|
368
|
+
const { refreshAccessToken, TransientRefreshError } = await import('../client/functions')
|
|
369
|
+
mockFetch.mockImplementation(() =>
|
|
370
|
+
Promise.resolve({
|
|
371
|
+
ok: false,
|
|
372
|
+
status: 500,
|
|
373
|
+
statusText: 'Internal Server Error',
|
|
374
|
+
json: async () => ({}),
|
|
375
|
+
})
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
await expect(refreshAccessToken()).rejects.toBeInstanceOf(TransientRefreshError)
|
|
379
|
+
expect(getAccessToken()).toBe(VALID_TOKEN)
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it('THROWS TransientRefreshError on 503 (service unavailable) and KEEPS the token', async () => {
|
|
383
|
+
const { refreshAccessToken, TransientRefreshError } = await import('../client/functions')
|
|
384
|
+
mockFetch.mockImplementation(() =>
|
|
385
|
+
Promise.resolve({
|
|
386
|
+
ok: false,
|
|
387
|
+
status: 503,
|
|
388
|
+
statusText: 'Service Unavailable',
|
|
389
|
+
json: async () => ({}),
|
|
390
|
+
})
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
await expect(refreshAccessToken()).rejects.toBeInstanceOf(TransientRefreshError)
|
|
394
|
+
expect(getAccessToken()).toBe(VALID_TOKEN)
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it('THROWS TransientRefreshError on fetch network failure and KEEPS the token', async () => {
|
|
398
|
+
const { refreshAccessToken, TransientRefreshError } = await import('../client/functions')
|
|
399
|
+
mockFetch.mockImplementation(() => Promise.reject(new TypeError('Failed to fetch')))
|
|
400
|
+
|
|
401
|
+
await expect(refreshAccessToken()).rejects.toBeInstanceOf(TransientRefreshError)
|
|
402
|
+
expect(getAccessToken()).toBe(VALID_TOKEN)
|
|
403
|
+
})
|
|
404
|
+
})
|
|
405
|
+
|
|
216
406
|
describe('notifySessionExpired cooldown', () => {
|
|
217
407
|
beforeEach(() => {
|
|
218
408
|
setOnSessionExpired(null);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Node 25 installs globalThis.localStorage / sessionStorage = {} (stub with
|
|
2
|
+
// no Storage methods) before jsdom/happy-dom load, and the DOM env can't
|
|
3
|
+
// override an already-defined global. Replace both with working in-memory
|
|
4
|
+
// Storage impls so tests can use bare `localStorage` / `sessionStorage`.
|
|
5
|
+
class MemStorage implements Storage {
|
|
6
|
+
private store: Record<string, string> = {};
|
|
7
|
+
get length(): number {
|
|
8
|
+
return Object.keys(this.store).length;
|
|
9
|
+
}
|
|
10
|
+
clear(): void {
|
|
11
|
+
this.store = {};
|
|
12
|
+
}
|
|
13
|
+
getItem(key: string): string | null {
|
|
14
|
+
return Object.prototype.hasOwnProperty.call(this.store, key) ? this.store[key] : null;
|
|
15
|
+
}
|
|
16
|
+
setItem(key: string, value: string): void {
|
|
17
|
+
this.store[key] = String(value);
|
|
18
|
+
}
|
|
19
|
+
removeItem(key: string): void {
|
|
20
|
+
delete this.store[key];
|
|
21
|
+
}
|
|
22
|
+
key(i: number): string | null {
|
|
23
|
+
return Object.keys(this.store)[i] ?? null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
Object.defineProperty(globalThis, 'localStorage', {
|
|
28
|
+
configurable: true,
|
|
29
|
+
writable: true,
|
|
30
|
+
value: new MemStorage(),
|
|
31
|
+
});
|
|
32
|
+
Object.defineProperty(globalThis, 'sessionStorage', {
|
|
33
|
+
configurable: true,
|
|
34
|
+
writable: true,
|
|
35
|
+
value: new MemStorage(),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-level contract test for the target useAuth() return shape (bead gkat).
|
|
3
|
+
*
|
|
4
|
+
* The goal of bead q6vf is to let raise-simpli replace its forked AuthProvider
|
|
5
|
+
* with @startsimpli/auth's shared AuthProvider. For that to work, useAuth()
|
|
6
|
+
* must surface OAuth + registration (currently only exposed through the
|
|
7
|
+
* standalone functional API).
|
|
8
|
+
*
|
|
9
|
+
* This file documents — at the type level — the shape we're committing to.
|
|
10
|
+
* It fails to compile until UseAuthReturn in use-auth.ts is extended.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from 'vitest'
|
|
14
|
+
import type { UseAuthReturn } from '../client/use-auth'
|
|
15
|
+
|
|
16
|
+
describe('useAuth() shape contract (target API)', () => {
|
|
17
|
+
it('has existing methods (baseline)', () => {
|
|
18
|
+
type Baseline = Pick<
|
|
19
|
+
UseAuthReturn,
|
|
20
|
+
'user' | 'session' | 'isLoading' | 'isAuthenticated' | 'login' | 'logout' | 'refreshUser' | 'getAccessToken'
|
|
21
|
+
>
|
|
22
|
+
const _: Baseline = null as unknown as Baseline
|
|
23
|
+
expect(true).toBe(true)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('exposes register(payload) returning Promise<void>', () => {
|
|
27
|
+
type Register = UseAuthReturn['register']
|
|
28
|
+
// Lock the parameter shape. The AuthProvider-wrapped return is Promise<void>
|
|
29
|
+
// because the provider updates context state rather than returning Session
|
|
30
|
+
// (consumers call useAuth().session / user after the promise resolves).
|
|
31
|
+
const _: (payload: {
|
|
32
|
+
email: string
|
|
33
|
+
password: string
|
|
34
|
+
passwordConfirm: string
|
|
35
|
+
name?: string
|
|
36
|
+
firstName?: string
|
|
37
|
+
lastName?: string
|
|
38
|
+
}) => Promise<void> = null as unknown as Register
|
|
39
|
+
expect(_).toBeNull()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('exposes signInWithGoogle(redirectTo?) returning Promise<string> (the auth URL)', () => {
|
|
43
|
+
type SignIn = UseAuthReturn['signInWithGoogle']
|
|
44
|
+
const _: (redirectTo?: string) => Promise<string> = null as unknown as SignIn
|
|
45
|
+
expect(_).toBeNull()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('exposes completeGoogleCallback(code, state) returning Promise<void>', () => {
|
|
49
|
+
type Callback = UseAuthReturn['completeGoogleCallback']
|
|
50
|
+
const _: (code: string, state: string) => Promise<void> = null as unknown as Callback
|
|
51
|
+
expect(_).toBeNull()
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Shape reference (what raise-simpli's forked auth-context currently expects
|
|
57
|
+
* from its own useAuth; we need the shared hook to cover the same surface
|
|
58
|
+
* modulo naming — see bead q6vf for the full call-site mapping).
|
|
59
|
+
*
|
|
60
|
+
* useAuth().signIn(email, password) → useAuth().login(email, password)
|
|
61
|
+
* useAuth().signOut() → useAuth().logout()
|
|
62
|
+
* useAuth().refresh() → useAuth().refreshUser() (semantics differ, see q6vf)
|
|
63
|
+
* useAuth().register(payload) → useAuth().register(payload) [NEW]
|
|
64
|
+
* useAuth().handleOAuthSuccess({access,user}) → useAuth().completeGoogleCallback() [NEW]
|
|
65
|
+
* useAuth().user → useAuth().user
|
|
66
|
+
* useAuth().status → useAuth().isLoading + isAuthenticated
|
|
67
|
+
* useAuth().accessToken → useAuth().getAccessToken()
|
|
68
|
+
*/
|