@oxyhq/core 5.4.3 → 6.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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +6 -3
- package/dist/cjs/OxyServices.base.js +7 -102
- package/dist/cjs/boot/coldBootV2.js +350 -0
- package/dist/cjs/boot/deviceBootReturn.js +152 -0
- package/dist/cjs/crypto/keyManager.js +95 -0
- package/dist/cjs/i18n/locales/en-US.json +13 -1
- package/dist/cjs/i18n/locales/es-ES.json +13 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +13 -1
- package/dist/cjs/i18n/locales/locales/es-ES.json +13 -1
- package/dist/cjs/index.js +47 -43
- package/dist/cjs/mixins/OxyServices.accounts.js +6 -13
- package/dist/cjs/mixins/OxyServices.auth.js +66 -201
- package/dist/cjs/mixins/OxyServices.authorizedApps.js +38 -0
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +119 -0
- package/dist/cjs/mixins/index.js +13 -17
- package/dist/cjs/session/SessionClient.js +44 -2
- package/dist/cjs/session/authStateStore.js +284 -0
- package/dist/cjs/session/createSessionClient.js +8 -2
- package/dist/cjs/session/refresh.js +264 -0
- package/dist/cjs/utils/accountUtils.js +1 -55
- package/dist/cjs/utils/authWebUrl.js +6 -15
- package/dist/cjs/utils/fapiAutoDetect.js +16 -64
- package/dist/cjs/utils/platform.js +19 -0
- package/dist/cjs/utils/ssoBounce.js +15 -340
- package/dist/cjs/utils/validationUtils.js +57 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +6 -3
- package/dist/esm/OxyServices.base.js +7 -102
- package/dist/esm/boot/coldBootV2.js +344 -0
- package/dist/esm/boot/deviceBootReturn.js +146 -0
- package/dist/esm/crypto/keyManager.js +95 -0
- package/dist/esm/i18n/locales/en-US.json +13 -1
- package/dist/esm/i18n/locales/es-ES.json +13 -1
- package/dist/esm/i18n/locales/locales/en-US.json +13 -1
- package/dist/esm/i18n/locales/locales/es-ES.json +13 -1
- package/dist/esm/index.js +28 -18
- package/dist/esm/mixins/OxyServices.accounts.js +6 -13
- package/dist/esm/mixins/OxyServices.auth.js +66 -201
- package/dist/esm/mixins/OxyServices.authorizedApps.js +35 -0
- package/dist/esm/mixins/OxyServices.deviceBoot.js +116 -0
- package/dist/esm/mixins/index.js +13 -17
- package/dist/esm/session/SessionClient.js +44 -2
- package/dist/esm/session/authStateStore.js +278 -0
- package/dist/esm/session/createSessionClient.js +8 -2
- package/dist/esm/session/refresh.js +257 -0
- package/dist/esm/utils/accountUtils.js +0 -53
- package/dist/esm/utils/authWebUrl.js +6 -14
- package/dist/esm/utils/fapiAutoDetect.js +16 -63
- package/dist/esm/utils/platform.js +18 -0
- package/dist/esm/utils/ssoBounce.js +14 -324
- package/dist/esm/utils/validationUtils.js +56 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +13 -0
- package/dist/types/OxyServices.base.d.ts +0 -52
- package/dist/types/OxyServices.d.ts +0 -25
- package/dist/types/boot/coldBootV2.d.ts +76 -0
- package/dist/types/boot/deviceBootReturn.d.ts +83 -0
- package/dist/types/crypto/keyManager.d.ts +21 -0
- package/dist/types/index.d.ts +16 -21
- package/dist/types/mixins/OxyServices.accounts.d.ts +0 -2
- package/dist/types/mixins/OxyServices.analytics.d.ts +0 -2
- package/dist/types/mixins/OxyServices.appData.d.ts +0 -2
- package/dist/types/mixins/OxyServices.assets.d.ts +0 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +35 -77
- package/dist/types/mixins/{OxyServices.redirect.d.ts → OxyServices.authorizedApps.d.ts} +35 -33
- package/dist/types/mixins/OxyServices.civic.d.ts +0 -2
- package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -2
- package/dist/types/mixins/OxyServices.contacts.d.ts +0 -2
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +110 -0
- package/dist/types/mixins/OxyServices.devices.d.ts +0 -2
- package/dist/types/mixins/OxyServices.features.d.ts +0 -2
- package/dist/types/mixins/OxyServices.identity.d.ts +0 -2
- package/dist/types/mixins/OxyServices.language.d.ts +0 -2
- package/dist/types/mixins/OxyServices.links.d.ts +0 -2
- package/dist/types/mixins/OxyServices.location.d.ts +0 -2
- package/dist/types/mixins/OxyServices.nodes.d.ts +0 -2
- package/dist/types/mixins/OxyServices.payment.d.ts +0 -2
- package/dist/types/mixins/OxyServices.privacy.d.ts +0 -2
- package/dist/types/mixins/OxyServices.reputation.d.ts +0 -2
- package/dist/types/mixins/OxyServices.security.d.ts +0 -2
- package/dist/types/mixins/OxyServices.topics.d.ts +0 -2
- package/dist/types/mixins/OxyServices.user.d.ts +0 -2
- package/dist/types/mixins/OxyServices.utility.d.ts +0 -2
- package/dist/types/mixins/index.d.ts +6 -9
- package/dist/types/models/interfaces.d.ts +0 -67
- package/dist/types/session/SessionClient.d.ts +38 -1
- package/dist/types/session/authStateStore.d.ts +119 -0
- package/dist/types/session/createSessionClient.d.ts +8 -1
- package/dist/types/session/refresh.d.ts +93 -0
- package/dist/types/utils/accountUtils.d.ts +0 -14
- package/dist/types/utils/authWebUrl.d.ts +6 -12
- package/dist/types/utils/fapiAutoDetect.d.ts +15 -38
- package/dist/types/utils/platform.d.ts +14 -0
- package/dist/types/utils/ssoBounce.d.ts +14 -262
- package/dist/types/utils/validationUtils.d.ts +15 -0
- package/package.json +2 -2
- package/src/HttpService.ts +19 -3
- package/src/OxyServices.base.ts +7 -112
- package/src/OxyServices.ts +0 -38
- package/src/boot/__tests__/coldBootV2.test.ts +317 -0
- package/src/boot/__tests__/deviceBootReturn.test.ts +158 -0
- package/src/boot/coldBootV2.ts +426 -0
- package/src/boot/deviceBootReturn.ts +195 -0
- package/src/crypto/__tests__/sharedDeviceToken.test.ts +24 -0
- package/src/crypto/keyManager.ts +101 -0
- package/src/i18n/locales/en-US.json +13 -1
- package/src/i18n/locales/es-ES.json +13 -1
- package/src/index.ts +78 -64
- package/src/mixins/OxyServices.accounts.ts +6 -13
- package/src/mixins/OxyServices.auth.ts +78 -253
- package/src/mixins/OxyServices.authorizedApps.ts +75 -0
- package/src/mixins/OxyServices.deviceBoot.ts +146 -0
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +107 -0
- package/src/mixins/__tests__/accounts.test.ts +17 -44
- package/src/mixins/__tests__/authorizedApps.test.ts +63 -0
- package/src/mixins/__tests__/passwordSignIn.test.ts +91 -0
- package/src/mixins/index.ts +16 -22
- package/src/models/interfaces.ts +0 -79
- package/src/session/SessionClient.ts +69 -3
- package/src/session/__tests__/SessionClient.additive.test.ts +92 -0
- package/src/session/__tests__/SessionClient.rest.test.ts +25 -0
- package/src/session/__tests__/SessionClient.socketFactory.test.ts +79 -0
- package/src/session/__tests__/SessionClient.state.test.ts +18 -5
- package/src/session/__tests__/authStateStore.test.ts +209 -0
- package/src/session/__tests__/refresh.test.ts +256 -0
- package/src/session/authStateStore.ts +335 -0
- package/src/session/createSessionClient.ts +9 -1
- package/src/session/refresh.ts +334 -0
- package/src/utils/__tests__/authWebUrl.test.ts +5 -29
- package/src/utils/__tests__/fapiAutoDetect.test.ts +5 -126
- package/src/utils/__tests__/validationUtils.test.ts +30 -0
- package/src/utils/accountUtils.ts +0 -62
- package/src/utils/authWebUrl.ts +6 -15
- package/src/utils/fapiAutoDetect.ts +16 -60
- package/src/utils/platform.ts +21 -0
- package/src/utils/ssoBounce.ts +14 -371
- package/src/utils/validationUtils.ts +62 -0
- package/dist/cjs/AuthManager.js +0 -1110
- package/dist/cjs/AuthManagerTypes.js +0 -13
- package/dist/cjs/CrossDomainAuth.js +0 -206
- package/dist/cjs/mixins/OxyServices.fedcm.js +0 -823
- package/dist/cjs/mixins/OxyServices.redirect.js +0 -95
- package/dist/cjs/mixins/OxyServices.silent.js +0 -204
- package/dist/cjs/mixins/OxyServices.sso.js +0 -208
- package/dist/cjs/utils/ssoEstablish.js +0 -110
- package/dist/cjs/utils/ssoReturn.js +0 -267
- package/dist/esm/AuthManager.js +0 -1105
- package/dist/esm/AuthManagerTypes.js +0 -12
- package/dist/esm/CrossDomainAuth.js +0 -201
- package/dist/esm/mixins/OxyServices.fedcm.js +0 -821
- package/dist/esm/mixins/OxyServices.redirect.js +0 -92
- package/dist/esm/mixins/OxyServices.silent.js +0 -202
- package/dist/esm/mixins/OxyServices.sso.js +0 -204
- package/dist/esm/utils/ssoEstablish.js +0 -107
- package/dist/esm/utils/ssoReturn.js +0 -263
- package/dist/types/AuthManager.d.ts +0 -380
- package/dist/types/AuthManagerTypes.d.ts +0 -81
- package/dist/types/CrossDomainAuth.d.ts +0 -164
- package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -331
- package/dist/types/mixins/OxyServices.silent.d.ts +0 -132
- package/dist/types/mixins/OxyServices.sso.d.ts +0 -138
- package/dist/types/utils/ssoEstablish.d.ts +0 -85
- package/dist/types/utils/ssoReturn.d.ts +0 -146
- package/src/AuthManager.ts +0 -1269
- package/src/AuthManagerTypes.ts +0 -86
- package/src/CrossDomainAuth.ts +0 -243
- package/src/__tests__/authManager.cookiePath.test.ts +0 -390
- package/src/__tests__/authManager.security.test.ts +0 -377
- package/src/__tests__/crossDomainAuth.test.ts +0 -116
- package/src/__tests__/establishDeviceRefreshSlot.test.ts +0 -221
- package/src/mixins/OxyServices.fedcm.ts +0 -1026
- package/src/mixins/OxyServices.redirect.ts +0 -122
- package/src/mixins/OxyServices.silent.ts +0 -272
- package/src/mixins/OxyServices.sso.ts +0 -261
- package/src/mixins/__tests__/constructorAuthWebUrl.test.ts +0 -85
- package/src/mixins/__tests__/fedcm.test.ts +0 -667
- package/src/mixins/__tests__/sessionBaseUrl.test.ts +0 -61
- package/src/mixins/__tests__/silent.test.ts +0 -102
- package/src/mixins/__tests__/sso.test.ts +0 -228
- package/src/utils/__tests__/consumeSsoReturn.test.ts +0 -816
- package/src/utils/__tests__/ssoBounce.test.ts +0 -219
- package/src/utils/__tests__/ssoEstablish.test.ts +0 -204
- package/src/utils/__tests__/ssoReturn.test.ts +0 -251
- package/src/utils/ssoEstablish.ts +0 -174
- package/src/utils/ssoReturn.ts +0 -372
|
@@ -77,4 +77,29 @@ describe('SessionClient REST', () => {
|
|
|
77
77
|
expect(c.getState()?.revision).toBe(7);
|
|
78
78
|
expect(host.setTokens).not.toHaveBeenCalled();
|
|
79
79
|
});
|
|
80
|
+
|
|
81
|
+
it('accepts a NEW device\'s lower-revision state and plants its token (cross-device revision reset)', async () => {
|
|
82
|
+
const deviceA: DeviceSessionState = {
|
|
83
|
+
deviceId: 'A', accounts: [{ accountId: 'a1', sessionId: 's1', authuser: 0 }], activeAccountId: 'a1', revision: 10, updatedAt: 1720000000000,
|
|
84
|
+
};
|
|
85
|
+
const deviceB: DeviceSessionState = {
|
|
86
|
+
deviceId: 'B', accounts: [{ accountId: 'a2', sessionId: 's2', authuser: 0 }], activeAccountId: 'a2', revision: 1, updatedAt: 1720000001000,
|
|
87
|
+
};
|
|
88
|
+
const makeRequest = jest.fn()
|
|
89
|
+
.mockResolvedValueOnce({ state: deviceA, activeToken: { accessToken: 'jwt-A', expiresAt: 'x' } })
|
|
90
|
+
.mockResolvedValueOnce({ state: deviceB, activeToken: { accessToken: 'jwt-B', expiresAt: 'x' } });
|
|
91
|
+
const host = makeHost(makeRequest);
|
|
92
|
+
const c = new SessionClient(host);
|
|
93
|
+
|
|
94
|
+
await c.bootstrap();
|
|
95
|
+
expect(c.getState()?.deviceId).toBe('A');
|
|
96
|
+
expect(host.setTokens).toHaveBeenLastCalledWith('jwt-A');
|
|
97
|
+
|
|
98
|
+
// Device B is freshly converged (revision 1) — it must win over device A's
|
|
99
|
+
// stale-but-higher revision, and its token must be planted.
|
|
100
|
+
await c.bootstrap();
|
|
101
|
+
expect(c.getState()?.deviceId).toBe('B');
|
|
102
|
+
expect(c.getState()?.revision).toBe(1);
|
|
103
|
+
expect(host.setTokens).toHaveBeenLastCalledWith('jwt-B');
|
|
104
|
+
});
|
|
80
105
|
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { DeviceSessionState } from '@oxyhq/contracts';
|
|
2
|
+
import * as socketLoader from '../socketLoader';
|
|
3
|
+
import type { MinimalSocket, SocketIOFactory } from '../socketLoader';
|
|
4
|
+
import { SessionClient, type SessionClientHost } from '../SessionClient';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* P0 (bundler-fragility): SessionClient must NOT depend on a runtime dynamic
|
|
8
|
+
* `import('socket.io-client')` when a consumer (services/auth-sdk) injects the
|
|
9
|
+
* `io` factory statically. These tests pin the two branches of `connectSocket`:
|
|
10
|
+
*
|
|
11
|
+
* 1. `socketFactory` injected → the injected factory is used and the lazy
|
|
12
|
+
* loader (`getSocketIO`) is NEVER invoked (the exact call that fails in the
|
|
13
|
+
* Metro/Expo-web + Vite published-dist consumers).
|
|
14
|
+
* 2. no `socketFactory` → the lazy loader IS invoked (fallback).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
type Handler = (...args: unknown[]) => void;
|
|
18
|
+
class FakeSocket implements MinimalSocket {
|
|
19
|
+
connected = false;
|
|
20
|
+
handlers = new Map<string, Handler[]>();
|
|
21
|
+
on(event: string, cb: Handler) { const l = this.handlers.get(event) ?? []; l.push(cb); this.handlers.set(event, l); }
|
|
22
|
+
off(event: string, cb?: Handler) { if (!cb) { this.handlers.delete(event); return; } this.handlers.set(event, (this.handlers.get(event) ?? []).filter((h) => h !== cb)); }
|
|
23
|
+
connect() { this.connected = true; }
|
|
24
|
+
disconnect() { this.connected = false; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const STATE = (rev: number): DeviceSessionState => ({ deviceId: 'd1', accounts: [{ accountId: 'a1', sessionId: 's1', authuser: 0 }], activeAccountId: 'a1', revision: rev, updatedAt: 1720000000000 });
|
|
28
|
+
const SYNC = (rev: number) => ({ state: STATE(rev), activeToken: { accessToken: `jwt-${rev}`, expiresAt: 'x' } });
|
|
29
|
+
|
|
30
|
+
function makeHost(over: Partial<SessionClientHost> = {}): SessionClientHost {
|
|
31
|
+
return {
|
|
32
|
+
makeRequest: jest.fn().mockResolvedValue(SYNC(1)),
|
|
33
|
+
getBaseURL: () => 'http://test.invalid',
|
|
34
|
+
getAccessToken: () => 'tok',
|
|
35
|
+
onTokensChanged: () => () => undefined,
|
|
36
|
+
setTokens: jest.fn(),
|
|
37
|
+
getCurrentAccountId: () => 'a1',
|
|
38
|
+
...over,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('SessionClient injected socketFactory', () => {
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
jest.restoreAllMocks();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('uses the injected factory and NEVER invokes the lazy loader', async () => {
|
|
48
|
+
const loaderSpy = jest.spyOn(socketLoader, 'getSocketIO');
|
|
49
|
+
let created: FakeSocket | null = null;
|
|
50
|
+
const factory: SocketIOFactory = jest.fn((_uri: string) => {
|
|
51
|
+
created = new FakeSocket();
|
|
52
|
+
created.connected = true;
|
|
53
|
+
return created;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const client = new SessionClient(makeHost(), { socketFactory: factory });
|
|
57
|
+
await client.start();
|
|
58
|
+
|
|
59
|
+
expect(factory).toHaveBeenCalledTimes(1);
|
|
60
|
+
expect(factory).toHaveBeenCalledWith('http://test.invalid', expect.objectContaining({ transports: ['websocket'] }));
|
|
61
|
+
expect(loaderSpy).not.toHaveBeenCalled();
|
|
62
|
+
expect(created).not.toBeNull();
|
|
63
|
+
client.stop();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('falls back to the lazy loader when no factory is injected', async () => {
|
|
67
|
+
const fake = new FakeSocket();
|
|
68
|
+
fake.connected = true;
|
|
69
|
+
const lazyFactory: SocketIOFactory = jest.fn(() => fake);
|
|
70
|
+
const loaderSpy = jest.spyOn(socketLoader, 'getSocketIO').mockResolvedValue(lazyFactory);
|
|
71
|
+
|
|
72
|
+
const client = new SessionClient(makeHost());
|
|
73
|
+
await client.start();
|
|
74
|
+
|
|
75
|
+
expect(loaderSpy).toHaveBeenCalledTimes(1);
|
|
76
|
+
expect(lazyFactory).toHaveBeenCalledWith('http://test.invalid', expect.objectContaining({ transports: ['websocket'] }));
|
|
77
|
+
client.stop();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -32,12 +32,25 @@ describe('SessionClient state', () => {
|
|
|
32
32
|
expect(seen.at(-1)?.revision).toBe(1);
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
-
it('ignores a stale or equal revision (last-writer-wins)', () => {
|
|
35
|
+
it('ignores a stale or equal revision (last-writer-wins) WITHIN the same device', () => {
|
|
36
36
|
const c = new TestClient(makeHost());
|
|
37
|
-
c.apply(STATE(5));
|
|
38
|
-
expect(c.apply(STATE(5))).toBe(false);
|
|
39
|
-
expect(c.apply(STATE(4))).toBe(false);
|
|
40
|
-
expect(c.
|
|
37
|
+
c.apply({ ...STATE(5), deviceId: 'A' });
|
|
38
|
+
expect(c.apply({ ...STATE(5), deviceId: 'A' })).toBe(false);
|
|
39
|
+
expect(c.apply({ ...STATE(4), deviceId: 'A' })).toBe(false);
|
|
40
|
+
expect(c.apply({ ...STATE(6), deviceId: 'A' })).toBe(true);
|
|
41
|
+
expect(c.getState()?.revision).toBe(6);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('accepts a LOWER-revision state from a DIFFERENT device (revision baseline resets cross-device)', () => {
|
|
45
|
+
const c = new TestClient(makeHost());
|
|
46
|
+
// Device A at a high revision.
|
|
47
|
+
expect(c.apply({ ...STATE(10), deviceId: 'A' })).toBe(true);
|
|
48
|
+
expect(c.getState()?.deviceId).toBe('A');
|
|
49
|
+
// Device B, freshly converged (revision 1), must win over device A's
|
|
50
|
+
// stale-but-higher revision — the cross-device comparison is not monotone.
|
|
51
|
+
expect(c.apply({ ...STATE(1), deviceId: 'B' })).toBe(true);
|
|
52
|
+
expect(c.getState()?.deviceId).toBe('B');
|
|
53
|
+
expect(c.getState()?.revision).toBe(1);
|
|
41
54
|
});
|
|
42
55
|
|
|
43
56
|
it('rejects an invalid (unvalidated) state without applying', () => {
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createWebAuthStateStore,
|
|
3
|
+
createNativeAuthStateStore,
|
|
4
|
+
createMemoryAuthStateStore,
|
|
5
|
+
AUTH_STATE_STORAGE_KEY,
|
|
6
|
+
DEVICE_TOKEN_STORAGE_KEY,
|
|
7
|
+
type PersistedAuthState,
|
|
8
|
+
type NativeKeyValueStorage,
|
|
9
|
+
} from '../authStateStore';
|
|
10
|
+
|
|
11
|
+
const SAMPLE: PersistedAuthState = {
|
|
12
|
+
sessionId: 's-1',
|
|
13
|
+
refreshToken: 'r-abcdefghijklmnop',
|
|
14
|
+
userId: 'u-1',
|
|
15
|
+
deviceToken: 'd-1234567890',
|
|
16
|
+
accessToken: 'a-jwt',
|
|
17
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** A minimal in-map Storage; setItem can be made to throw (quota simulation). */
|
|
21
|
+
function makeFakeStorage(opts: { throwOnSet?: boolean } = {}): Storage {
|
|
22
|
+
const map = new Map<string, string>();
|
|
23
|
+
return {
|
|
24
|
+
getItem: (k: string) => (map.has(k) ? (map.get(k) as string) : null),
|
|
25
|
+
setItem: (k: string, v: string) => {
|
|
26
|
+
if (opts.throwOnSet) {
|
|
27
|
+
throw new DOMException('QuotaExceededError', 'QuotaExceededError');
|
|
28
|
+
}
|
|
29
|
+
map.set(k, v);
|
|
30
|
+
},
|
|
31
|
+
removeItem: (k: string) => {
|
|
32
|
+
map.delete(k);
|
|
33
|
+
},
|
|
34
|
+
clear: () => map.clear(),
|
|
35
|
+
key: (i: number) => Array.from(map.keys())[i] ?? null,
|
|
36
|
+
get length() {
|
|
37
|
+
return map.size;
|
|
38
|
+
},
|
|
39
|
+
} as Storage;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function installLocalStorage(storage: Storage): void {
|
|
43
|
+
Object.defineProperty(globalThis, 'localStorage', {
|
|
44
|
+
value: storage,
|
|
45
|
+
configurable: true,
|
|
46
|
+
writable: true,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
// Remove whatever localStorage the test installed (value or throwing getter).
|
|
52
|
+
if (Object.getOwnPropertyDescriptor(globalThis, 'localStorage')) {
|
|
53
|
+
delete (globalThis as { localStorage?: Storage }).localStorage;
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('createWebAuthStateStore', () => {
|
|
58
|
+
it('round-trips a PersistedAuthState under the versioned key', async () => {
|
|
59
|
+
const storage = makeFakeStorage();
|
|
60
|
+
installLocalStorage(storage);
|
|
61
|
+
const store = createWebAuthStateStore();
|
|
62
|
+
|
|
63
|
+
await store.save(SAMPLE);
|
|
64
|
+
expect(storage.getItem(AUTH_STATE_STORAGE_KEY)).toBeTruthy();
|
|
65
|
+
expect(await store.load()).toEqual(SAMPLE);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('clear() wipes the session but the deviceToken survives', async () => {
|
|
69
|
+
installLocalStorage(makeFakeStorage());
|
|
70
|
+
const store = createWebAuthStateStore();
|
|
71
|
+
|
|
72
|
+
await store.save(SAMPLE);
|
|
73
|
+
await store.saveDeviceToken('device-token-xyz');
|
|
74
|
+
await store.clear();
|
|
75
|
+
|
|
76
|
+
expect(await store.load()).toBeNull();
|
|
77
|
+
expect(await store.loadDeviceToken()).toBe('device-token-xyz');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('persists the deviceToken under a separate long-lived key', async () => {
|
|
81
|
+
const storage = makeFakeStorage();
|
|
82
|
+
installLocalStorage(storage);
|
|
83
|
+
const store = createWebAuthStateStore();
|
|
84
|
+
|
|
85
|
+
await store.saveDeviceToken('dt');
|
|
86
|
+
expect(storage.getItem(DEVICE_TOKEN_STORAGE_KEY)).toBe('dt');
|
|
87
|
+
await store.clearDeviceToken();
|
|
88
|
+
expect(await store.loadDeviceToken()).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns null for a malformed or incomplete blob', async () => {
|
|
92
|
+
const storage = makeFakeStorage();
|
|
93
|
+
installLocalStorage(storage);
|
|
94
|
+
const store = createWebAuthStateStore();
|
|
95
|
+
|
|
96
|
+
storage.setItem(AUTH_STATE_STORAGE_KEY, 'not-json');
|
|
97
|
+
expect(await store.load()).toBeNull();
|
|
98
|
+
|
|
99
|
+
storage.setItem(AUTH_STATE_STORAGE_KEY, JSON.stringify({ sessionId: 's', userId: 'u' }));
|
|
100
|
+
expect(await store.load()).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('degrades to in-memory when accessing localStorage throws (sandboxed iframe SecurityError)', async () => {
|
|
104
|
+
Object.defineProperty(globalThis, 'localStorage', {
|
|
105
|
+
configurable: true,
|
|
106
|
+
get() {
|
|
107
|
+
throw new DOMException('The operation is insecure.', 'SecurityError');
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Construction must not throw, and the store must still function (in-memory).
|
|
112
|
+
const store = createWebAuthStateStore();
|
|
113
|
+
await expect(store.save(SAMPLE)).resolves.toBeUndefined();
|
|
114
|
+
expect(await store.load()).toEqual(SAMPLE);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('swallows a write that throws (quota / private mode) without rejecting', async () => {
|
|
118
|
+
installLocalStorage(makeFakeStorage({ throwOnSet: true }));
|
|
119
|
+
const store = createWebAuthStateStore();
|
|
120
|
+
|
|
121
|
+
await expect(store.save(SAMPLE)).resolves.toBeUndefined();
|
|
122
|
+
// The write never reached storage...
|
|
123
|
+
expect(localStorage.getItem(AUTH_STATE_STORAGE_KEY)).toBeNull();
|
|
124
|
+
// ...but the in-memory mirror keeps the session live for this page's lifetime.
|
|
125
|
+
expect(await store.load()).toEqual(SAMPLE);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('the mirror keeps the deviceToken live when the write throws', async () => {
|
|
129
|
+
installLocalStorage(makeFakeStorage({ throwOnSet: true }));
|
|
130
|
+
const store = createWebAuthStateStore();
|
|
131
|
+
|
|
132
|
+
await store.saveDeviceToken('dt-mirrored');
|
|
133
|
+
expect(await store.loadDeviceToken()).toBe('dt-mirrored');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('a cleared session reads null even if storage later holds a stale blob (mirror wins)', async () => {
|
|
137
|
+
const storage = makeFakeStorage();
|
|
138
|
+
installLocalStorage(storage);
|
|
139
|
+
const store = createWebAuthStateStore();
|
|
140
|
+
|
|
141
|
+
await store.save(SAMPLE);
|
|
142
|
+
await store.clear();
|
|
143
|
+
// Something (another tab / a failed remove) leaves a stale blob behind.
|
|
144
|
+
storage.setItem(AUTH_STATE_STORAGE_KEY, JSON.stringify(SAMPLE));
|
|
145
|
+
// The authoritative in-memory mirror still reports the cleared state.
|
|
146
|
+
expect(await store.load()).toBeNull();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('createNativeAuthStateStore', () => {
|
|
151
|
+
function makeNativeStorage(): NativeKeyValueStorage & { map: Map<string, string> } {
|
|
152
|
+
const map = new Map<string, string>();
|
|
153
|
+
return {
|
|
154
|
+
map,
|
|
155
|
+
getItem: async (k) => (map.has(k) ? (map.get(k) as string) : null),
|
|
156
|
+
setItem: async (k, v) => {
|
|
157
|
+
map.set(k, v);
|
|
158
|
+
},
|
|
159
|
+
removeItem: async (k) => {
|
|
160
|
+
map.delete(k);
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
it('round-trips through the injected async storage', async () => {
|
|
166
|
+
const storage = makeNativeStorage();
|
|
167
|
+
const store = createNativeAuthStateStore(storage);
|
|
168
|
+
|
|
169
|
+
await store.save(SAMPLE);
|
|
170
|
+
expect(storage.map.get(AUTH_STATE_STORAGE_KEY)).toBeTruthy();
|
|
171
|
+
expect(await store.load()).toEqual(SAMPLE);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('deviceToken survives a session clear', async () => {
|
|
175
|
+
const store = createNativeAuthStateStore(makeNativeStorage());
|
|
176
|
+
await store.save(SAMPLE);
|
|
177
|
+
await store.saveDeviceToken('dt-native');
|
|
178
|
+
await store.clear();
|
|
179
|
+
expect(await store.load()).toBeNull();
|
|
180
|
+
expect(await store.loadDeviceToken()).toBe('dt-native');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('keeps the session live via the mirror when the injected storage throws (locked keychain)', async () => {
|
|
184
|
+
const store = createNativeAuthStateStore({
|
|
185
|
+
getItem: async () => {
|
|
186
|
+
throw new Error('secure store locked');
|
|
187
|
+
},
|
|
188
|
+
setItem: async () => {
|
|
189
|
+
throw new Error('secure store locked');
|
|
190
|
+
},
|
|
191
|
+
removeItem: async () => {
|
|
192
|
+
throw new Error('secure store locked');
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
await expect(store.save(SAMPLE)).resolves.toBeUndefined();
|
|
196
|
+
// The write threw, but the in-memory mirror preserves the session.
|
|
197
|
+
expect(await store.load()).toEqual(SAMPLE);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('createMemoryAuthStateStore', () => {
|
|
202
|
+
it('round-trips and clears in process memory', async () => {
|
|
203
|
+
const store = createMemoryAuthStateStore();
|
|
204
|
+
await store.save(SAMPLE);
|
|
205
|
+
expect(await store.load()).toEqual(SAMPLE);
|
|
206
|
+
await store.clear();
|
|
207
|
+
expect(await store.load()).toBeNull();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import type { OxyServices } from '../../OxyServices';
|
|
2
|
+
import type { TokenRefreshResponse } from '@oxyhq/contracts';
|
|
3
|
+
import type { SessionLoginResponse } from '../../models/session';
|
|
4
|
+
import { refreshPersistedSession, startTokenRefreshScheduler } from '../refresh';
|
|
5
|
+
import { createMemoryAuthStateStore, type PersistedAuthState } from '../authStateStore';
|
|
6
|
+
|
|
7
|
+
const STORED: PersistedAuthState = {
|
|
8
|
+
sessionId: 'sess-old',
|
|
9
|
+
refreshToken: 'refresh-old-abcdefghij',
|
|
10
|
+
userId: 'user-1',
|
|
11
|
+
deviceToken: 'device-abcdefghij',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const ROTATED: TokenRefreshResponse = {
|
|
15
|
+
accessToken: 'access-new',
|
|
16
|
+
refreshToken: 'refresh-new-abcdefghij',
|
|
17
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
18
|
+
sessionId: 'sess-new',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
interface RefreshMockOverrides {
|
|
22
|
+
refreshWithToken?: OxyServices['refreshWithToken'];
|
|
23
|
+
signInWithSharedIdentity?: OxyServices['signInWithSharedIdentity'];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeOxy(overrides: RefreshMockOverrides = {}): { oxy: OxyServices; setTokens: jest.Mock } {
|
|
27
|
+
const setTokens = jest.fn();
|
|
28
|
+
const oxy = {
|
|
29
|
+
setTokens,
|
|
30
|
+
refreshWithToken: overrides.refreshWithToken ?? (async () => ROTATED),
|
|
31
|
+
signInWithSharedIdentity: overrides.signInWithSharedIdentity ?? (async () => null),
|
|
32
|
+
} as unknown as OxyServices;
|
|
33
|
+
return { oxy, setTokens };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('refreshPersistedSession — arm 1 (refresh-token rotation)', () => {
|
|
37
|
+
it('rotates, plants, persists the new family head, and returns the new token', async () => {
|
|
38
|
+
const store = createMemoryAuthStateStore();
|
|
39
|
+
await store.save(STORED);
|
|
40
|
+
const { oxy, setTokens } = makeOxy();
|
|
41
|
+
|
|
42
|
+
const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
|
|
43
|
+
|
|
44
|
+
expect(token).toBe('access-new');
|
|
45
|
+
expect(setTokens).toHaveBeenCalledWith('access-new');
|
|
46
|
+
expect(await store.load()).toEqual({
|
|
47
|
+
sessionId: 'sess-new',
|
|
48
|
+
refreshToken: 'refresh-new-abcdefghij',
|
|
49
|
+
userId: 'user-1',
|
|
50
|
+
deviceToken: 'device-abcdefghij',
|
|
51
|
+
accessToken: 'access-new',
|
|
52
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('clears the store on a family-revoked (401) error', async () => {
|
|
57
|
+
const store = createMemoryAuthStateStore();
|
|
58
|
+
await store.save(STORED);
|
|
59
|
+
const { oxy } = makeOxy({
|
|
60
|
+
refreshWithToken: async () => {
|
|
61
|
+
throw Object.assign(new Error('revoked'), { status: 401 });
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
|
|
66
|
+
|
|
67
|
+
expect(token).toBeNull();
|
|
68
|
+
expect(await store.load()).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('clears the store on an invalid_grant code', async () => {
|
|
72
|
+
const store = createMemoryAuthStateStore();
|
|
73
|
+
await store.save(STORED);
|
|
74
|
+
const { oxy } = makeOxy({
|
|
75
|
+
refreshWithToken: async () => {
|
|
76
|
+
throw Object.assign(new Error('bad'), { code: 'invalid_grant' });
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false })).toBeNull();
|
|
81
|
+
expect(await store.load()).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('KEEPS the store on a transient (500 / network) error', async () => {
|
|
85
|
+
const store = createMemoryAuthStateStore();
|
|
86
|
+
await store.save(STORED);
|
|
87
|
+
const { oxy } = makeOxy({
|
|
88
|
+
refreshWithToken: async () => {
|
|
89
|
+
throw Object.assign(new Error('server'), { status: 500 });
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false })).toBeNull();
|
|
94
|
+
expect(await store.load()).toEqual(STORED);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('refreshPersistedSession — arm 2 (native shared-key fallback)', () => {
|
|
99
|
+
const SHARED_SESSION: SessionLoginResponse = {
|
|
100
|
+
sessionId: 'sess-shared',
|
|
101
|
+
deviceId: 'dev-1',
|
|
102
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
103
|
+
user: { id: 'user-1', username: 'u', name: {}, avatar: undefined },
|
|
104
|
+
accessToken: 'access-shared',
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
it('re-mints via shared identity when there is no refresh token', async () => {
|
|
108
|
+
const store = createMemoryAuthStateStore(); // no stored refresh token
|
|
109
|
+
const signInWithSharedIdentity = jest.fn(async () => SHARED_SESSION);
|
|
110
|
+
const { oxy } = makeOxy({ signInWithSharedIdentity });
|
|
111
|
+
|
|
112
|
+
const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true });
|
|
113
|
+
|
|
114
|
+
expect(token).toBe('access-shared');
|
|
115
|
+
expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('falls back to shared-key after a revoked refresh token', async () => {
|
|
119
|
+
const store = createMemoryAuthStateStore();
|
|
120
|
+
await store.save(STORED);
|
|
121
|
+
const signInWithSharedIdentity = jest.fn(async () => SHARED_SESSION);
|
|
122
|
+
const { oxy } = makeOxy({
|
|
123
|
+
refreshWithToken: async () => {
|
|
124
|
+
throw Object.assign(new Error('revoked'), { status: 403 });
|
|
125
|
+
},
|
|
126
|
+
signInWithSharedIdentity,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true });
|
|
130
|
+
|
|
131
|
+
expect(token).toBe('access-shared');
|
|
132
|
+
expect(await store.load()).toBeNull(); // revoked family was cleared
|
|
133
|
+
expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('does NOT try shared-key when the fallback is disabled (web)', async () => {
|
|
137
|
+
const store = createMemoryAuthStateStore();
|
|
138
|
+
const signInWithSharedIdentity = jest.fn(async () => SHARED_SESSION);
|
|
139
|
+
const { oxy } = makeOxy({ signInWithSharedIdentity });
|
|
140
|
+
|
|
141
|
+
expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false })).toBeNull();
|
|
142
|
+
expect(signInWithSharedIdentity).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('startTokenRefreshScheduler', () => {
|
|
147
|
+
function makeSchedulerOxy(expiresInSeconds: number | null): {
|
|
148
|
+
oxy: OxyServices;
|
|
149
|
+
refreshAccessToken: jest.Mock;
|
|
150
|
+
} {
|
|
151
|
+
let cleared = false;
|
|
152
|
+
const refreshAccessToken = jest.fn(async () => {
|
|
153
|
+
cleared = true; // stop the reschedule loop after the first fire
|
|
154
|
+
return null;
|
|
155
|
+
});
|
|
156
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
157
|
+
const oxy = {
|
|
158
|
+
getAccessToken: () => (cleared ? null : 'tok'),
|
|
159
|
+
getAccessTokenExpiry: () => (expiresInSeconds === null ? null : nowSec + expiresInSeconds),
|
|
160
|
+
onTokensChanged: () => () => undefined,
|
|
161
|
+
httpService: { refreshAccessToken },
|
|
162
|
+
} as unknown as OxyServices;
|
|
163
|
+
return { oxy, refreshAccessToken };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
afterEach(() => {
|
|
167
|
+
jest.useRealTimers();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('fires a refresh ~60s before expiry', async () => {
|
|
171
|
+
jest.useFakeTimers();
|
|
172
|
+
const { oxy, refreshAccessToken } = makeSchedulerOxy(120);
|
|
173
|
+
const handle = startTokenRefreshScheduler(oxy);
|
|
174
|
+
|
|
175
|
+
expect(refreshAccessToken).not.toHaveBeenCalled();
|
|
176
|
+
await jest.advanceTimersByTimeAsync(60_000);
|
|
177
|
+
expect(refreshAccessToken).toHaveBeenCalledWith('preflight');
|
|
178
|
+
|
|
179
|
+
handle.dispose();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('no-ops cleanly for an opaque token with no exp', () => {
|
|
183
|
+
jest.useFakeTimers();
|
|
184
|
+
const { oxy, refreshAccessToken } = makeSchedulerOxy(null);
|
|
185
|
+
const handle = startTokenRefreshScheduler(oxy);
|
|
186
|
+
jest.advanceTimersByTime(10 * 60_000);
|
|
187
|
+
expect(refreshAccessToken).not.toHaveBeenCalled();
|
|
188
|
+
handle.dispose();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('backs off on repeated failure — bounded attempts, never a zero-delay busy loop', async () => {
|
|
192
|
+
jest.useFakeTimers();
|
|
193
|
+
// Already-expired token + a refresh that ALWAYS fails. The old code floored
|
|
194
|
+
// the delay to 0 and re-armed in the finally block → a tight loop that would
|
|
195
|
+
// exhaust jest's 100k-timer guard. The backoff schedule fires only a handful
|
|
196
|
+
// of times per simulated minute.
|
|
197
|
+
const refreshAccessToken = jest.fn(async () => null);
|
|
198
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
199
|
+
const oxy = {
|
|
200
|
+
getAccessToken: () => 'tok',
|
|
201
|
+
getAccessTokenExpiry: () => nowSec - 10,
|
|
202
|
+
onTokensChanged: () => () => undefined,
|
|
203
|
+
httpService: { refreshAccessToken },
|
|
204
|
+
} as unknown as OxyServices;
|
|
205
|
+
|
|
206
|
+
const handle = startTokenRefreshScheduler(oxy);
|
|
207
|
+
await jest.advanceTimersByTimeAsync(60_000);
|
|
208
|
+
|
|
209
|
+
expect(refreshAccessToken.mock.calls.length).toBeGreaterThan(0);
|
|
210
|
+
expect(refreshAccessToken.mock.calls.length).toBeLessThan(10);
|
|
211
|
+
handle.dispose();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('resets the backoff after a successful refresh', async () => {
|
|
215
|
+
jest.useFakeTimers();
|
|
216
|
+
// Expiry is always ~1h from NOW, so a successful refresh genuinely pushes
|
|
217
|
+
// the token out (as a real rotation would). Fail once, then succeed.
|
|
218
|
+
const refreshAccessToken = jest
|
|
219
|
+
.fn<Promise<string | null>, [unknown]>()
|
|
220
|
+
.mockResolvedValueOnce(null)
|
|
221
|
+
.mockResolvedValue('fresh');
|
|
222
|
+
const oxy = {
|
|
223
|
+
getAccessToken: () => 'tok',
|
|
224
|
+
getAccessTokenExpiry: () => Math.floor(Date.now() / 1000) + 3600,
|
|
225
|
+
onTokensChanged: () => () => undefined,
|
|
226
|
+
httpService: { refreshAccessToken },
|
|
227
|
+
} as unknown as OxyServices;
|
|
228
|
+
|
|
229
|
+
const handle = startTokenRefreshScheduler(oxy);
|
|
230
|
+
// First fire ~ (3600-60)s out (fails → 5s backoff), then the backoff fire
|
|
231
|
+
// succeeds and re-arms ~1h out from the (now-fresh) expiry.
|
|
232
|
+
await jest.advanceTimersByTimeAsync((3600 - 60) * 1000);
|
|
233
|
+
await jest.advanceTimersByTimeAsync(5_000);
|
|
234
|
+
const callsAfterSuccess = refreshAccessToken.mock.calls.length;
|
|
235
|
+
expect(callsAfterSuccess).toBe(2); // one failure + one success
|
|
236
|
+
// A further minute must NOT produce a burst — the healthy re-arm is ~1h out.
|
|
237
|
+
await jest.advanceTimersByTimeAsync(60_000);
|
|
238
|
+
expect(refreshAccessToken.mock.calls.length).toBe(callsAfterSuccess);
|
|
239
|
+
handle.dispose();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('calls unref() on its timer so it never holds the event loop', () => {
|
|
243
|
+
const unref = jest.fn();
|
|
244
|
+
const setTimeoutSpy = jest
|
|
245
|
+
.spyOn(global, 'setTimeout')
|
|
246
|
+
.mockReturnValue({ unref } as unknown as ReturnType<typeof setTimeout>);
|
|
247
|
+
const { oxy } = makeSchedulerOxy(120);
|
|
248
|
+
|
|
249
|
+
const handle = startTokenRefreshScheduler(oxy);
|
|
250
|
+
expect(setTimeoutSpy).toHaveBeenCalled();
|
|
251
|
+
expect(unref).toHaveBeenCalled();
|
|
252
|
+
|
|
253
|
+
handle.dispose();
|
|
254
|
+
setTimeoutSpy.mockRestore();
|
|
255
|
+
});
|
|
256
|
+
});
|