@sanity/sdk 3.1.0 → 3.2.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/dist/_exports/_internal.d.ts +52 -21
- package/dist/_exports/_internal.d.ts.map +1 -1
- package/dist/_exports/_internal.js +10 -9
- package/dist/_exports/_internal.js.map +1 -1
- package/dist/_exports/agent.d.ts +7 -7
- package/dist/_exports/agent.d.ts.map +1 -1
- package/dist/_exports/agent.js +1 -1
- package/dist/_exports/comlink.d.ts +9 -9
- package/dist/_exports/comlink.d.ts.map +1 -1
- package/dist/_exports/comlink.js +3 -2
- package/dist/_exports/comlink.js.map +1 -1
- package/dist/_exports/dashboard.d.ts +2 -0
- package/dist/_exports/dashboard.js +2 -0
- package/dist/{authStore-C-OEkGaM.d.ts → authStore-BO2vch3I.d.ts} +516 -63
- package/dist/authStore-BO2vch3I.d.ts.map +1 -0
- package/dist/{clientStore-gUALQ0qW.js → clientStore-BI4D8HhJ.js} +124 -7
- package/dist/clientStore-BI4D8HhJ.js.map +1 -0
- package/dist/{createSanityInstance-DCePpBYW.d.ts → createSanityInstance-BQIqyGLX.d.ts} +16 -1
- package/dist/createSanityInstance-BQIqyGLX.d.ts.map +1 -0
- package/dist/{object-BwynGPRZ.js → createStateSourceAction-DdAhEfnZ.js} +2 -82
- package/dist/createStateSourceAction-DdAhEfnZ.js.map +1 -0
- package/dist/defineProperty-Bho9Ngsi.js +31 -0
- package/dist/{getNodeState-E0hjqFOo.js → getNodeState-B4nG0sjm.js} +3 -2
- package/dist/{getNodeState-E0hjqFOo.js.map → getNodeState-B4nG0sjm.js.map} +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +335 -51
- package/dist/index.js.map +1 -1
- package/dist/object-CxXlSVg7.js +83 -0
- package/dist/object-CxXlSVg7.js.map +1 -0
- package/dist/topicStore-Ct7IFNtY.js +565 -0
- package/dist/topicStore-Ct7IFNtY.js.map +1 -0
- package/dist/topicStore-D0WqhQGF.d.ts +435 -0
- package/dist/topicStore-D0WqhQGF.d.ts.map +1 -0
- package/dist/{version-C90rNWaX.js → version-BcRmCiYi.js} +6 -6
- package/dist/{version-C90rNWaX.js.map → version-BcRmCiYi.js.map} +1 -1
- package/package.json +19 -18
- package/src/_exports/_internal.ts +12 -0
- package/src/_exports/dashboard.ts +36 -0
- package/src/_exports/index.ts +17 -1
- package/src/applications/applications.test-d.ts +28 -4
- package/src/applications/applications.ts +39 -14
- package/src/auth/authMode.test.ts +24 -0
- package/src/auth/authMode.ts +8 -3
- package/src/auth/authStore.ts +12 -0
- package/src/auth/authStrategy.ts +2 -0
- package/src/auth/logout.test.ts +45 -0
- package/src/auth/logout.ts +1 -0
- package/src/auth/oauth/oauthActions.test.ts +457 -0
- package/src/auth/oauth/oauthActions.ts +389 -0
- package/src/auth/oauth/oauthAuth.test.ts +206 -0
- package/src/auth/oauth/oauthAuth.ts +168 -0
- package/src/auth/oauth/pkce.test.ts +44 -0
- package/src/auth/oauth/pkce.ts +60 -0
- package/src/auth/oauth/types.ts +12 -0
- package/src/config/authConfig.ts +18 -0
- package/src/dashboard/messageBus/__fixtures__/test-topics.ts +18 -0
- package/src/dashboard/messageBus/bus.test.ts +1033 -0
- package/src/dashboard/messageBus/bus.ts +1246 -0
- package/src/dashboard/messageBus/store.test.ts +142 -0
- package/src/dashboard/messageBus/store.ts +74 -0
- package/src/dashboard/messageBus/topicStore.test.ts +172 -0
- package/src/dashboard/messageBus/topicStore.ts +184 -0
- package/src/dashboard/messageBus/topics.test.ts +71 -0
- package/src/dashboard/messageBus/topics.ts +260 -0
- package/src/document/actions.ts +3 -3
- package/src/document/applyDocumentActions.ts +3 -3
- package/src/document/documentStore.ts +12 -12
- package/src/document/processMutations.ts +58 -22
- package/src/installations/installations.test-d.ts +3 -3
- package/src/installations/installations.ts +2 -19
- package/src/projection/getProjectionState.ts +2 -2
- package/src/projection/resolveProjection.ts +2 -2
- package/src/query/queryStore.ts +3 -3
- package/src/typegen/defineProjection.test.ts +17 -0
- package/src/typegen/defineProjection.ts +19 -0
- package/src/typegen/groqCompat.ts +122 -0
- package/src/typegen/resolve.ts +37 -0
- package/src/utils/createGroqSearchFilter.test.ts +8 -0
- package/src/utils/createGroqSearchFilter.ts +6 -3
- package/dist/authStore-C-OEkGaM.d.ts.map +0 -1
- package/dist/clientStore-gUALQ0qW.js.map +0 -1
- package/dist/createSanityInstance-DCePpBYW.d.ts.map +0 -1
- package/dist/object-BwynGPRZ.js.map +0 -1
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import {type ClientConfig, ClientError, type SanityClient} from '@sanity/client'
|
|
2
|
+
import {NEVER} from 'rxjs'
|
|
3
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
4
|
+
|
|
5
|
+
import {createSanityInstance, type SanityInstance} from '../../store/createSanityInstance'
|
|
6
|
+
import {AuthStateType} from '../authStateType'
|
|
7
|
+
import {getAuthState} from '../authStore'
|
|
8
|
+
import {subscribeToStateAndFetchCurrentUser} from '../subscribeToStateAndFetchCurrentUser'
|
|
9
|
+
import {
|
|
10
|
+
getOAuthTokensState,
|
|
11
|
+
handleOAuthCallback,
|
|
12
|
+
OAUTH_STATE_KEY,
|
|
13
|
+
OAUTH_VERIFIER_KEY,
|
|
14
|
+
refreshOAuthTokens,
|
|
15
|
+
revokeOAuthTokens,
|
|
16
|
+
serializeTokens,
|
|
17
|
+
startOAuthAuthorization,
|
|
18
|
+
} from './oauthActions'
|
|
19
|
+
import {deserializeTokens, OAUTH_TOKENS_KEY} from './oauthAuth'
|
|
20
|
+
import {type OAuthTokens} from './types'
|
|
21
|
+
|
|
22
|
+
const readStored = (storage: Storage) => deserializeTokens(storage.getItem(OAUTH_TOKENS_KEY))
|
|
23
|
+
|
|
24
|
+
vi.mock('../subscribeToStateAndFetchCurrentUser')
|
|
25
|
+
vi.mock('../../utils/logger', async (importOriginal) => {
|
|
26
|
+
const original = await importOriginal<typeof import('../../utils/logger')>()
|
|
27
|
+
return {
|
|
28
|
+
...original,
|
|
29
|
+
createLogger: vi.fn(() => ({
|
|
30
|
+
info: vi.fn(),
|
|
31
|
+
debug: vi.fn(),
|
|
32
|
+
warn: vi.fn(),
|
|
33
|
+
error: vi.fn(),
|
|
34
|
+
trace: vi.fn(),
|
|
35
|
+
})),
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const oauthConfig = {
|
|
40
|
+
clientId: 'client-abc',
|
|
41
|
+
redirectUri: 'https://app.example.com/callback',
|
|
42
|
+
organizationId: 'org123',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const seededTokens: OAuthTokens = {
|
|
46
|
+
accessToken: 'stored-access',
|
|
47
|
+
tokenType: 'bearer',
|
|
48
|
+
expiresIn: 3600,
|
|
49
|
+
expiresAt: new Date('2030-01-01T00:00:00.000Z'),
|
|
50
|
+
refreshToken: 'stored-refresh',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const tokenResponse = {
|
|
54
|
+
access_token: 'new-access',
|
|
55
|
+
token_type: 'bearer',
|
|
56
|
+
expires_in: 3600,
|
|
57
|
+
refresh_token: 'new-refresh',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function createMemoryStorage(seed?: Record<string, string>): Storage {
|
|
61
|
+
const map = new Map<string, string>(Object.entries(seed ?? {}))
|
|
62
|
+
return {
|
|
63
|
+
getItem: (k) => map.get(k) ?? null,
|
|
64
|
+
setItem: (k, v) => void map.set(k, v),
|
|
65
|
+
removeItem: (k) => void map.delete(k),
|
|
66
|
+
clear: () => map.clear(),
|
|
67
|
+
key: (i) => [...map.keys()][i] ?? null,
|
|
68
|
+
get length() {
|
|
69
|
+
return map.size
|
|
70
|
+
},
|
|
71
|
+
} as Storage
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function deferred<T>(): {
|
|
75
|
+
promise: Promise<T>
|
|
76
|
+
resolve: (v: T) => void
|
|
77
|
+
reject: (e: unknown) => void
|
|
78
|
+
} {
|
|
79
|
+
let resolve!: (v: T) => void
|
|
80
|
+
let reject!: (e: unknown) => void
|
|
81
|
+
const promise = new Promise<T>((res, rej) => {
|
|
82
|
+
resolve = res
|
|
83
|
+
reject = rej
|
|
84
|
+
})
|
|
85
|
+
return {promise, resolve, reject}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let instance: SanityInstance | undefined
|
|
89
|
+
|
|
90
|
+
interface SetupOptions {
|
|
91
|
+
request?: ReturnType<typeof vi.fn>
|
|
92
|
+
storageSeed?: Record<string, string>
|
|
93
|
+
sessionSeed?: Record<string, string>
|
|
94
|
+
initialLocationHref?: string
|
|
95
|
+
withOAuthConfig?: boolean
|
|
96
|
+
apiHost?: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function setup(options: SetupOptions = {}) {
|
|
100
|
+
const request = options.request ?? vi.fn().mockResolvedValue(tokenResponse)
|
|
101
|
+
const clientFactory = vi.fn((_config: ClientConfig) => ({request}) as unknown as SanityClient)
|
|
102
|
+
const storageArea = createMemoryStorage(options.storageSeed)
|
|
103
|
+
const session = createMemoryStorage(options.sessionSeed)
|
|
104
|
+
vi.stubGlobal('sessionStorage', session)
|
|
105
|
+
|
|
106
|
+
instance = createSanityInstance({
|
|
107
|
+
projectId: 'p',
|
|
108
|
+
dataset: 'd',
|
|
109
|
+
auth: {
|
|
110
|
+
clientFactory,
|
|
111
|
+
storageArea,
|
|
112
|
+
initialLocationHref: options.initialLocationHref ?? 'https://app.example.com/',
|
|
113
|
+
...(options.apiHost && {apiHost: options.apiHost}),
|
|
114
|
+
...(options.withOAuthConfig === false ? {} : {oauth: oauthConfig}),
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
return {request, clientFactory, storageArea, session}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseBody(body: unknown): URLSearchParams {
|
|
122
|
+
return new URLSearchParams(body as string)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
beforeEach(() => {
|
|
126
|
+
vi.mocked(subscribeToStateAndFetchCurrentUser).mockReturnValue(NEVER.subscribe())
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
afterEach(() => {
|
|
130
|
+
instance?.dispose()
|
|
131
|
+
instance = undefined
|
|
132
|
+
vi.unstubAllGlobals()
|
|
133
|
+
vi.clearAllMocks()
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe('startOAuthAuthorization', () => {
|
|
137
|
+
it('persists verifier/state and navigates to the authorize URL', async () => {
|
|
138
|
+
const assign = vi.fn()
|
|
139
|
+
vi.stubGlobal('window', {location: {assign}})
|
|
140
|
+
const {session} = setup({initialLocationHref: 'https://app.example.com/'})
|
|
141
|
+
|
|
142
|
+
await startOAuthAuthorization(instance!)
|
|
143
|
+
|
|
144
|
+
expect(session.getItem(OAUTH_VERIFIER_KEY)).toBeTruthy()
|
|
145
|
+
expect(session.getItem(OAUTH_STATE_KEY)).toBeTruthy()
|
|
146
|
+
|
|
147
|
+
expect(assign).toHaveBeenCalledTimes(1)
|
|
148
|
+
const url = new URL(assign.mock.calls[0][0])
|
|
149
|
+
expect(url.pathname).toBe('/v1/auth/oauth/authorize')
|
|
150
|
+
expect(url.searchParams.get('response_type')).toBe('code')
|
|
151
|
+
expect(url.searchParams.get('client_id')).toBe('client-abc')
|
|
152
|
+
expect(url.searchParams.get('redirect_uri')).toBe(oauthConfig.redirectUri)
|
|
153
|
+
expect(url.searchParams.get('code_challenge_method')).toBe('S256')
|
|
154
|
+
expect(url.searchParams.get('code_challenge')).toBeTruthy()
|
|
155
|
+
expect(url.searchParams.get('state')).toBe(session.getItem(OAUTH_STATE_KEY))
|
|
156
|
+
expect(url.searchParams.getAll('resource')).toEqual(['urn:io.sanity:organization:org123'])
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('throws when OAuth is not configured', async () => {
|
|
160
|
+
setup({withOAuthConfig: false})
|
|
161
|
+
await expect(startOAuthAuthorization(instance!)).rejects.toThrow(/OAuth is not configured/)
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('handleOAuthCallback', () => {
|
|
166
|
+
const callbackHref = 'https://app.example.com/callback?code=auth-code&state=state-xyz'
|
|
167
|
+
|
|
168
|
+
it('exchanges the code, persists tokens, clears artifacts and logs in', async () => {
|
|
169
|
+
const {request, storageArea, session, clientFactory} = setup({
|
|
170
|
+
apiHost: 'https://api.sanity.work',
|
|
171
|
+
sessionSeed: {[OAUTH_STATE_KEY]: 'state-xyz', [OAUTH_VERIFIER_KEY]: 'verifier-1'},
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
const result = await handleOAuthCallback(instance!, callbackHref)
|
|
175
|
+
|
|
176
|
+
expect(result).toBe('https://app.example.com/callback')
|
|
177
|
+
expect(clientFactory).toHaveBeenCalledWith(
|
|
178
|
+
expect.objectContaining({apiHost: 'https://api.sanity.work'}),
|
|
179
|
+
)
|
|
180
|
+
expect(request).toHaveBeenCalledTimes(1)
|
|
181
|
+
const call = request.mock.calls[0][0]
|
|
182
|
+
expect(call).toMatchObject({
|
|
183
|
+
method: 'POST',
|
|
184
|
+
url: '/auth/oauth/token',
|
|
185
|
+
headers: {'content-type': 'application/x-www-form-urlencoded'},
|
|
186
|
+
tag: 'oauth.token',
|
|
187
|
+
})
|
|
188
|
+
const body = parseBody(call.body)
|
|
189
|
+
expect(body.get('grant_type')).toBe('authorization_code')
|
|
190
|
+
expect(body.get('code')).toBe('auth-code')
|
|
191
|
+
expect(body.get('code_verifier')).toBe('verifier-1')
|
|
192
|
+
expect(body.get('redirect_uri')).toBe(oauthConfig.redirectUri)
|
|
193
|
+
expect(body.get('client_id')).toBe('client-abc')
|
|
194
|
+
expect(body.get('resource')).toBe('urn:io.sanity:organization:org123')
|
|
195
|
+
|
|
196
|
+
expect(readStored(storageArea)).toMatchObject({accessToken: 'new-access'})
|
|
197
|
+
expect(session.getItem(OAUTH_VERIFIER_KEY)).toBeNull()
|
|
198
|
+
expect(session.getItem(OAUTH_STATE_KEY)).toBeNull()
|
|
199
|
+
|
|
200
|
+
expect(getAuthState(instance!).getCurrent()).toMatchObject({
|
|
201
|
+
type: AuthStateType.LOGGED_IN,
|
|
202
|
+
token: 'new-access',
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('does not perform a second exchange on a duplicate concurrent invocation', async () => {
|
|
207
|
+
const pending = deferred<typeof tokenResponse>()
|
|
208
|
+
const request = vi.fn().mockReturnValue(pending.promise)
|
|
209
|
+
setup({
|
|
210
|
+
request,
|
|
211
|
+
sessionSeed: {[OAUTH_STATE_KEY]: 'state-xyz', [OAUTH_VERIFIER_KEY]: 'verifier-1'},
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
const first = handleOAuthCallback(instance!, callbackHref)
|
|
215
|
+
const second = await handleOAuthCallback(instance!, callbackHref)
|
|
216
|
+
|
|
217
|
+
expect(second).toBe(false)
|
|
218
|
+
expect(request).toHaveBeenCalledTimes(1)
|
|
219
|
+
|
|
220
|
+
pending.resolve(tokenResponse)
|
|
221
|
+
expect(await first).toBe('https://app.example.com/callback')
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('surfaces an ?error= callback as ERROR without exchanging', async () => {
|
|
225
|
+
const {request} = setup()
|
|
226
|
+
|
|
227
|
+
const result = await handleOAuthCallback(
|
|
228
|
+
instance!,
|
|
229
|
+
'https://app.example.com/callback?error=access_denied&error_description=denied',
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
expect(result).toBe('https://app.example.com/callback')
|
|
233
|
+
expect(request).not.toHaveBeenCalled()
|
|
234
|
+
expect(getAuthState(instance!).getCurrent()).toMatchObject({type: AuthStateType.ERROR})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('surfaces an ?error= callback without a description', async () => {
|
|
238
|
+
setup()
|
|
239
|
+
await handleOAuthCallback(instance!, 'https://app.example.com/callback?error=access_denied')
|
|
240
|
+
expect(getAuthState(instance!).getCurrent()).toMatchObject({type: AuthStateType.ERROR})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('rejects a state mismatch as ERROR without exchanging', async () => {
|
|
244
|
+
const {request} = setup({
|
|
245
|
+
sessionSeed: {[OAUTH_STATE_KEY]: 'different', [OAUTH_VERIFIER_KEY]: 'verifier-1'},
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
const result = await handleOAuthCallback(instance!, callbackHref)
|
|
249
|
+
|
|
250
|
+
expect(result).toBe('https://app.example.com/callback')
|
|
251
|
+
expect(request).not.toHaveBeenCalled()
|
|
252
|
+
expect(getAuthState(instance!).getCurrent()).toMatchObject({type: AuthStateType.ERROR})
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('errors when the code verifier is missing', async () => {
|
|
256
|
+
const {request} = setup({sessionSeed: {[OAUTH_STATE_KEY]: 'state-xyz'}})
|
|
257
|
+
|
|
258
|
+
const result = await handleOAuthCallback(instance!, callbackHref)
|
|
259
|
+
|
|
260
|
+
expect(result).toBe('https://app.example.com/callback')
|
|
261
|
+
expect(request).not.toHaveBeenCalled()
|
|
262
|
+
expect(getAuthState(instance!).getCurrent()).toMatchObject({type: AuthStateType.ERROR})
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('returns false when there is no code in the URL', async () => {
|
|
266
|
+
const {request} = setup()
|
|
267
|
+
const result = await handleOAuthCallback(instance!, 'https://app.example.com/callback')
|
|
268
|
+
expect(result).toBe(false)
|
|
269
|
+
expect(request).not.toHaveBeenCalled()
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('sets ERROR when the token exchange fails', async () => {
|
|
273
|
+
const request = vi.fn().mockRejectedValue(new Error('boom'))
|
|
274
|
+
const {session} = setup({
|
|
275
|
+
request,
|
|
276
|
+
sessionSeed: {[OAUTH_STATE_KEY]: 'state-xyz', [OAUTH_VERIFIER_KEY]: 'verifier-1'},
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
const result = await handleOAuthCallback(instance!, callbackHref)
|
|
280
|
+
|
|
281
|
+
expect(result).toBe('https://app.example.com/callback')
|
|
282
|
+
expect(getAuthState(instance!).getCurrent()).toMatchObject({type: AuthStateType.ERROR})
|
|
283
|
+
expect(session.getItem(OAUTH_VERIFIER_KEY)).toBeNull()
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
describe('refreshOAuthTokens', () => {
|
|
288
|
+
it('refreshes tokens and persists them', async () => {
|
|
289
|
+
const {request, storageArea} = setup({
|
|
290
|
+
storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(seededTokens)},
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
const result = await refreshOAuthTokens(instance!)
|
|
294
|
+
|
|
295
|
+
expect(request).toHaveBeenCalledTimes(1)
|
|
296
|
+
const body = parseBody(request.mock.calls[0][0].body)
|
|
297
|
+
expect(body.get('grant_type')).toBe('refresh_token')
|
|
298
|
+
expect(body.get('refresh_token')).toBe('stored-refresh')
|
|
299
|
+
expect(body.get('client_id')).toBe('client-abc')
|
|
300
|
+
expect(body.get('resource')).toBe('urn:io.sanity:organization:org123')
|
|
301
|
+
|
|
302
|
+
expect(result).toMatchObject({accessToken: 'new-access', refreshToken: 'new-refresh'})
|
|
303
|
+
expect(readStored(storageArea)).toMatchObject({accessToken: 'new-access'})
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('shares a single in-flight request across concurrent callers', async () => {
|
|
307
|
+
const pending = deferred<typeof tokenResponse>()
|
|
308
|
+
const request = vi.fn().mockReturnValue(pending.promise)
|
|
309
|
+
setup({
|
|
310
|
+
request,
|
|
311
|
+
storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(seededTokens)},
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
const a = refreshOAuthTokens(instance!)
|
|
315
|
+
const b = refreshOAuthTokens(instance!)
|
|
316
|
+
|
|
317
|
+
pending.resolve(tokenResponse)
|
|
318
|
+
const [ra, rb] = await Promise.all([a, b])
|
|
319
|
+
|
|
320
|
+
expect(request).toHaveBeenCalledTimes(1)
|
|
321
|
+
expect(ra).toBe(rb)
|
|
322
|
+
expect(ra).toMatchObject({accessToken: 'new-access'})
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('keeps the previous refresh token when the endpoint omits one', async () => {
|
|
326
|
+
const request = vi.fn().mockResolvedValue({
|
|
327
|
+
access_token: 'new-access',
|
|
328
|
+
token_type: 'bearer',
|
|
329
|
+
expires_in: 3600,
|
|
330
|
+
})
|
|
331
|
+
const {storageArea} = setup({
|
|
332
|
+
request,
|
|
333
|
+
storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(seededTokens)},
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
await refreshOAuthTokens(instance!)
|
|
337
|
+
|
|
338
|
+
expect(readStored(storageArea)).toMatchObject({refreshToken: 'stored-refresh'})
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('logs out when there is no refresh token', async () => {
|
|
342
|
+
const noRefresh = {...seededTokens, refreshToken: undefined}
|
|
343
|
+
const {request, storageArea} = setup({
|
|
344
|
+
storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(noRefresh)},
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
const result = await refreshOAuthTokens(instance!)
|
|
348
|
+
|
|
349
|
+
expect(result).toBeNull()
|
|
350
|
+
expect(request).not.toHaveBeenCalled()
|
|
351
|
+
expect(readStored(storageArea)).toBeNull()
|
|
352
|
+
expect(getAuthState(instance!).getCurrent()).toMatchObject({type: AuthStateType.LOGGED_OUT})
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('clears tokens and logs out on an unrecoverable (4xx) refresh failure', async () => {
|
|
356
|
+
const invalidGrant = new ClientError({
|
|
357
|
+
statusCode: 400,
|
|
358
|
+
headers: {},
|
|
359
|
+
body: {error: 'invalid_grant'},
|
|
360
|
+
})
|
|
361
|
+
const request = vi.fn().mockRejectedValue(invalidGrant)
|
|
362
|
+
const {storageArea} = setup({
|
|
363
|
+
request,
|
|
364
|
+
storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(seededTokens)},
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
await expect(refreshOAuthTokens(instance!)).rejects.toBe(invalidGrant)
|
|
368
|
+
expect(readStored(storageArea)).toBeNull()
|
|
369
|
+
expect(getAuthState(instance!).getCurrent()).toMatchObject({type: AuthStateType.LOGGED_OUT})
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('keeps the session on a transient refresh failure', async () => {
|
|
373
|
+
const networkError = new Error('network down')
|
|
374
|
+
const request = vi.fn().mockRejectedValue(networkError)
|
|
375
|
+
const {storageArea} = setup({
|
|
376
|
+
request,
|
|
377
|
+
storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(seededTokens)},
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
await expect(refreshOAuthTokens(instance!)).rejects.toBe(networkError)
|
|
381
|
+
// Tokens are preserved and the user stays logged in for a retry.
|
|
382
|
+
expect(readStored(storageArea)).toMatchObject({accessToken: 'stored-access'})
|
|
383
|
+
expect(getOAuthTokensState(instance!).getCurrent()).toMatchObject({
|
|
384
|
+
accessToken: 'stored-access',
|
|
385
|
+
})
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
it('keeps the session on a 429 rate-limit refresh failure', async () => {
|
|
389
|
+
const rateLimited = new ClientError({
|
|
390
|
+
statusCode: 429,
|
|
391
|
+
headers: {},
|
|
392
|
+
body: {error: 'rate_limited'},
|
|
393
|
+
})
|
|
394
|
+
const request = vi.fn().mockRejectedValue(rateLimited)
|
|
395
|
+
const {storageArea} = setup({
|
|
396
|
+
request,
|
|
397
|
+
storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(seededTokens)},
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
await expect(refreshOAuthTokens(instance!)).rejects.toBe(rateLimited)
|
|
401
|
+
expect(readStored(storageArea)).toMatchObject({accessToken: 'stored-access'})
|
|
402
|
+
})
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
describe('revokeOAuthTokens', () => {
|
|
406
|
+
it('revokes with token and client_id, then clears local state', async () => {
|
|
407
|
+
const {request, storageArea} = setup({
|
|
408
|
+
storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(seededTokens)},
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
await revokeOAuthTokens(instance!)
|
|
412
|
+
|
|
413
|
+
expect(request).toHaveBeenCalledTimes(1)
|
|
414
|
+
const call = request.mock.calls[0][0]
|
|
415
|
+
expect(call).toMatchObject({method: 'POST', url: '/auth/oauth/revoke', tag: 'oauth.revoke'})
|
|
416
|
+
const body = parseBody(call.body)
|
|
417
|
+
expect(body.get('token')).toBe('stored-refresh')
|
|
418
|
+
expect(body.get('client_id')).toBe('client-abc')
|
|
419
|
+
|
|
420
|
+
expect(readStored(storageArea)).toBeNull()
|
|
421
|
+
expect(getAuthState(instance!).getCurrent()).toMatchObject({type: AuthStateType.LOGGED_OUT})
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
it('clears local state even when the revoke request fails', async () => {
|
|
425
|
+
const request = vi.fn().mockRejectedValue(new Error('revoke failed'))
|
|
426
|
+
const {storageArea} = setup({
|
|
427
|
+
request,
|
|
428
|
+
storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(seededTokens)},
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
await expect(revokeOAuthTokens(instance!)).resolves.toBeUndefined()
|
|
432
|
+
expect(readStored(storageArea)).toBeNull()
|
|
433
|
+
expect(getAuthState(instance!).getCurrent()).toMatchObject({type: AuthStateType.LOGGED_OUT})
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
it('clears state without calling the endpoint when there is no token', async () => {
|
|
437
|
+
const {request, storageArea} = setup()
|
|
438
|
+
|
|
439
|
+
await revokeOAuthTokens(instance!)
|
|
440
|
+
|
|
441
|
+
expect(request).not.toHaveBeenCalled()
|
|
442
|
+
expect(readStored(storageArea)).toBeNull()
|
|
443
|
+
expect(getAuthState(instance!).getCurrent()).toMatchObject({type: AuthStateType.LOGGED_OUT})
|
|
444
|
+
})
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
describe('getOAuthTokensState', () => {
|
|
448
|
+
it('exposes the current OAuth tokens', () => {
|
|
449
|
+
setup({storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(seededTokens)}})
|
|
450
|
+
expect(getOAuthTokensState(instance!).getCurrent()).toEqual(seededTokens)
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
it('returns null when there are no OAuth tokens', () => {
|
|
454
|
+
setup()
|
|
455
|
+
expect(getOAuthTokensState(instance!).getCurrent()).toBeNull()
|
|
456
|
+
})
|
|
457
|
+
})
|