@oxy.so/core 1.2.1 → 1.3.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/crypto/identityMove.js +123 -0
- package/dist/cjs/crypto/keyManager.js +60 -21
- package/dist/cjs/crypto/webIdentityCarrier.js +262 -0
- package/dist/cjs/i18n/locales/ar-SA.json +16 -13
- package/dist/cjs/i18n/locales/ca-ES.json +16 -13
- package/dist/cjs/i18n/locales/de-DE.json +16 -13
- package/dist/cjs/i18n/locales/en-US.json +16 -17
- package/dist/cjs/i18n/locales/es-ES.json +16 -17
- package/dist/cjs/i18n/locales/fr-FR.json +16 -13
- package/dist/cjs/i18n/locales/it-IT.json +16 -13
- package/dist/cjs/i18n/locales/ja-JP.json +16 -13
- package/dist/cjs/i18n/locales/ko-KR.json +16 -13
- package/dist/cjs/i18n/locales/locales/ar-SA.json +16 -13
- package/dist/cjs/i18n/locales/locales/ca-ES.json +16 -13
- package/dist/cjs/i18n/locales/locales/de-DE.json +16 -13
- package/dist/cjs/i18n/locales/locales/en-US.json +16 -17
- package/dist/cjs/i18n/locales/locales/es-ES.json +16 -17
- package/dist/cjs/i18n/locales/locales/fr-FR.json +16 -13
- package/dist/cjs/i18n/locales/locales/it-IT.json +16 -13
- package/dist/cjs/i18n/locales/locales/ja-JP.json +16 -13
- package/dist/cjs/i18n/locales/locales/ko-KR.json +16 -13
- package/dist/cjs/i18n/locales/locales/pt-PT.json +16 -13
- package/dist/cjs/i18n/locales/locales/zh-CN.json +16 -13
- package/dist/cjs/i18n/locales/pt-PT.json +16 -13
- package/dist/cjs/i18n/locales/zh-CN.json +16 -13
- package/dist/cjs/index.js +38 -4
- package/dist/cjs/mixins/OxyServices.auth.js +14 -4
- package/dist/cjs/mixins/OxyServices.identity.js +14 -0
- package/dist/cjs/session/accountDialogController.js +333 -142
- package/dist/cjs/utils/authWebUrl.js +8 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/crypto/identityMove.js +110 -0
- package/dist/esm/crypto/keyManager.js +60 -21
- package/dist/esm/crypto/webIdentityCarrier.js +243 -0
- package/dist/esm/i18n/locales/ar-SA.json +16 -13
- package/dist/esm/i18n/locales/ca-ES.json +16 -13
- package/dist/esm/i18n/locales/de-DE.json +16 -13
- package/dist/esm/i18n/locales/en-US.json +16 -17
- package/dist/esm/i18n/locales/es-ES.json +16 -17
- package/dist/esm/i18n/locales/fr-FR.json +16 -13
- package/dist/esm/i18n/locales/it-IT.json +16 -13
- package/dist/esm/i18n/locales/ja-JP.json +16 -13
- package/dist/esm/i18n/locales/ko-KR.json +16 -13
- package/dist/esm/i18n/locales/locales/ar-SA.json +16 -13
- package/dist/esm/i18n/locales/locales/ca-ES.json +16 -13
- package/dist/esm/i18n/locales/locales/de-DE.json +16 -13
- package/dist/esm/i18n/locales/locales/en-US.json +16 -17
- package/dist/esm/i18n/locales/locales/es-ES.json +16 -17
- package/dist/esm/i18n/locales/locales/fr-FR.json +16 -13
- package/dist/esm/i18n/locales/locales/it-IT.json +16 -13
- package/dist/esm/i18n/locales/locales/ja-JP.json +16 -13
- package/dist/esm/i18n/locales/locales/ko-KR.json +16 -13
- package/dist/esm/i18n/locales/locales/pt-PT.json +16 -13
- package/dist/esm/i18n/locales/locales/zh-CN.json +16 -13
- package/dist/esm/i18n/locales/pt-PT.json +16 -13
- package/dist/esm/i18n/locales/zh-CN.json +16 -13
- package/dist/esm/index.js +5 -1
- package/dist/esm/mixins/OxyServices.auth.js +14 -4
- package/dist/esm/mixins/OxyServices.identity.js +14 -0
- package/dist/esm/session/accountDialogController.js +334 -143
- package/dist/esm/utils/authWebUrl.js +7 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/crypto/identityMove.d.ts +59 -0
- package/dist/types/crypto/keyManager.d.ts +35 -5
- package/dist/types/crypto/webIdentityCarrier.d.ts +123 -0
- package/dist/types/index.d.ts +5 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +12 -0
- package/dist/types/models/interfaces.d.ts +15 -0
- package/dist/types/session/accountDialogController.d.ts +169 -17
- package/dist/types/utils/authWebUrl.d.ts +7 -0
- package/package.json +2 -2
- package/src/crypto/__tests__/identityMove.test.ts +104 -0
- package/src/crypto/__tests__/keyManager.sharedSlotSync.test.ts +150 -0
- package/src/crypto/__tests__/webIdentityCarrier.test.ts +211 -0
- package/src/crypto/identityMove.ts +133 -0
- package/src/crypto/keyManager.ts +71 -21
- package/src/crypto/webIdentityCarrier.ts +326 -0
- package/src/i18n/locales/ar-SA.json +16 -13
- package/src/i18n/locales/ca-ES.json +16 -13
- package/src/i18n/locales/de-DE.json +16 -13
- package/src/i18n/locales/en-US.json +16 -17
- package/src/i18n/locales/es-ES.json +16 -17
- package/src/i18n/locales/fr-FR.json +16 -13
- package/src/i18n/locales/it-IT.json +16 -13
- package/src/i18n/locales/ja-JP.json +16 -13
- package/src/i18n/locales/ko-KR.json +16 -13
- package/src/i18n/locales/pt-PT.json +16 -13
- package/src/i18n/locales/zh-CN.json +16 -13
- package/src/index.ts +38 -1
- package/src/mixins/OxyServices.auth.ts +16 -3
- package/src/mixins/OxyServices.identity.ts +14 -0
- package/src/mixins/__tests__/OxyServices.rotateKey.test.ts +47 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +3 -0
- package/src/mixins/__tests__/verifyChallenge.test.ts +31 -0
- package/src/models/interfaces.ts +15 -0
- package/src/session/__tests__/accountDialogController.test.ts +407 -10
- package/src/session/accountDialogController.ts +406 -145
- package/src/utils/__tests__/oauthPkce.test.ts +50 -26
- package/src/utils/authWebUrl.ts +8 -0
|
@@ -175,6 +175,53 @@ describe('OxyServices.rotateKey', () => {
|
|
|
175
175
|
expect(clearEntrySpy).toHaveBeenCalledWith('GET:/u/user-123/did.json');
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
+
// The shared slot is what every cross-app reader takes first, including
|
|
179
|
+
// `deriveScopedSeed` — the FairCoin wallet Peable derives. A rotation that
|
|
180
|
+
// wrote only the primary left that wallet on the replaced key, watching
|
|
181
|
+
// addresses the rotated identity will never be paid at, with no error
|
|
182
|
+
// anywhere. Shared is written FIRST so a crash between the two writes leaves
|
|
183
|
+
// the money path on the new key and the primary stale, which fails loudly.
|
|
184
|
+
it('writes the new key to the shared slot too, before the primary', async () => {
|
|
185
|
+
const order: string[] = [];
|
|
186
|
+
jest.spyOn(RecoveryPhraseService, 'derivePendingIdentity').mockResolvedValue(pendingFixture);
|
|
187
|
+
jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(OLD_PUBLIC);
|
|
188
|
+
jest.spyOn(SignatureService, 'sign').mockResolvedValue('sig-hex');
|
|
189
|
+
jest.spyOn(protocol, 'signMessage').mockResolvedValue('newkeyproof-hex');
|
|
190
|
+
jest.spyOn(KeyManager, 'hasSharedIdentity').mockResolvedValue(true);
|
|
191
|
+
const sharedSpy = jest
|
|
192
|
+
.spyOn(KeyManager, 'importSharedIdentity')
|
|
193
|
+
.mockImplementation(async () => { order.push('shared'); return NEW_PUBLIC; });
|
|
194
|
+
const importSpy = jest
|
|
195
|
+
.spyOn(KeyManager, 'importKeyPair')
|
|
196
|
+
.mockImplementation(async () => { order.push('primary'); return NEW_PUBLIC; });
|
|
197
|
+
makeRequestSpy
|
|
198
|
+
.mockResolvedValueOnce({ challenge: 'chal-1', expiresAt: '2999-01-01T00:00:00.000Z' })
|
|
199
|
+
.mockResolvedValueOnce({ success: true, publicKey: NEW_PUBLIC, message: 'ok' });
|
|
200
|
+
|
|
201
|
+
await oxy.rotateKey({ proof: 'device' });
|
|
202
|
+
|
|
203
|
+
expect(sharedSpy).toHaveBeenCalledWith(NEW_PRIVATE);
|
|
204
|
+
expect(importSpy).toHaveBeenCalledWith(NEW_PRIVATE, { overwrite: true });
|
|
205
|
+
expect(order).toEqual(['shared', 'primary']);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('leaves the shared slot alone on a device that has none', async () => {
|
|
209
|
+
jest.spyOn(RecoveryPhraseService, 'derivePendingIdentity').mockResolvedValue(pendingFixture);
|
|
210
|
+
jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(OLD_PUBLIC);
|
|
211
|
+
jest.spyOn(SignatureService, 'sign').mockResolvedValue('sig-hex');
|
|
212
|
+
jest.spyOn(protocol, 'signMessage').mockResolvedValue('newkeyproof-hex');
|
|
213
|
+
jest.spyOn(KeyManager, 'hasSharedIdentity').mockResolvedValue(false);
|
|
214
|
+
const sharedSpy = jest.spyOn(KeyManager, 'importSharedIdentity');
|
|
215
|
+
jest.spyOn(KeyManager, 'importKeyPair').mockResolvedValue(NEW_PUBLIC);
|
|
216
|
+
makeRequestSpy
|
|
217
|
+
.mockResolvedValueOnce({ challenge: 'chal-1', expiresAt: '2999-01-01T00:00:00.000Z' })
|
|
218
|
+
.mockResolvedValueOnce({ success: true, publicKey: NEW_PUBLIC, message: 'ok' });
|
|
219
|
+
|
|
220
|
+
await oxy.rotateKey({ proof: 'device' });
|
|
221
|
+
|
|
222
|
+
expect(sharedSpy).not.toHaveBeenCalled();
|
|
223
|
+
});
|
|
224
|
+
|
|
178
225
|
it('throws (no network) when the device holds no identity', async () => {
|
|
179
226
|
jest.spyOn(RecoveryPhraseService, 'derivePendingIdentity').mockResolvedValue(pendingFixture);
|
|
180
227
|
jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(null);
|
|
@@ -878,6 +878,8 @@ describe('OxyServices — "Sign in with Oxy" handoff', () => {
|
|
|
878
878
|
'iPad',
|
|
879
879
|
'fp-1',
|
|
880
880
|
undefined,
|
|
881
|
+
// Planting stays the default: only a caller that asks otherwise opts out.
|
|
882
|
+
{ plantTokens: undefined },
|
|
881
883
|
);
|
|
882
884
|
expect(result).toEqual(sessionFixture);
|
|
883
885
|
});
|
|
@@ -913,6 +915,7 @@ describe('OxyServices — "Sign in with Oxy" handoff', () => {
|
|
|
913
915
|
undefined,
|
|
914
916
|
undefined,
|
|
915
917
|
{ retry: false },
|
|
918
|
+
{ plantTokens: undefined },
|
|
916
919
|
);
|
|
917
920
|
expect(result).toEqual(sessionFixture);
|
|
918
921
|
});
|
|
@@ -127,4 +127,35 @@ describe('OxyServices.verifyChallenge token planting', () => {
|
|
|
127
127
|
expect(oxy.hasValidToken()).toBe(true);
|
|
128
128
|
expect(oxy.getAccessToken()).toBe('access_claim');
|
|
129
129
|
});
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* `plantTokens: false` is for a caller that must decide AFTER the response
|
|
133
|
+
* whether the session is still wanted — the account dialog, whose user may
|
|
134
|
+
* have cancelled while the request was in flight. It still gets the bearer in
|
|
135
|
+
* the response; the client just is not switched to it.
|
|
136
|
+
*/
|
|
137
|
+
it('leaves the client untouched with plantTokens: false, on both paths', async () => {
|
|
138
|
+
const oxy = makeOxy();
|
|
139
|
+
|
|
140
|
+
jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method, url) => {
|
|
141
|
+
const body = {
|
|
142
|
+
sessionId: 'sess_x',
|
|
143
|
+
deviceId: 'dev_x',
|
|
144
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
145
|
+
user: { id: 'user_x', username: 'x' },
|
|
146
|
+
};
|
|
147
|
+
if (url === '/auth/verify') return { ...body, accessToken: 'access_verify' } as never;
|
|
148
|
+
if (url === '/auth/session/claim') return { ...body, accessToken: 'access_claim' } as never;
|
|
149
|
+
throw new Error(`unexpected request to ${url}`);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const verified = await oxy.verifyChallenge('pubkey', 'challenge', 'sig', 1, undefined, undefined, undefined, {
|
|
153
|
+
plantTokens: false,
|
|
154
|
+
});
|
|
155
|
+
const claimed = await oxy.claimSessionByToken('session-token-abc', { plantTokens: false });
|
|
156
|
+
|
|
157
|
+
expect(verified.accessToken).toBe('access_verify');
|
|
158
|
+
expect(claimed.accessToken).toBe('access_claim');
|
|
159
|
+
expect(oxy.hasValidToken()).toBe(false);
|
|
160
|
+
});
|
|
130
161
|
});
|
package/src/models/interfaces.ts
CHANGED
|
@@ -117,7 +117,22 @@ export interface User {
|
|
|
117
117
|
description?: string;
|
|
118
118
|
phone?: string;
|
|
119
119
|
address?: string;
|
|
120
|
+
/** Legacy free-text birthday, kept for backward compatibility. Prefer `dateOfBirth`. */
|
|
120
121
|
birthday?: string;
|
|
122
|
+
/**
|
|
123
|
+
* The structured date of birth, `YYYY-MM-DD`. The new source of truth going
|
|
124
|
+
* forward — `birthday` above is unparsed free text with no guaranteed
|
|
125
|
+
* shape. Present only on the account's OWN profile (never on another
|
|
126
|
+
* account's public profile), same visibility as `phone`/`address`/`birthday`.
|
|
127
|
+
*/
|
|
128
|
+
dateOfBirth?: string | null;
|
|
129
|
+
/**
|
|
130
|
+
* Derived, non-PII signal: whether the account holder is at least 18.
|
|
131
|
+
* `undefined` when `dateOfBirth` is unset ("unknown"), distinct from
|
|
132
|
+
* `false` ("known, not yet 18"). Never stored — computed fresh by the API
|
|
133
|
+
* on every read of the owner's own profile.
|
|
134
|
+
*/
|
|
135
|
+
isAdult?: boolean;
|
|
121
136
|
website?: string;
|
|
122
137
|
createdAt?: string;
|
|
123
138
|
updatedAt?: string;
|
|
@@ -137,6 +137,9 @@ interface OxyMock {
|
|
|
137
137
|
denyCommonsSignIn: jest.Mock;
|
|
138
138
|
claimSessionByToken: jest.Mock;
|
|
139
139
|
signInWithSharedIdentity: jest.Mock;
|
|
140
|
+
/** Plant / clear the bearer — both fire `onTokensChanged`, like `OxyServices`. */
|
|
141
|
+
setTokens: jest.Mock;
|
|
142
|
+
clearTokens: jest.Mock;
|
|
140
143
|
/**
|
|
141
144
|
* Test helper: set the current access token and fire every registered
|
|
142
145
|
* `onTokensChanged` listener (mirrors `OxyServices.setTokens`/`clearTokens`).
|
|
@@ -149,6 +152,12 @@ function makeOxy(): OxyMock {
|
|
|
149
152
|
const tokenListeners = new Set<(token: string | null) => void>();
|
|
150
153
|
// Authenticated by default (mirrors a warm start with a planted bearer).
|
|
151
154
|
let currentToken: string | null = 'access-token';
|
|
155
|
+
const emitTokenChange = (token: string | null) => {
|
|
156
|
+
currentToken = token;
|
|
157
|
+
for (const listener of tokenListeners) {
|
|
158
|
+
listener(token);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
152
161
|
return {
|
|
153
162
|
getAccessToken: jest.fn(() => currentToken),
|
|
154
163
|
getBaseURL: jest.fn(() => 'http://test.invalid'),
|
|
@@ -168,12 +177,9 @@ function makeOxy(): OxyMock {
|
|
|
168
177
|
denyCommonsSignIn: jest.fn().mockResolvedValue({ success: true }),
|
|
169
178
|
claimSessionByToken: jest.fn(),
|
|
170
179
|
signInWithSharedIdentity: jest.fn().mockResolvedValue(null),
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
listener(token);
|
|
175
|
-
}
|
|
176
|
-
},
|
|
180
|
+
setTokens: jest.fn((token: string) => emitTokenChange(token)),
|
|
181
|
+
clearTokens: jest.fn(() => emitTokenChange(null)),
|
|
182
|
+
emitTokenChange,
|
|
177
183
|
};
|
|
178
184
|
}
|
|
179
185
|
|
|
@@ -494,6 +500,7 @@ describe('AccountDialogController — sign in with Oxy', () => {
|
|
|
494
500
|
const snap = controller.getSnapshot();
|
|
495
501
|
expect(snap.signIn.phase).toBe('error');
|
|
496
502
|
expect(snap.signIn.error).toMatch(/clientId/);
|
|
503
|
+
expect(snap.signIn.failure).toBe('not-configured');
|
|
497
504
|
});
|
|
498
505
|
|
|
499
506
|
it('polls, claims, and commits when the QR flow is authorized', async () => {
|
|
@@ -526,7 +533,7 @@ describe('AccountDialogController — sign in with Oxy', () => {
|
|
|
526
533
|
expect(oxy.pollCommonsSignIn).toHaveBeenCalledTimes(1);
|
|
527
534
|
|
|
528
535
|
await jest.advanceTimersByTimeAsync(1000); // second poll → authorized → claim
|
|
529
|
-
expect(oxy.claimSessionByToken).toHaveBeenCalledWith('secret-tok');
|
|
536
|
+
expect(oxy.claimSessionByToken).toHaveBeenCalledWith('secret-tok', { plantTokens: false });
|
|
530
537
|
expect(commitSession).toHaveBeenCalledWith(
|
|
531
538
|
expect.objectContaining({
|
|
532
539
|
sessionId: 'sess-1',
|
|
@@ -559,6 +566,7 @@ describe('AccountDialogController — sign in with Oxy', () => {
|
|
|
559
566
|
|
|
560
567
|
expect(controller.getSnapshot().signIn.phase).toBe('error');
|
|
561
568
|
expect(controller.getSnapshot().signIn.error).toMatch(/denied/i);
|
|
569
|
+
expect(controller.getSnapshot().signIn.failure).toBe('denied');
|
|
562
570
|
|
|
563
571
|
// No further polls after the terminal error.
|
|
564
572
|
await jest.advanceTimersByTimeAsync(5000);
|
|
@@ -618,7 +626,7 @@ describe('AccountDialogController — startPasskeyHubSignIn (b2 passkey hub popu
|
|
|
618
626
|
it('opens the popup synchronously, then navigates it to the hub URL with the authorizeCode once the session exists', async () => {
|
|
619
627
|
const popup = fakePopup();
|
|
620
628
|
const openPopup = jest.fn(() => popup);
|
|
621
|
-
const { controller, oxy } = makeHarness({ openPopup, hubBaseUrl: 'https://
|
|
629
|
+
const { controller, oxy } = makeHarness({ openPopup, hubBaseUrl: 'https://id.oxy.so' });
|
|
622
630
|
oxy.startCommonsSignIn.mockResolvedValue({
|
|
623
631
|
sessionToken: 'secret-tok',
|
|
624
632
|
authorizeCode: 'AUTH-CODE',
|
|
@@ -630,7 +638,7 @@ describe('AccountDialogController — startPasskeyHubSignIn (b2 passkey hub popu
|
|
|
630
638
|
await controller.startPasskeyHubSignIn();
|
|
631
639
|
|
|
632
640
|
expect(openPopup).toHaveBeenCalledTimes(1);
|
|
633
|
-
expect(popup.location.href).toBe('https://
|
|
641
|
+
expect(popup.location.href).toBe('https://id.oxy.so/continue?code=AUTH-CODE');
|
|
634
642
|
// Same underlying device-flow session showQr would create — the QR view
|
|
635
643
|
// still renders as a fallback/alternative alongside the popup.
|
|
636
644
|
const snap = controller.getSnapshot();
|
|
@@ -698,6 +706,8 @@ describe('AccountDialogController — startPasskeyHubSignIn (b2 passkey hub popu
|
|
|
698
706
|
const snap = controller.getSnapshot();
|
|
699
707
|
expect(snap.signIn.phase).toBe('error');
|
|
700
708
|
expect(snap.signIn.error).toMatch(/cancelled/i);
|
|
709
|
+
// A VOLUNTARY exit — the surface must be able to tell it from a failure.
|
|
710
|
+
expect(snap.signIn.failure).toBe('cancelled');
|
|
701
711
|
} finally {
|
|
702
712
|
jest.useRealTimers();
|
|
703
713
|
}
|
|
@@ -891,7 +901,7 @@ describe('AccountDialogController — /auth-session socket (instant QR wake)', (
|
|
|
891
901
|
await flush();
|
|
892
902
|
|
|
893
903
|
expect(oxy.pollCommonsSignIn).toHaveBeenCalledWith('secret-tok');
|
|
894
|
-
expect(oxy.claimSessionByToken).toHaveBeenCalledWith('secret-tok');
|
|
904
|
+
expect(oxy.claimSessionByToken).toHaveBeenCalledWith('secret-tok', { plantTokens: false });
|
|
895
905
|
expect(commitSession).toHaveBeenCalled();
|
|
896
906
|
expect(controller.getSnapshot().view).toBe('accounts');
|
|
897
907
|
expect(sock.disconnected).toBe(true); // torn down on completion
|
|
@@ -1307,11 +1317,13 @@ describe('AccountDialogController — automatic delivery selection (#691 phase 5
|
|
|
1307
1317
|
'qrPayload',
|
|
1308
1318
|
'expiresAt',
|
|
1309
1319
|
'error',
|
|
1320
|
+
'failure',
|
|
1310
1321
|
'route',
|
|
1311
1322
|
'routeFailed',
|
|
1312
1323
|
'pushSentAt',
|
|
1313
1324
|
'openedAt',
|
|
1314
1325
|
'progress',
|
|
1326
|
+
'attempt',
|
|
1315
1327
|
]);
|
|
1316
1328
|
// Only the PUBLIC handles are exposed.
|
|
1317
1329
|
expect(snap.signIn.authorizeCode).toBe('AUTH-CODE');
|
|
@@ -1560,11 +1572,13 @@ describe('AccountDialogController — cancellation converges (#691 phase 5)', ()
|
|
|
1560
1572
|
qrPayload: null,
|
|
1561
1573
|
expiresAt: null,
|
|
1562
1574
|
error: null,
|
|
1575
|
+
failure: null,
|
|
1563
1576
|
route: null,
|
|
1564
1577
|
routeFailed: false,
|
|
1565
1578
|
pushSentAt: null,
|
|
1566
1579
|
openedAt: null,
|
|
1567
1580
|
progress: 'idle',
|
|
1581
|
+
attempt: expect.any(Number),
|
|
1568
1582
|
});
|
|
1569
1583
|
await jest.advanceTimersByTimeAsync(10_000);
|
|
1570
1584
|
expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
|
|
@@ -1959,6 +1973,389 @@ describe('AccountDialogController — the directory (ADR 0002)', () => {
|
|
|
1959
1973
|
});
|
|
1960
1974
|
});
|
|
1961
1975
|
|
|
1976
|
+
/** A promise whose settlement the test controls — a slow network, on demand. */
|
|
1977
|
+
function deferred<T>(): {
|
|
1978
|
+
promise: Promise<T>;
|
|
1979
|
+
resolve: (value: T) => void;
|
|
1980
|
+
reject: (error: unknown) => void;
|
|
1981
|
+
} {
|
|
1982
|
+
let resolve: (value: T) => void = () => undefined;
|
|
1983
|
+
let reject: (error: unknown) => void = () => undefined;
|
|
1984
|
+
const promise = new Promise<T>((res, rej) => {
|
|
1985
|
+
resolve = res;
|
|
1986
|
+
reject = rej;
|
|
1987
|
+
});
|
|
1988
|
+
return { promise, resolve, reject };
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
const CLAIMED_SESSION = {
|
|
1992
|
+
accessToken: 'access-claimed',
|
|
1993
|
+
sessionId: 'sess-claimed',
|
|
1994
|
+
deviceId: 'device-1',
|
|
1995
|
+
expiresAt: '2030-01-01T00:00:00Z',
|
|
1996
|
+
user: user('b1'),
|
|
1997
|
+
};
|
|
1998
|
+
|
|
1999
|
+
describe('AccountDialogController — an abandoned attempt stays abandoned', () => {
|
|
2000
|
+
it('withdraws a request that is created only after the user closed the dialog, and wires nothing up', async () => {
|
|
2001
|
+
jest.useFakeTimers();
|
|
2002
|
+
try {
|
|
2003
|
+
const { controller, oxy } = makeHarness();
|
|
2004
|
+
const created = deferred<typeof DELIVERY_HANDLE & { expiresAt: number }>();
|
|
2005
|
+
oxy.startCommonsSignIn.mockReturnValue(created.promise);
|
|
2006
|
+
|
|
2007
|
+
const running = controller.showQr();
|
|
2008
|
+
expect(controller.getSnapshot().signIn.phase).toBe('starting');
|
|
2009
|
+
// Closed on a slow connection: there is no authorize code to withdraw yet.
|
|
2010
|
+
controller.cancelSignIn();
|
|
2011
|
+
expect(oxy.denyCommonsSignIn).not.toHaveBeenCalled();
|
|
2012
|
+
|
|
2013
|
+
created.resolve({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
|
|
2014
|
+
await running;
|
|
2015
|
+
|
|
2016
|
+
// The late request is withdrawn the moment it exists, and nothing revives:
|
|
2017
|
+
// no waiting surface, no delivery, no poll.
|
|
2018
|
+
expect(oxy.denyCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
|
|
2019
|
+
expect(controller.getSnapshot().signIn.phase).toBe('idle');
|
|
2020
|
+
expect(controller.getSnapshot().signIn.authorizeCode).toBeNull();
|
|
2021
|
+
expect(oxy.deliverCommonsSignIn).not.toHaveBeenCalled();
|
|
2022
|
+
await jest.advanceTimersByTimeAsync(10_000);
|
|
2023
|
+
expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
|
|
2024
|
+
} finally {
|
|
2025
|
+
jest.useRealTimers();
|
|
2026
|
+
}
|
|
2027
|
+
});
|
|
2028
|
+
|
|
2029
|
+
it('lets a newer attempt win over a slower earlier one', async () => {
|
|
2030
|
+
const { controller, oxy } = makeHarness();
|
|
2031
|
+
const first = deferred<unknown>();
|
|
2032
|
+
oxy.startCommonsSignIn
|
|
2033
|
+
.mockReturnValueOnce(first.promise)
|
|
2034
|
+
.mockResolvedValueOnce({
|
|
2035
|
+
sessionToken: 'tok-2',
|
|
2036
|
+
authorizeCode: 'CODE-2',
|
|
2037
|
+
qrPayload: 'oxycommons://approve?code=CODE-2',
|
|
2038
|
+
expiresAt: Date.now() + 600_000,
|
|
2039
|
+
status: 'pending',
|
|
2040
|
+
});
|
|
2041
|
+
|
|
2042
|
+
const slow = controller.showQr();
|
|
2043
|
+
await controller.showQr();
|
|
2044
|
+
first.resolve({
|
|
2045
|
+
sessionToken: 'tok-1',
|
|
2046
|
+
authorizeCode: 'CODE-1',
|
|
2047
|
+
qrPayload: 'oxycommons://approve?code=CODE-1',
|
|
2048
|
+
expiresAt: Date.now() + 600_000,
|
|
2049
|
+
status: 'pending',
|
|
2050
|
+
});
|
|
2051
|
+
await slow;
|
|
2052
|
+
|
|
2053
|
+
expect(controller.getSnapshot().signIn.authorizeCode).toBe('CODE-2');
|
|
2054
|
+
expect(oxy.denyCommonsSignIn).toHaveBeenCalledWith('CODE-1');
|
|
2055
|
+
expect(oxy.denyCommonsSignIn).not.toHaveBeenCalledWith('CODE-2');
|
|
2056
|
+
controller.cancelSignIn();
|
|
2057
|
+
});
|
|
2058
|
+
|
|
2059
|
+
it('never installs a shared-identity session that was minted after the user cancelled', async () => {
|
|
2060
|
+
const { controller, oxy, commitSession, onSignedIn } = makeHarness();
|
|
2061
|
+
const minted = deferred<SessionLoginResponse>();
|
|
2062
|
+
oxy.signInWithSharedIdentity.mockReturnValue(minted.promise);
|
|
2063
|
+
|
|
2064
|
+
const running = controller.signInWithOxy();
|
|
2065
|
+
// Minted without planting: the controller decides whether to install it.
|
|
2066
|
+
expect(oxy.signInWithSharedIdentity).toHaveBeenCalledWith({ plantTokens: false });
|
|
2067
|
+
controller.cancelSignIn();
|
|
2068
|
+
minted.resolve({
|
|
2069
|
+
sessionId: 'sess-shared',
|
|
2070
|
+
deviceId: 'device-1',
|
|
2071
|
+
expiresAt: '2030-01-01T00:00:00Z',
|
|
2072
|
+
accessToken: 'access-shared',
|
|
2073
|
+
user: { id: 'a1', username: 'user_a1' },
|
|
2074
|
+
});
|
|
2075
|
+
await running;
|
|
2076
|
+
|
|
2077
|
+
expect(oxy.setTokens).not.toHaveBeenCalled();
|
|
2078
|
+
expect(commitSession).not.toHaveBeenCalled();
|
|
2079
|
+
expect(onSignedIn).not.toHaveBeenCalled();
|
|
2080
|
+
// …and the abandoned attempt does not fall through to a QR request either.
|
|
2081
|
+
expect(oxy.startCommonsSignIn).not.toHaveBeenCalled();
|
|
2082
|
+
expect(controller.getSnapshot().signIn.phase).toBe('idle');
|
|
2083
|
+
});
|
|
2084
|
+
|
|
2085
|
+
it('never installs a session whose claim returned after the user cancelled', async () => {
|
|
2086
|
+
jest.useFakeTimers();
|
|
2087
|
+
try {
|
|
2088
|
+
const { controller, oxy, commitSession, onSignedIn } = makeHarness();
|
|
2089
|
+
oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
|
|
2090
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: true, sessionId: 'sess-claimed', status: 'authorized' });
|
|
2091
|
+
const claim = deferred<typeof CLAIMED_SESSION>();
|
|
2092
|
+
oxy.claimSessionByToken.mockReturnValue(claim.promise);
|
|
2093
|
+
|
|
2094
|
+
await controller.showQr();
|
|
2095
|
+
await jest.advanceTimersByTimeAsync(1000); // poll → authorized → claim in flight
|
|
2096
|
+
expect(controller.getSnapshot().signIn.phase).toBe('authorized');
|
|
2097
|
+
|
|
2098
|
+
controller.cancelSignIn();
|
|
2099
|
+
claim.resolve(CLAIMED_SESSION);
|
|
2100
|
+
await jest.advanceTimersByTimeAsync(0);
|
|
2101
|
+
|
|
2102
|
+
expect(oxy.setTokens).not.toHaveBeenCalled();
|
|
2103
|
+
expect(commitSession).not.toHaveBeenCalled();
|
|
2104
|
+
expect(onSignedIn).not.toHaveBeenCalled();
|
|
2105
|
+
expect(controller.getSnapshot().signIn.phase).toBe('idle');
|
|
2106
|
+
} finally {
|
|
2107
|
+
jest.useRealTimers();
|
|
2108
|
+
}
|
|
2109
|
+
});
|
|
2110
|
+
|
|
2111
|
+
it('plants the claimed bearer at the install point, immediately before the commit', async () => {
|
|
2112
|
+
jest.useFakeTimers();
|
|
2113
|
+
try {
|
|
2114
|
+
const { controller, oxy, commitSession } = makeHarness();
|
|
2115
|
+
oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
|
|
2116
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: true, sessionId: 'sess-claimed', status: 'authorized' });
|
|
2117
|
+
oxy.claimSessionByToken.mockResolvedValue(CLAIMED_SESSION);
|
|
2118
|
+
|
|
2119
|
+
await controller.showQr();
|
|
2120
|
+
await jest.advanceTimersByTimeAsync(1000);
|
|
2121
|
+
|
|
2122
|
+
expect(oxy.setTokens).toHaveBeenCalledWith('access-claimed');
|
|
2123
|
+
expect(oxy.setTokens.mock.invocationCallOrder[0]).toBeLessThan(
|
|
2124
|
+
commitSession.mock.invocationCallOrder[0],
|
|
2125
|
+
);
|
|
2126
|
+
expect(controller.getSnapshot().signIn.phase).toBe('completed');
|
|
2127
|
+
} finally {
|
|
2128
|
+
jest.useRealTimers();
|
|
2129
|
+
}
|
|
2130
|
+
});
|
|
2131
|
+
|
|
2132
|
+
it('restores the previous bearer when the commit fails, and fails the attempt', async () => {
|
|
2133
|
+
jest.useFakeTimers();
|
|
2134
|
+
try {
|
|
2135
|
+
const { controller, oxy, commitSession, onSignedIn } = makeHarness();
|
|
2136
|
+
commitSession.mockRejectedValue(new Error('persist failed'));
|
|
2137
|
+
oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
|
|
2138
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: true, sessionId: 'sess-claimed', status: 'authorized' });
|
|
2139
|
+
oxy.claimSessionByToken.mockResolvedValue(CLAIMED_SESSION);
|
|
2140
|
+
|
|
2141
|
+
await controller.showQr();
|
|
2142
|
+
await jest.advanceTimersByTimeAsync(1000);
|
|
2143
|
+
|
|
2144
|
+
// Requests must not keep going out as an account the device never took.
|
|
2145
|
+
expect(oxy.getAccessToken()).toBe('access-token');
|
|
2146
|
+
expect(onSignedIn).not.toHaveBeenCalled();
|
|
2147
|
+
expect(controller.getSnapshot().signIn.phase).toBe('error');
|
|
2148
|
+
expect(controller.getSnapshot().signIn.failure).toBe('unknown');
|
|
2149
|
+
} finally {
|
|
2150
|
+
jest.useRealTimers();
|
|
2151
|
+
}
|
|
2152
|
+
});
|
|
2153
|
+
|
|
2154
|
+
it('falls through to the QR handoff when a shared-identity session cannot be committed', async () => {
|
|
2155
|
+
const { controller, oxy, commitSession } = makeHarness();
|
|
2156
|
+
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
|
2157
|
+
commitSession.mockRejectedValue(new Error('persist failed'));
|
|
2158
|
+
oxy.signInWithSharedIdentity.mockResolvedValue({
|
|
2159
|
+
sessionId: 'sess-shared',
|
|
2160
|
+
deviceId: 'device-1',
|
|
2161
|
+
expiresAt: '2030-01-01T00:00:00Z',
|
|
2162
|
+
accessToken: 'access-shared',
|
|
2163
|
+
user: { id: 'a1', username: 'user_a1' },
|
|
2164
|
+
});
|
|
2165
|
+
oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
|
|
2166
|
+
|
|
2167
|
+
await controller.signInWithOxy();
|
|
2168
|
+
|
|
2169
|
+
expect(oxy.getAccessToken()).toBe('access-token');
|
|
2170
|
+
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
2171
|
+
controller.cancelSignIn();
|
|
2172
|
+
warnSpy.mockRestore();
|
|
2173
|
+
});
|
|
2174
|
+
|
|
2175
|
+
it('classifies an unreachable server as a network failure', async () => {
|
|
2176
|
+
const { controller, oxy } = makeHarness();
|
|
2177
|
+
oxy.startCommonsSignIn.mockRejectedValue(
|
|
2178
|
+
Object.assign(new Error('Network error - failed to connect to server'), { code: 'NETWORK_ERROR', status: 0 }),
|
|
2179
|
+
);
|
|
2180
|
+
|
|
2181
|
+
await controller.showQr();
|
|
2182
|
+
|
|
2183
|
+
expect(controller.getSnapshot().signIn.phase).toBe('error');
|
|
2184
|
+
expect(controller.getSnapshot().signIn.failure).toBe('network');
|
|
2185
|
+
});
|
|
2186
|
+
|
|
2187
|
+
it('moves the attempt identity on every new attempt, and keeps it on the failure it belongs to', async () => {
|
|
2188
|
+
const { controller, oxy } = makeHarness({ clientId: null });
|
|
2189
|
+
oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
|
|
2190
|
+
|
|
2191
|
+
await controller.showQr();
|
|
2192
|
+
const firstFailure = controller.getSnapshot().signIn.attempt;
|
|
2193
|
+
await controller.showQr();
|
|
2194
|
+
|
|
2195
|
+
// Same message, different attempt — a surface can tell a repeat of the
|
|
2196
|
+
// failure it already reported from a new one.
|
|
2197
|
+
expect(controller.getSnapshot().signIn.failure).toBe('not-configured');
|
|
2198
|
+
expect(controller.getSnapshot().signIn.attempt).not.toBe(firstFailure);
|
|
2199
|
+
});
|
|
2200
|
+
});
|
|
2201
|
+
|
|
2202
|
+
describe('AccountDialogController — "Try again" repeats the user\'s choice', () => {
|
|
2203
|
+
it('reopens the passkey hub after a failed hub attempt', async () => {
|
|
2204
|
+
// A real window stays closed once closed, so every open is a fresh one.
|
|
2205
|
+
let popup = fakePopup();
|
|
2206
|
+
const openPopup = jest.fn(() => {
|
|
2207
|
+
popup = fakePopup();
|
|
2208
|
+
return popup;
|
|
2209
|
+
});
|
|
2210
|
+
const { controller, oxy } = makeHarness({ openPopup });
|
|
2211
|
+
oxy.startCommonsSignIn.mockRejectedValue(new Error('boom'));
|
|
2212
|
+
|
|
2213
|
+
await controller.startPasskeyHubSignIn();
|
|
2214
|
+
expect(controller.getSnapshot().signIn.phase).toBe('error');
|
|
2215
|
+
|
|
2216
|
+
oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
|
|
2217
|
+
const retry = controller.retrySignIn();
|
|
2218
|
+
// Opened synchronously, inside the press — before any await.
|
|
2219
|
+
expect(openPopup).toHaveBeenCalledTimes(2);
|
|
2220
|
+
await retry;
|
|
2221
|
+
|
|
2222
|
+
expect(oxy.deliverCommonsSignIn).not.toHaveBeenCalled();
|
|
2223
|
+
expect(popup.location.href).toContain('/continue?code=AUTH-CODE');
|
|
2224
|
+
controller.cancelSignIn();
|
|
2225
|
+
});
|
|
2226
|
+
|
|
2227
|
+
it('retries "Sign in with Oxy" (never the hub) after a failed Oxy attempt', async () => {
|
|
2228
|
+
const openPopup = jest.fn(() => fakePopup());
|
|
2229
|
+
const { controller, oxy } = makeHarness({ openPopup });
|
|
2230
|
+
oxy.startCommonsSignIn.mockRejectedValueOnce(new Error('boom'));
|
|
2231
|
+
|
|
2232
|
+
await controller.signInWithOxy();
|
|
2233
|
+
expect(controller.getSnapshot().signIn.phase).toBe('error');
|
|
2234
|
+
|
|
2235
|
+
oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
|
|
2236
|
+
await controller.retrySignIn();
|
|
2237
|
+
|
|
2238
|
+
expect(openPopup).not.toHaveBeenCalled();
|
|
2239
|
+
expect(oxy.signInWithSharedIdentity).toHaveBeenCalledTimes(2);
|
|
2240
|
+
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
2241
|
+
controller.cancelSignIn();
|
|
2242
|
+
});
|
|
2243
|
+
|
|
2244
|
+
it('closes a hub popup that was opened for an attempt the user cancelled while its request was created', async () => {
|
|
2245
|
+
const popup = fakePopup();
|
|
2246
|
+
const { controller, oxy } = makeHarness({ openPopup: () => popup });
|
|
2247
|
+
const created = deferred<unknown>();
|
|
2248
|
+
oxy.startCommonsSignIn.mockReturnValue(created.promise);
|
|
2249
|
+
|
|
2250
|
+
const running = controller.startPasskeyHubSignIn();
|
|
2251
|
+
controller.cancelSignIn();
|
|
2252
|
+
// Closed at once, not left blank until the slow request returns.
|
|
2253
|
+
expect(popup.close).toHaveBeenCalled();
|
|
2254
|
+
|
|
2255
|
+
created.resolve({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
|
|
2256
|
+
await running;
|
|
2257
|
+
expect(popup.location.href).toBe('');
|
|
2258
|
+
});
|
|
2259
|
+
});
|
|
2260
|
+
|
|
2261
|
+
describe('AccountDialogController — device mutations report their own outcome', () => {
|
|
2262
|
+
function directoryClient() {
|
|
2263
|
+
const sc = new TestSessionClient(host().host);
|
|
2264
|
+
const controller = createAccountDialogController({
|
|
2265
|
+
oxyServices: makeOxy() as unknown as OxyServices,
|
|
2266
|
+
sessionClient: sc,
|
|
2267
|
+
clientId: 'oxy_dk_test',
|
|
2268
|
+
commitSession: jest.fn().mockResolvedValue(undefined),
|
|
2269
|
+
});
|
|
2270
|
+
return { controller, sc };
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
it('reports a switch that happened as a switch, even when the re-read after it fails', async () => {
|
|
2274
|
+
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
|
2275
|
+
const { controller, sc } = directoryClient();
|
|
2276
|
+
jest.spyOn(sc, 'activateContext').mockResolvedValue(undefined);
|
|
2277
|
+
jest.spyOn(sc, 'refreshDirectory').mockRejectedValue(new Error('directory boom'));
|
|
2278
|
+
|
|
2279
|
+
// The subject DID change. Calling that a failed switch would invite the user
|
|
2280
|
+
// to repeat it — and skip the cache reset a switch requires.
|
|
2281
|
+
expect(await controller.activateContext('ctx-b')).toBe(true);
|
|
2282
|
+
// The re-read's failure is still reported, as the directory's.
|
|
2283
|
+
expect(controller.getSnapshot().error).toBe('directory boom');
|
|
2284
|
+
expect(controller.getSnapshot().activatingContextId).toBeNull();
|
|
2285
|
+
warnSpy.mockRestore();
|
|
2286
|
+
});
|
|
2287
|
+
|
|
2288
|
+
it('never lets an older refresh failure overwrite a newer successful read', async () => {
|
|
2289
|
+
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
|
2290
|
+
const { controller, sc } = directoryClient();
|
|
2291
|
+
const older = deferred<void>();
|
|
2292
|
+
jest
|
|
2293
|
+
.spyOn(sc, 'refreshDirectory')
|
|
2294
|
+
.mockReturnValueOnce(older.promise)
|
|
2295
|
+
.mockResolvedValueOnce(undefined);
|
|
2296
|
+
|
|
2297
|
+
const first = controller.refresh();
|
|
2298
|
+
expect(await controller.refresh()).toBe(true);
|
|
2299
|
+
older.reject(new Error('stale boom'));
|
|
2300
|
+
expect(await first).toBe(false);
|
|
2301
|
+
|
|
2302
|
+
expect(controller.getSnapshot().error).toBeNull();
|
|
2303
|
+
expect(controller.getSnapshot().loading).toBe(false);
|
|
2304
|
+
warnSpy.mockRestore();
|
|
2305
|
+
});
|
|
2306
|
+
|
|
2307
|
+
it('refuses a removal while a switch is in flight, and a switch while a removal is', async () => {
|
|
2308
|
+
const { controller, sc } = directoryClient();
|
|
2309
|
+
const switching = deferred<void>();
|
|
2310
|
+
jest.spyOn(sc, 'activateContext').mockReturnValue(switching.promise);
|
|
2311
|
+
const removeContext = jest.spyOn(sc, 'signOutContext').mockResolvedValue(undefined);
|
|
2312
|
+
const removePrincipal = jest.spyOn(sc, 'signOutPrincipal').mockResolvedValue(undefined);
|
|
2313
|
+
jest.spyOn(sc, 'refreshDirectory').mockResolvedValue(undefined);
|
|
2314
|
+
|
|
2315
|
+
const activation = controller.activateContext('ctx-b');
|
|
2316
|
+
expect(controller.isDeviceMutationInFlight()).toBe(true);
|
|
2317
|
+
// Refused, not queued: nothing destructive may fire later, after the switch.
|
|
2318
|
+
expect(await controller.signOutContext('ctx-a')).toBe(false);
|
|
2319
|
+
expect(await controller.signOutPrincipal('p-a')).toBe(false);
|
|
2320
|
+
expect(removeContext).not.toHaveBeenCalled();
|
|
2321
|
+
expect(removePrincipal).not.toHaveBeenCalled();
|
|
2322
|
+
|
|
2323
|
+
switching.resolve();
|
|
2324
|
+
expect(await activation).toBe(true);
|
|
2325
|
+
expect(controller.isDeviceMutationInFlight()).toBe(false);
|
|
2326
|
+
|
|
2327
|
+
const removing = deferred<void>();
|
|
2328
|
+
removeContext.mockReturnValue(removing.promise);
|
|
2329
|
+
const removal = controller.signOutContext('ctx-a');
|
|
2330
|
+
expect(await controller.activateContext('ctx-c')).toBe(false);
|
|
2331
|
+
removing.resolve();
|
|
2332
|
+
expect(await removal).toBe(true);
|
|
2333
|
+
await flush();
|
|
2334
|
+
expect(removeContext).toHaveBeenCalledTimes(1);
|
|
2335
|
+
});
|
|
2336
|
+
|
|
2337
|
+
it('runs a host operation under the same gate, and releases it when the operation rejects', async () => {
|
|
2338
|
+
const { controller, sc } = directoryClient();
|
|
2339
|
+
const signingOut = deferred<string>();
|
|
2340
|
+
const activate = jest.spyOn(sc, 'activateContext').mockResolvedValue(undefined);
|
|
2341
|
+
jest.spyOn(sc, 'refreshDirectory').mockResolvedValue(undefined);
|
|
2342
|
+
|
|
2343
|
+
const exclusive = controller.runDeviceMutation(() => signingOut.promise);
|
|
2344
|
+
expect(await controller.activateContext('ctx-b')).toBe(false);
|
|
2345
|
+
expect(await controller.runDeviceMutation(async () => 'second')).toEqual({ ran: false });
|
|
2346
|
+
expect(activate).not.toHaveBeenCalled();
|
|
2347
|
+
signingOut.resolve('done');
|
|
2348
|
+
expect(await exclusive).toEqual({ ran: true, value: 'done' });
|
|
2349
|
+
|
|
2350
|
+
await expect(
|
|
2351
|
+
controller.runDeviceMutation(async () => {
|
|
2352
|
+
throw new Error('boom');
|
|
2353
|
+
}),
|
|
2354
|
+
).rejects.toThrow('boom');
|
|
2355
|
+
expect(controller.isDeviceMutationInFlight()).toBe(false);
|
|
2356
|
+
});
|
|
2357
|
+
});
|
|
2358
|
+
|
|
1962
2359
|
it('createAccountDialogController returns an AccountDialogController instance', () => {
|
|
1963
2360
|
const { controller } = makeHarness();
|
|
1964
2361
|
expect(controller).toBeInstanceOf(AccountDialogController);
|