@oxyhq/core 12.10.6 → 12.11.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/boot/sessionColdBoot.js +142 -37
- package/dist/cjs/i18n/locales/ar-SA.json +13 -1
- package/dist/cjs/i18n/locales/ca-ES.json +13 -1
- package/dist/cjs/i18n/locales/de-DE.json +13 -1
- package/dist/cjs/i18n/locales/en-US.json +13 -4
- package/dist/cjs/i18n/locales/es-ES.json +13 -4
- package/dist/cjs/i18n/locales/fr-FR.json +13 -1
- package/dist/cjs/i18n/locales/it-IT.json +13 -1
- package/dist/cjs/i18n/locales/ja-JP.json +13 -1
- package/dist/cjs/i18n/locales/ko-KR.json +13 -1
- package/dist/cjs/i18n/locales/locales/ar-SA.json +13 -1
- package/dist/cjs/i18n/locales/locales/ca-ES.json +13 -1
- package/dist/cjs/i18n/locales/locales/de-DE.json +13 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +13 -4
- package/dist/cjs/i18n/locales/locales/es-ES.json +13 -4
- package/dist/cjs/i18n/locales/locales/fr-FR.json +13 -1
- package/dist/cjs/i18n/locales/locales/it-IT.json +13 -1
- package/dist/cjs/i18n/locales/locales/ja-JP.json +13 -1
- package/dist/cjs/i18n/locales/locales/ko-KR.json +13 -1
- package/dist/cjs/i18n/locales/locales/pt-PT.json +13 -1
- package/dist/cjs/i18n/locales/locales/zh-CN.json +13 -1
- package/dist/cjs/i18n/locales/pt-PT.json +13 -1
- package/dist/cjs/i18n/locales/zh-CN.json +13 -1
- package/dist/cjs/index.js +31 -5
- package/dist/cjs/mixins/OxyServices.auth.js +257 -17
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +54 -3
- package/dist/cjs/mixins/OxyServices.notifications.js +63 -0
- package/dist/cjs/mixins/index.js +5 -0
- package/dist/cjs/session/SessionClient.js +40 -4
- package/dist/cjs/session/accountDialogController.js +288 -41
- package/dist/cjs/session/identityPin.js +256 -0
- package/dist/cjs/session/identitySession.js +135 -0
- package/dist/cjs/session/projectSessionState.js +54 -15
- package/dist/cjs/session/refresh.js +73 -8
- package/dist/cjs/utils/commonsDelivery.js +59 -0
- package/dist/cjs/utils/oauthPkce.js +33 -3
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +142 -37
- package/dist/esm/i18n/locales/ar-SA.json +13 -1
- package/dist/esm/i18n/locales/ca-ES.json +13 -1
- package/dist/esm/i18n/locales/de-DE.json +13 -1
- package/dist/esm/i18n/locales/en-US.json +13 -4
- package/dist/esm/i18n/locales/es-ES.json +13 -4
- package/dist/esm/i18n/locales/fr-FR.json +13 -1
- package/dist/esm/i18n/locales/it-IT.json +13 -1
- package/dist/esm/i18n/locales/ja-JP.json +13 -1
- package/dist/esm/i18n/locales/ko-KR.json +13 -1
- package/dist/esm/i18n/locales/locales/ar-SA.json +13 -1
- package/dist/esm/i18n/locales/locales/ca-ES.json +13 -1
- package/dist/esm/i18n/locales/locales/de-DE.json +13 -1
- package/dist/esm/i18n/locales/locales/en-US.json +13 -4
- package/dist/esm/i18n/locales/locales/es-ES.json +13 -4
- package/dist/esm/i18n/locales/locales/fr-FR.json +13 -1
- package/dist/esm/i18n/locales/locales/it-IT.json +13 -1
- package/dist/esm/i18n/locales/locales/ja-JP.json +13 -1
- package/dist/esm/i18n/locales/locales/ko-KR.json +13 -1
- package/dist/esm/i18n/locales/locales/pt-PT.json +13 -1
- package/dist/esm/i18n/locales/locales/zh-CN.json +13 -1
- package/dist/esm/i18n/locales/pt-PT.json +13 -1
- package/dist/esm/i18n/locales/zh-CN.json +13 -1
- package/dist/esm/index.js +14 -1
- package/dist/esm/mixins/OxyServices.auth.js +257 -17
- package/dist/esm/mixins/OxyServices.deviceBoot.js +52 -3
- package/dist/esm/mixins/OxyServices.notifications.js +60 -0
- package/dist/esm/mixins/index.js +5 -0
- package/dist/esm/session/SessionClient.js +40 -4
- package/dist/esm/session/accountDialogController.js +288 -41
- package/dist/esm/session/identityPin.js +249 -0
- package/dist/esm/session/identitySession.js +131 -0
- package/dist/esm/session/projectSessionState.js +54 -15
- package/dist/esm/session/refresh.js +73 -8
- package/dist/esm/utils/commonsDelivery.js +54 -0
- package/dist/esm/utils/oauthPkce.js +31 -2
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/boot/sessionColdBoot.d.ts +38 -1
- package/dist/types/index.d.ts +12 -4
- package/dist/types/mixins/OxyServices.auth.d.ts +262 -11
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +31 -1
- package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/session/SessionClient.d.ts +28 -0
- package/dist/types/session/accountDialogController.d.ts +187 -9
- package/dist/types/session/identityPin.d.ts +87 -0
- package/dist/types/session/identitySession.d.ts +105 -0
- package/dist/types/session/projectSessionState.d.ts +21 -19
- package/dist/types/session/refresh.d.ts +32 -1
- package/dist/types/utils/commonsDelivery.d.ts +105 -0
- package/dist/types/utils/oauthPkce.d.ts +21 -1
- package/package.json +2 -2
- package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
- package/src/boot/sessionColdBoot.ts +183 -37
- package/src/i18n/locales/ar-SA.json +13 -1
- package/src/i18n/locales/ca-ES.json +13 -1
- package/src/i18n/locales/de-DE.json +13 -1
- package/src/i18n/locales/en-US.json +13 -4
- package/src/i18n/locales/es-ES.json +13 -4
- package/src/i18n/locales/fr-FR.json +13 -1
- package/src/i18n/locales/it-IT.json +13 -1
- package/src/i18n/locales/ja-JP.json +13 -1
- package/src/i18n/locales/ko-KR.json +13 -1
- package/src/i18n/locales/pt-PT.json +13 -1
- package/src/i18n/locales/zh-CN.json +13 -1
- package/src/index.ts +49 -0
- package/src/mixins/OxyServices.auth.ts +464 -24
- package/src/mixins/OxyServices.deviceBoot.ts +54 -2
- package/src/mixins/OxyServices.notifications.ts +142 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +754 -2
- package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +18 -0
- package/src/mixins/index.ts +6 -0
- package/src/session/SessionClient.ts +57 -4
- package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
- package/src/session/__tests__/accountDialogController.test.ts +812 -1
- package/src/session/__tests__/identityPin.test.ts +131 -0
- package/src/session/__tests__/identitySession.test.ts +194 -0
- package/src/session/__tests__/projectSessionState.test.ts +88 -0
- package/src/session/__tests__/refresh.identityPin.test.ts +306 -0
- package/src/session/accountDialogController.ts +435 -44
- package/src/session/identityPin.ts +310 -0
- package/src/session/identitySession.ts +206 -0
- package/src/session/projectSessionState.ts +61 -13
- package/src/session/refresh.ts +103 -9
- package/src/utils/__tests__/oauthPkce.test.ts +51 -0
- package/src/utils/commonsDelivery.ts +132 -0
- package/src/utils/oauthPkce.ts +56 -3
|
@@ -6,6 +6,7 @@ import type { AccountNode } from '../../mixins/OxyServices.accounts';
|
|
|
6
6
|
import { SessionClient, type SessionClientHost } from '../SessionClient';
|
|
7
7
|
import type { MinimalSocket, SocketIOFactory } from '../socketLoader';
|
|
8
8
|
import { logger } from '../../logger';
|
|
9
|
+
import { setPlatformOS } from '../../utils/platform';
|
|
9
10
|
import {
|
|
10
11
|
AccountDialogController,
|
|
11
12
|
createAccountDialogController,
|
|
@@ -76,7 +77,10 @@ interface OxyMock {
|
|
|
76
77
|
getFileDownloadUrl: jest.Mock;
|
|
77
78
|
switchToAccount: jest.Mock;
|
|
78
79
|
startCommonsSignIn: jest.Mock;
|
|
80
|
+
deliverCommonsSignIn: jest.Mock;
|
|
79
81
|
pollCommonsSignIn: jest.Mock;
|
|
82
|
+
deliverCommonsSignIn: jest.Mock;
|
|
83
|
+
denyCommonsSignIn: jest.Mock;
|
|
80
84
|
claimSessionByToken: jest.Mock;
|
|
81
85
|
signInWithSharedIdentity: jest.Mock;
|
|
82
86
|
/**
|
|
@@ -103,7 +107,12 @@ function makeOxy(): OxyMock {
|
|
|
103
107
|
getFileDownloadUrl: jest.fn((id: string) => `https://cdn/${id}`),
|
|
104
108
|
switchToAccount: jest.fn(),
|
|
105
109
|
startCommonsSignIn: jest.fn(),
|
|
110
|
+
deliverCommonsSignIn: jest.fn().mockResolvedValue({ delivered: false, targets: 0 }),
|
|
106
111
|
pollCommonsSignIn: jest.fn(),
|
|
112
|
+
// Default: no capable Commons installation is registered. A NORMAL outcome
|
|
113
|
+
// that resolves the primary route to QR — never an error.
|
|
114
|
+
deliverCommonsSignIn: jest.fn().mockResolvedValue({ delivered: false, targets: 0 }),
|
|
115
|
+
denyCommonsSignIn: jest.fn().mockResolvedValue({ success: true }),
|
|
107
116
|
claimSessionByToken: jest.fn(),
|
|
108
117
|
signInWithSharedIdentity: jest.fn().mockResolvedValue(null),
|
|
109
118
|
emitTokenChange: (token: string | null) => {
|
|
@@ -131,6 +140,9 @@ function makeHarness(
|
|
|
131
140
|
clientId: string | null;
|
|
132
141
|
openPopup: () => import('../accountDialogController').PopupWindowHandle | null;
|
|
133
142
|
hubBaseUrl: string;
|
|
143
|
+
platform: import('../../utils/commonsDelivery').CommonsDeliveryPlatform;
|
|
144
|
+
openUrl: (url: string) => void;
|
|
145
|
+
canOpenApp: (url: string) => Promise<boolean>;
|
|
134
146
|
}> = {},
|
|
135
147
|
): Harness {
|
|
136
148
|
const oxy = makeOxy();
|
|
@@ -146,6 +158,9 @@ function makeHarness(
|
|
|
146
158
|
pollIntervalMs: 1000,
|
|
147
159
|
openPopup: over.openPopup,
|
|
148
160
|
hubBaseUrl: over.hubBaseUrl,
|
|
161
|
+
platform: over.platform,
|
|
162
|
+
openUrl: over.openUrl,
|
|
163
|
+
canOpenApp: over.canOpenApp,
|
|
149
164
|
});
|
|
150
165
|
return { controller, oxy, sc, commitSession, onSignedIn };
|
|
151
166
|
}
|
|
@@ -160,6 +175,11 @@ describe('AccountDialogController — initial + views', () => {
|
|
|
160
175
|
expect(snap.loading).toBe(false);
|
|
161
176
|
expect(snap.switchingAccountId).toBeNull();
|
|
162
177
|
expect(snap.signIn.phase).toBe('idle');
|
|
178
|
+
expect(snap.signIn.route).toBeNull();
|
|
179
|
+
expect(snap.signIn.routeFailed).toBe(false);
|
|
180
|
+
expect(snap.signIn.pushSentAt).toBeNull();
|
|
181
|
+
expect(snap.signIn.openedAt).toBeNull();
|
|
182
|
+
expect(snap.signIn.progress).toBe('idle');
|
|
163
183
|
expect(snap.commonsAvailability).toBe('unknown');
|
|
164
184
|
});
|
|
165
185
|
|
|
@@ -518,8 +538,16 @@ describe('AccountDialogController — sign in with Oxy', () => {
|
|
|
518
538
|
expect(commitSession).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'sess-shared' }));
|
|
519
539
|
expect(onSignedIn).toHaveBeenCalledWith(expect.objectContaining({ id: 'a1' }));
|
|
520
540
|
expect(controller.getSnapshot().view).toBe('accounts');
|
|
521
|
-
|
|
541
|
+
// Terminal SUCCESS — the surface gets one frame to show "Identity confirmed".
|
|
542
|
+
expect(controller.getSnapshot().signIn.phase).toBe('completed');
|
|
543
|
+
expect(controller.getSnapshot().signIn.progress).toBe('identity-confirmed');
|
|
522
544
|
expect(oxy.startCommonsSignIn).not.toHaveBeenCalled();
|
|
545
|
+
|
|
546
|
+
// …and a new intention clears it, so the next entry never opens on the
|
|
547
|
+
// previous sign-in's terminal state.
|
|
548
|
+
controller.add();
|
|
549
|
+
expect(controller.getSnapshot().signIn.phase).toBe('idle');
|
|
550
|
+
expect(controller.getSnapshot().signIn.progress).toBe('idle');
|
|
523
551
|
});
|
|
524
552
|
|
|
525
553
|
it('falls through to the QR handoff when no shared identity is present', async () => {
|
|
@@ -541,6 +569,44 @@ describe('AccountDialogController — sign in with Oxy', () => {
|
|
|
541
569
|
expect(snap.signIn.phase).toBe('waiting');
|
|
542
570
|
expect(snap.signIn.authorizeCode).toBe('AUTH-CODE');
|
|
543
571
|
expect(snap.signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
|
|
572
|
+
expect(oxy.deliverCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
|
|
573
|
+
expect(snap.signIn.route).toBe('qr');
|
|
574
|
+
controller.cancelSignIn();
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('selects await-push when delivery succeeds for a signed-in user', async () => {
|
|
578
|
+
const { controller, oxy } = makeHarness();
|
|
579
|
+
oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: true, targets: 1 });
|
|
580
|
+
oxy.startCommonsSignIn.mockResolvedValue({
|
|
581
|
+
sessionToken: 'secret-tok',
|
|
582
|
+
authorizeCode: 'AUTH-CODE',
|
|
583
|
+
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
584
|
+
expiresAt: Date.now() + 300_000,
|
|
585
|
+
status: 'pending',
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
await controller.showQr();
|
|
589
|
+
|
|
590
|
+
expect(oxy.deliverCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
|
|
591
|
+
expect(controller.getSnapshot().signIn.route).toBe('await-push');
|
|
592
|
+
controller.cancelSignIn();
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it('skips deliver when no bearer is planted', async () => {
|
|
596
|
+
const { controller, oxy } = makeHarness();
|
|
597
|
+
oxy.emitTokenChange(null);
|
|
598
|
+
oxy.startCommonsSignIn.mockResolvedValue({
|
|
599
|
+
sessionToken: 'secret-tok',
|
|
600
|
+
authorizeCode: 'AUTH-CODE',
|
|
601
|
+
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
602
|
+
expiresAt: Date.now() + 300_000,
|
|
603
|
+
status: 'pending',
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
await controller.showQr();
|
|
607
|
+
|
|
608
|
+
expect(oxy.deliverCommonsSignIn).not.toHaveBeenCalled();
|
|
609
|
+
expect(controller.getSnapshot().signIn.route).toBe('qr');
|
|
544
610
|
controller.cancelSignIn();
|
|
545
611
|
});
|
|
546
612
|
|
|
@@ -640,6 +706,10 @@ describe('AccountDialogController — sign in with Oxy', () => {
|
|
|
640
706
|
await controller.showQr();
|
|
641
707
|
controller.cancelSignIn();
|
|
642
708
|
expect(controller.getSnapshot().signIn.phase).toBe('idle');
|
|
709
|
+
expect(controller.getSnapshot().signIn.progress).toBe('idle');
|
|
710
|
+
// Cancellation converges server-side too: the abandoned request is
|
|
711
|
+
// withdrawn so a stale QR can never be approved later.
|
|
712
|
+
expect(oxy.denyCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
|
|
643
713
|
|
|
644
714
|
await jest.advanceTimersByTimeAsync(5000);
|
|
645
715
|
expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
|
|
@@ -796,11 +866,15 @@ describe('AccountDialogController — Commons availability (canOpenApp)', () =>
|
|
|
796
866
|
pollIntervalMs: 1000,
|
|
797
867
|
openUrl: opts.openUrl,
|
|
798
868
|
canOpenApp: opts.canOpenApp,
|
|
869
|
+
// The deep-link route is mobile-only (`selectCommonsDelivery`), and a
|
|
870
|
+
// `canOpenApp` probe is only ever injected by a native (mobile) host.
|
|
871
|
+
platform: 'mobile',
|
|
799
872
|
});
|
|
800
873
|
return { controller, oxy };
|
|
801
874
|
}
|
|
802
875
|
|
|
803
876
|
it('deep-links into Commons via openUrl when canOpenApp reports it installed, keeping the QR/polling fallback', async () => {
|
|
877
|
+
setPlatformOS('ios');
|
|
804
878
|
const openUrl = jest.fn();
|
|
805
879
|
const canOpenApp = jest.fn().mockResolvedValue(true);
|
|
806
880
|
const { controller } = makeController({ openUrl, canOpenApp });
|
|
@@ -814,9 +888,11 @@ describe('AccountDialogController — Commons availability (canOpenApp)', () =>
|
|
|
814
888
|
const snap = controller.getSnapshot();
|
|
815
889
|
expect(snap.view).toBe('qr');
|
|
816
890
|
expect(snap.signIn.phase).toBe('waiting');
|
|
891
|
+
expect(snap.signIn.route).toBe('open-commons');
|
|
817
892
|
expect(snap.signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
|
|
818
893
|
expect(snap.commonsAvailability).toBe('available');
|
|
819
894
|
controller.cancelSignIn();
|
|
895
|
+
setPlatformOS('web');
|
|
820
896
|
});
|
|
821
897
|
|
|
822
898
|
it('does NOT open Commons when canOpenApp reports it absent (renders QR only)', async () => {
|
|
@@ -975,6 +1051,741 @@ describe('AccountDialogController — /auth-session socket (instant QR wake)', (
|
|
|
975
1051
|
await flush();
|
|
976
1052
|
expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
|
|
977
1053
|
});
|
|
1054
|
+
|
|
1055
|
+
it('stops polling when an OAuth-bound session is authorized without a sessionId', async () => {
|
|
1056
|
+
const { controller, oxy, created } = makeSocketHarness();
|
|
1057
|
+
oxy.pollCommonsSignIn.mockResolvedValue({
|
|
1058
|
+
authorized: true,
|
|
1059
|
+
purpose: 'oauth_authorization',
|
|
1060
|
+
status: 'authorized',
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
await controller.showQr();
|
|
1064
|
+
const sock = created();
|
|
1065
|
+
if (!sock) throw new Error('socket not created');
|
|
1066
|
+
sock.server('auth_update', { status: 'authorized' });
|
|
1067
|
+
await flush();
|
|
1068
|
+
|
|
1069
|
+
expect(oxy.claimSessionByToken).not.toHaveBeenCalled();
|
|
1070
|
+
expect(controller.getSnapshot().signIn.phase).toBe('error');
|
|
1071
|
+
expect(controller.getSnapshot().signIn.error).toContain('OAuth sign-in');
|
|
1072
|
+
});
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
// ===========================================================================
|
|
1076
|
+
// Issue #691, Phase 5 — automatic delivery selection + honest progress
|
|
1077
|
+
// ===========================================================================
|
|
1078
|
+
|
|
1079
|
+
type DeliveryPlatform = import('../../utils/commonsDelivery').CommonsDeliveryPlatform;
|
|
1080
|
+
|
|
1081
|
+
const DELIVERY_HANDLE = {
|
|
1082
|
+
sessionToken: 'secret-tok',
|
|
1083
|
+
authorizeCode: 'AUTH-CODE',
|
|
1084
|
+
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
1085
|
+
status: 'pending' as const,
|
|
1086
|
+
};
|
|
1087
|
+
|
|
1088
|
+
function makeDeliveryHarness(opts: {
|
|
1089
|
+
platform?: DeliveryPlatform;
|
|
1090
|
+
canOpenApp?: jest.Mock;
|
|
1091
|
+
openUrl?: jest.Mock;
|
|
1092
|
+
/** Default `true` — a planted bearer, the only state that may push. */
|
|
1093
|
+
authenticated?: boolean;
|
|
1094
|
+
pollIntervalMs?: number;
|
|
1095
|
+
}): { controller: AccountDialogController; oxy: OxyMock } {
|
|
1096
|
+
const oxy = makeOxy();
|
|
1097
|
+
oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
|
|
1098
|
+
oxy.pollCommonsSignIn.mockResolvedValue({
|
|
1099
|
+
authorized: false,
|
|
1100
|
+
status: 'pending',
|
|
1101
|
+
pushSentAt: null,
|
|
1102
|
+
openedAt: null,
|
|
1103
|
+
});
|
|
1104
|
+
if (opts.authenticated === false) oxy.emitTokenChange(null);
|
|
1105
|
+
const controller = new AccountDialogController({
|
|
1106
|
+
oxyServices: oxy as unknown as OxyServices,
|
|
1107
|
+
sessionClient: new TestSessionClient(host()),
|
|
1108
|
+
clientId: 'oxy_dk_test',
|
|
1109
|
+
// Every real consumer wires the provider's commit funnel; without it the
|
|
1110
|
+
// controller falls back to `SessionClient.registerAndActivate`, which opens
|
|
1111
|
+
// a BroadcastChannel this harness has no reason to exercise.
|
|
1112
|
+
commitSession: jest.fn().mockResolvedValue(undefined),
|
|
1113
|
+
pollIntervalMs: opts.pollIntervalMs ?? 1000,
|
|
1114
|
+
platform: opts.platform,
|
|
1115
|
+
canOpenApp: opts.canOpenApp,
|
|
1116
|
+
openUrl: opts.openUrl,
|
|
1117
|
+
});
|
|
1118
|
+
return { controller, oxy };
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
describe('AccountDialogController — automatic delivery selection (#691 phase 5)', () => {
|
|
1122
|
+
it('chooses open-commons on mobile with a verified Commons link, and never pushes as well', async () => {
|
|
1123
|
+
const openUrl = jest.fn();
|
|
1124
|
+
const { controller, oxy } = makeDeliveryHarness({
|
|
1125
|
+
platform: 'mobile',
|
|
1126
|
+
canOpenApp: jest.fn().mockResolvedValue(true),
|
|
1127
|
+
openUrl,
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
await controller.showQr();
|
|
1131
|
+
await flush();
|
|
1132
|
+
|
|
1133
|
+
const snap = controller.getSnapshot();
|
|
1134
|
+
expect(snap.signIn.route).toBe('open-commons');
|
|
1135
|
+
expect(snap.signIn.routeFailed).toBe(false);
|
|
1136
|
+
expect(openUrl).toHaveBeenCalledWith('oxycommons://approve?v=1&code=AUTH-CODE');
|
|
1137
|
+
// The identity is reachable on THIS device — pushing would light up a second
|
|
1138
|
+
// surface for a request the user is about to confirm here.
|
|
1139
|
+
expect(oxy.deliverCommonsSignIn).not.toHaveBeenCalled();
|
|
1140
|
+
expect(snap.signIn.progress).toBe('awaiting-approval');
|
|
1141
|
+
controller.cancelSignIn();
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
it('chooses await-push when the server delivered to at least one known Commons installation', async () => {
|
|
1145
|
+
const openUrl = jest.fn();
|
|
1146
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop', openUrl });
|
|
1147
|
+
oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: true, targets: 2 });
|
|
1148
|
+
|
|
1149
|
+
await controller.showQr();
|
|
1150
|
+
await flush();
|
|
1151
|
+
|
|
1152
|
+
const snap = controller.getSnapshot();
|
|
1153
|
+
expect(oxy.deliverCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
|
|
1154
|
+
expect(snap.signIn.route).toBe('await-push');
|
|
1155
|
+
expect(snap.signIn.routeFailed).toBe(false);
|
|
1156
|
+
// "Check Commons on your phone" — the server CONFIRMED the dispatch.
|
|
1157
|
+
expect(snap.signIn.progress).toBe('delivered-to-commons');
|
|
1158
|
+
expect(openUrl).not.toHaveBeenCalled();
|
|
1159
|
+
controller.cancelSignIn();
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
it('falls through to QR when the delivery reached zero installations — no error surfaced', async () => {
|
|
1163
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1164
|
+
oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: false, targets: 0 });
|
|
1165
|
+
|
|
1166
|
+
await controller.showQr();
|
|
1167
|
+
await flush();
|
|
1168
|
+
|
|
1169
|
+
const snap = controller.getSnapshot();
|
|
1170
|
+
expect(snap.signIn.route).toBe('qr');
|
|
1171
|
+
expect(snap.signIn.routeFailed).toBe(false);
|
|
1172
|
+
expect(snap.signIn.phase).toBe('waiting');
|
|
1173
|
+
expect(snap.signIn.error).toBeNull();
|
|
1174
|
+
expect(snap.error).toBeNull();
|
|
1175
|
+
expect(snap.signIn.progress).toBe('awaiting-approval');
|
|
1176
|
+
controller.cancelSignIn();
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
it('falls through to QR when the delivery call FAILS — silently, as a normal outcome', async () => {
|
|
1180
|
+
const debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => undefined);
|
|
1181
|
+
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
|
1182
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1183
|
+
oxy.deliverCommonsSignIn.mockRejectedValue(new Error('delivery boom'));
|
|
1184
|
+
|
|
1185
|
+
await controller.showQr();
|
|
1186
|
+
await flush();
|
|
1187
|
+
|
|
1188
|
+
const snap = controller.getSnapshot();
|
|
1189
|
+
expect(snap.signIn.route).toBe('qr');
|
|
1190
|
+
expect(snap.signIn.phase).toBe('waiting');
|
|
1191
|
+
expect(snap.signIn.error).toBeNull();
|
|
1192
|
+
expect(snap.error).toBeNull();
|
|
1193
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
1194
|
+
expect(debugSpy).toHaveBeenCalledWith(
|
|
1195
|
+
'[AccountDialogController] Commons delivery unavailable (QR route)',
|
|
1196
|
+
{ component: 'AccountDialogController' },
|
|
1197
|
+
expect.any(Error),
|
|
1198
|
+
);
|
|
1199
|
+
controller.cancelSignIn();
|
|
1200
|
+
debugSpy.mockRestore();
|
|
1201
|
+
warnSpy.mockRestore();
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
it('treats a delivered:false response with a non-zero target count as zero targets (fail-safe to QR)', async () => {
|
|
1205
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1206
|
+
oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: false, targets: 3 });
|
|
1207
|
+
|
|
1208
|
+
await controller.showQr();
|
|
1209
|
+
await flush();
|
|
1210
|
+
|
|
1211
|
+
expect(controller.getSnapshot().signIn.route).toBe('qr');
|
|
1212
|
+
controller.cancelSignIn();
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
it('NEVER attempts delivery without a bearer — the unauthenticated surface cannot ring a phone', async () => {
|
|
1216
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop', authenticated: false });
|
|
1217
|
+
|
|
1218
|
+
await controller.showQr();
|
|
1219
|
+
await flush();
|
|
1220
|
+
|
|
1221
|
+
expect(oxy.deliverCommonsSignIn).not.toHaveBeenCalled();
|
|
1222
|
+
const snap = controller.getSnapshot();
|
|
1223
|
+
expect(snap.signIn.route).toBe('qr');
|
|
1224
|
+
expect(snap.signIn.error).toBeNull();
|
|
1225
|
+
controller.cancelSignIn();
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
it('pushes on mobile when no verified Commons link is available on this device', async () => {
|
|
1229
|
+
const openUrl = jest.fn();
|
|
1230
|
+
const { controller, oxy } = makeDeliveryHarness({
|
|
1231
|
+
platform: 'mobile',
|
|
1232
|
+
canOpenApp: jest.fn().mockResolvedValue(false),
|
|
1233
|
+
openUrl,
|
|
1234
|
+
});
|
|
1235
|
+
oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: true, targets: 1 });
|
|
1236
|
+
|
|
1237
|
+
await controller.showQr();
|
|
1238
|
+
await flush();
|
|
1239
|
+
|
|
1240
|
+
expect(oxy.deliverCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
|
|
1241
|
+
expect(controller.getSnapshot().signIn.route).toBe('await-push');
|
|
1242
|
+
expect(openUrl).not.toHaveBeenCalled();
|
|
1243
|
+
controller.cancelSignIn();
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
it('never deep-links from an unclassified surface, even with Commons installed', async () => {
|
|
1247
|
+
const openUrl = jest.fn();
|
|
1248
|
+
const { controller } = makeDeliveryHarness({
|
|
1249
|
+
// No `platform` supplied → 'unknown': the controller says so rather than
|
|
1250
|
+
// guessing, and an unresolved custom-scheme navigation is a dead end.
|
|
1251
|
+
canOpenApp: jest.fn().mockResolvedValue(true),
|
|
1252
|
+
openUrl,
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
await controller.showQr();
|
|
1256
|
+
await flush();
|
|
1257
|
+
|
|
1258
|
+
expect(openUrl).not.toHaveBeenCalled();
|
|
1259
|
+
expect(controller.getSnapshot().signIn.route).toBe('qr');
|
|
1260
|
+
expect(controller.getSnapshot().commonsAvailability).toBe('available');
|
|
1261
|
+
controller.cancelSignIn();
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
it('marks the primary route FAILED (never auto-cascades) when the Commons link cannot be opened', async () => {
|
|
1265
|
+
// Route chosen: open-commons. No URL opener was injected, so the one action
|
|
1266
|
+
// the route implies cannot happen — the UI needs that fact to reveal
|
|
1267
|
+
// "Having trouble?"; the controller must not silently switch to another route.
|
|
1268
|
+
const { controller } = makeDeliveryHarness({
|
|
1269
|
+
platform: 'mobile',
|
|
1270
|
+
canOpenApp: jest.fn().mockResolvedValue(true),
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
await controller.showQr();
|
|
1274
|
+
await flush();
|
|
1275
|
+
|
|
1276
|
+
const snap = controller.getSnapshot();
|
|
1277
|
+
expect(snap.signIn.route).toBe('open-commons');
|
|
1278
|
+
expect(snap.signIn.routeFailed).toBe(true);
|
|
1279
|
+
// Still a live request — the QR beneath it stays valid.
|
|
1280
|
+
expect(snap.signIn.phase).toBe('waiting');
|
|
1281
|
+
expect(snap.signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
|
|
1282
|
+
controller.cancelSignIn();
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
it('marks the primary route FAILED when opening the Commons link throws', async () => {
|
|
1286
|
+
const debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => undefined);
|
|
1287
|
+
const openUrl = jest.fn(() => {
|
|
1288
|
+
throw new Error('no handler for scheme');
|
|
1289
|
+
});
|
|
1290
|
+
const { controller } = makeDeliveryHarness({
|
|
1291
|
+
platform: 'mobile',
|
|
1292
|
+
canOpenApp: jest.fn().mockResolvedValue(true),
|
|
1293
|
+
openUrl,
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
await controller.showQr();
|
|
1297
|
+
await flush();
|
|
1298
|
+
|
|
1299
|
+
expect(controller.getSnapshot().signIn.route).toBe('open-commons');
|
|
1300
|
+
expect(controller.getSnapshot().signIn.routeFailed).toBe(true);
|
|
1301
|
+
expect(controller.getSnapshot().signIn.error).toBeNull();
|
|
1302
|
+
controller.cancelSignIn();
|
|
1303
|
+
debugSpy.mockRestore();
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
it('reports "preparing" until a route is actually resolved — it is never guessed', async () => {
|
|
1307
|
+
let releaseDelivery: () => void = () => undefined;
|
|
1308
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1309
|
+
oxy.deliverCommonsSignIn.mockImplementation(
|
|
1310
|
+
() =>
|
|
1311
|
+
new Promise((resolve) => {
|
|
1312
|
+
releaseDelivery = () => resolve({ delivered: true, targets: 1 });
|
|
1313
|
+
}),
|
|
1314
|
+
);
|
|
1315
|
+
|
|
1316
|
+
await controller.showQr();
|
|
1317
|
+
// The request exists and the QR is renderable, but no route has been chosen.
|
|
1318
|
+
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
1319
|
+
expect(controller.getSnapshot().signIn.route).toBeNull();
|
|
1320
|
+
expect(controller.getSnapshot().signIn.progress).toBe('preparing');
|
|
1321
|
+
|
|
1322
|
+
releaseDelivery();
|
|
1323
|
+
await flush();
|
|
1324
|
+
expect(controller.getSnapshot().signIn.route).toBe('await-push');
|
|
1325
|
+
expect(controller.getSnapshot().signIn.progress).toBe('delivered-to-commons');
|
|
1326
|
+
controller.cancelSignIn();
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
it('abandons route resolution when the flow was cancelled while it was in flight', async () => {
|
|
1330
|
+
const openUrl = jest.fn();
|
|
1331
|
+
let releaseProbe: (installed: boolean) => void = () => undefined;
|
|
1332
|
+
const { controller, oxy } = makeDeliveryHarness({
|
|
1333
|
+
platform: 'mobile',
|
|
1334
|
+
canOpenApp: jest.fn(
|
|
1335
|
+
() =>
|
|
1336
|
+
new Promise<boolean>((resolve) => {
|
|
1337
|
+
releaseProbe = resolve;
|
|
1338
|
+
}),
|
|
1339
|
+
),
|
|
1340
|
+
openUrl,
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
await controller.showQr();
|
|
1344
|
+
controller.cancelSignIn();
|
|
1345
|
+
releaseProbe(true);
|
|
1346
|
+
await flush();
|
|
1347
|
+
|
|
1348
|
+
expect(openUrl).not.toHaveBeenCalled();
|
|
1349
|
+
expect(oxy.deliverCommonsSignIn).not.toHaveBeenCalled();
|
|
1350
|
+
expect(controller.getSnapshot().signIn.route).toBeNull();
|
|
1351
|
+
});
|
|
1352
|
+
|
|
1353
|
+
it('abandons route resolution when the request was approved while it was in flight', async () => {
|
|
1354
|
+
jest.useFakeTimers();
|
|
1355
|
+
try {
|
|
1356
|
+
const openUrl = jest.fn();
|
|
1357
|
+
let releaseProbe: (installed: boolean) => void = () => undefined;
|
|
1358
|
+
const { controller, oxy } = makeDeliveryHarness({
|
|
1359
|
+
platform: 'mobile',
|
|
1360
|
+
canOpenApp: jest.fn(
|
|
1361
|
+
() =>
|
|
1362
|
+
new Promise<boolean>((resolve) => {
|
|
1363
|
+
releaseProbe = resolve;
|
|
1364
|
+
}),
|
|
1365
|
+
),
|
|
1366
|
+
openUrl,
|
|
1367
|
+
});
|
|
1368
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: true, sessionId: 'sess-1', status: 'authorized' });
|
|
1369
|
+
oxy.claimSessionByToken.mockResolvedValue({
|
|
1370
|
+
accessToken: 'access-1',
|
|
1371
|
+
sessionId: 'sess-1',
|
|
1372
|
+
deviceId: 'device-1',
|
|
1373
|
+
expiresAt: '2030-01-01T00:00:00Z',
|
|
1374
|
+
user: user('a1'),
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
await controller.showQr();
|
|
1378
|
+
await jest.advanceTimersByTimeAsync(1000); // approval lands first
|
|
1379
|
+
expect(controller.getSnapshot().signIn.phase).toBe('completed');
|
|
1380
|
+
|
|
1381
|
+
releaseProbe(true);
|
|
1382
|
+
await jest.advanceTimersByTimeAsync(0);
|
|
1383
|
+
|
|
1384
|
+
// Opening Commons after the session is already signed in would be a
|
|
1385
|
+
// pointless app switch — the resolution must abandon itself.
|
|
1386
|
+
expect(openUrl).not.toHaveBeenCalled();
|
|
1387
|
+
expect(controller.getSnapshot().signIn.route).toBeNull();
|
|
1388
|
+
} finally {
|
|
1389
|
+
jest.useRealTimers();
|
|
1390
|
+
}
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
it('does not run automatic delivery for the passkey hub flow (the popup is the primary surface)', async () => {
|
|
1394
|
+
const popup = fakePopup();
|
|
1395
|
+
const oxy = makeOxy();
|
|
1396
|
+
oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
|
|
1397
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
|
|
1398
|
+
oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: true, targets: 1 });
|
|
1399
|
+
const controller = new AccountDialogController({
|
|
1400
|
+
oxyServices: oxy as unknown as OxyServices,
|
|
1401
|
+
sessionClient: new TestSessionClient(host()),
|
|
1402
|
+
clientId: 'oxy_dk_test',
|
|
1403
|
+
pollIntervalMs: 1000,
|
|
1404
|
+
platform: 'desktop',
|
|
1405
|
+
openPopup: () => popup,
|
|
1406
|
+
hubBaseUrl: 'https://auth.oxy.so',
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
await controller.startPasskeyHubSignIn();
|
|
1410
|
+
await flush();
|
|
1411
|
+
|
|
1412
|
+
expect(oxy.deliverCommonsSignIn).not.toHaveBeenCalled();
|
|
1413
|
+
expect(controller.getSnapshot().signIn.route).toBe('qr');
|
|
1414
|
+
controller.cancelSignIn();
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
it('never leaks the secret device-flow token into the snapshot', async () => {
|
|
1418
|
+
const { controller } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1419
|
+
|
|
1420
|
+
await controller.showQr();
|
|
1421
|
+
await flush();
|
|
1422
|
+
|
|
1423
|
+
const snap = controller.getSnapshot();
|
|
1424
|
+
expect(JSON.stringify(snap)).not.toContain('secret-tok');
|
|
1425
|
+
expect(Object.keys(snap.signIn)).toEqual([
|
|
1426
|
+
'phase',
|
|
1427
|
+
'authorizeCode',
|
|
1428
|
+
'qrPayload',
|
|
1429
|
+
'expiresAt',
|
|
1430
|
+
'error',
|
|
1431
|
+
'route',
|
|
1432
|
+
'routeFailed',
|
|
1433
|
+
'pushSentAt',
|
|
1434
|
+
'openedAt',
|
|
1435
|
+
'progress',
|
|
1436
|
+
]);
|
|
1437
|
+
// Only the PUBLIC handles are exposed.
|
|
1438
|
+
expect(snap.signIn.authorizeCode).toBe('AUTH-CODE');
|
|
1439
|
+
controller.cancelSignIn();
|
|
1440
|
+
});
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
describe('AccountDialogController — sign-in progress (#691 phase 5)', () => {
|
|
1444
|
+
it('advances only on real signals, in order, to "Identity confirmed"', async () => {
|
|
1445
|
+
jest.useFakeTimers();
|
|
1446
|
+
try {
|
|
1447
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1448
|
+
oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: true, targets: 1 });
|
|
1449
|
+
oxy.pollCommonsSignIn
|
|
1450
|
+
// 1st tick: still nothing but the confirmed push.
|
|
1451
|
+
.mockResolvedValueOnce({
|
|
1452
|
+
authorized: false,
|
|
1453
|
+
status: 'pending',
|
|
1454
|
+
pushSentAt: '2026-07-27T10:00:00.000Z',
|
|
1455
|
+
openedAt: null,
|
|
1456
|
+
})
|
|
1457
|
+
// 2nd tick: the approver opened the request.
|
|
1458
|
+
.mockResolvedValueOnce({
|
|
1459
|
+
authorized: false,
|
|
1460
|
+
status: 'pending',
|
|
1461
|
+
pushSentAt: '2026-07-27T10:00:00.000Z',
|
|
1462
|
+
openedAt: '2026-07-27T10:00:12.000Z',
|
|
1463
|
+
})
|
|
1464
|
+
// 3rd tick: approved.
|
|
1465
|
+
.mockResolvedValue({
|
|
1466
|
+
authorized: true,
|
|
1467
|
+
sessionId: 'sess-1',
|
|
1468
|
+
status: 'authorized',
|
|
1469
|
+
pushSentAt: '2026-07-27T10:00:00.000Z',
|
|
1470
|
+
openedAt: '2026-07-27T10:00:12.000Z',
|
|
1471
|
+
});
|
|
1472
|
+
let releaseClaim: () => void = () => undefined;
|
|
1473
|
+
oxy.claimSessionByToken.mockImplementation(
|
|
1474
|
+
() =>
|
|
1475
|
+
new Promise((resolve) => {
|
|
1476
|
+
releaseClaim = () =>
|
|
1477
|
+
resolve({
|
|
1478
|
+
accessToken: 'access-1',
|
|
1479
|
+
sessionId: 'sess-1',
|
|
1480
|
+
deviceId: 'device-1',
|
|
1481
|
+
expiresAt: '2030-01-01T00:00:00Z',
|
|
1482
|
+
user: user('a1'),
|
|
1483
|
+
});
|
|
1484
|
+
}),
|
|
1485
|
+
);
|
|
1486
|
+
|
|
1487
|
+
await controller.showQr();
|
|
1488
|
+
await jest.advanceTimersByTimeAsync(0); // let the route resolve
|
|
1489
|
+
expect(controller.getSnapshot().signIn.progress).toBe('delivered-to-commons');
|
|
1490
|
+
|
|
1491
|
+
await jest.advanceTimersByTimeAsync(1000);
|
|
1492
|
+
expect(controller.getSnapshot().signIn.pushSentAt).toBe('2026-07-27T10:00:00.000Z');
|
|
1493
|
+
expect(controller.getSnapshot().signIn.progress).toBe('delivered-to-commons');
|
|
1494
|
+
|
|
1495
|
+
await jest.advanceTimersByTimeAsync(1000);
|
|
1496
|
+
expect(controller.getSnapshot().signIn.openedAt).toBe('2026-07-27T10:00:12.000Z');
|
|
1497
|
+
expect(controller.getSnapshot().signIn.progress).toBe('opened-in-commons');
|
|
1498
|
+
|
|
1499
|
+
await jest.advanceTimersByTimeAsync(1000);
|
|
1500
|
+
// Approved: the claim/commit is running — "Confirming identity".
|
|
1501
|
+
expect(controller.getSnapshot().signIn.phase).toBe('authorized');
|
|
1502
|
+
expect(controller.getSnapshot().signIn.progress).toBe('confirming-identity');
|
|
1503
|
+
|
|
1504
|
+
releaseClaim();
|
|
1505
|
+
await jest.advanceTimersByTimeAsync(0);
|
|
1506
|
+
expect(controller.getSnapshot().signIn.phase).toBe('completed');
|
|
1507
|
+
expect(controller.getSnapshot().signIn.progress).toBe('identity-confirmed');
|
|
1508
|
+
expect(controller.getSnapshot().view).toBe('accounts');
|
|
1509
|
+
} finally {
|
|
1510
|
+
jest.useRealTimers();
|
|
1511
|
+
}
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
it('does not advance without a signal — repeated empty polls keep it waiting', async () => {
|
|
1515
|
+
jest.useFakeTimers();
|
|
1516
|
+
try {
|
|
1517
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1518
|
+
oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: false, targets: 0 });
|
|
1519
|
+
oxy.pollCommonsSignIn.mockResolvedValue({
|
|
1520
|
+
authorized: false,
|
|
1521
|
+
status: 'pending',
|
|
1522
|
+
pushSentAt: null,
|
|
1523
|
+
openedAt: null,
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
await controller.showQr();
|
|
1527
|
+
await jest.advanceTimersByTimeAsync(0);
|
|
1528
|
+
expect(controller.getSnapshot().signIn.progress).toBe('awaiting-approval');
|
|
1529
|
+
|
|
1530
|
+
await jest.advanceTimersByTimeAsync(5000); // five fallback polls
|
|
1531
|
+
expect(oxy.pollCommonsSignIn).toHaveBeenCalledTimes(5);
|
|
1532
|
+
expect(controller.getSnapshot().signIn.progress).toBe('awaiting-approval');
|
|
1533
|
+
expect(controller.getSnapshot().signIn.pushSentAt).toBeNull();
|
|
1534
|
+
expect(controller.getSnapshot().signIn.openedAt).toBeNull();
|
|
1535
|
+
|
|
1536
|
+
controller.cancelSignIn();
|
|
1537
|
+
} finally {
|
|
1538
|
+
jest.useRealTimers();
|
|
1539
|
+
}
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1542
|
+
it('is monotone — a later, emptier status response never walks progress backwards', async () => {
|
|
1543
|
+
jest.useFakeTimers();
|
|
1544
|
+
try {
|
|
1545
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1546
|
+
oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: false, targets: 0 });
|
|
1547
|
+
oxy.pollCommonsSignIn
|
|
1548
|
+
.mockResolvedValueOnce({
|
|
1549
|
+
authorized: false,
|
|
1550
|
+
status: 'pending',
|
|
1551
|
+
pushSentAt: '2026-07-27T10:00:00.000Z',
|
|
1552
|
+
openedAt: '2026-07-27T10:00:12.000Z',
|
|
1553
|
+
})
|
|
1554
|
+
// An older API build (or a partial payload) omits the progress fields.
|
|
1555
|
+
.mockResolvedValue({ authorized: false, status: 'pending' });
|
|
1556
|
+
|
|
1557
|
+
await controller.showQr();
|
|
1558
|
+
await jest.advanceTimersByTimeAsync(1000);
|
|
1559
|
+
expect(controller.getSnapshot().signIn.progress).toBe('opened-in-commons');
|
|
1560
|
+
|
|
1561
|
+
await jest.advanceTimersByTimeAsync(2000);
|
|
1562
|
+
expect(controller.getSnapshot().signIn.openedAt).toBe('2026-07-27T10:00:12.000Z');
|
|
1563
|
+
expect(controller.getSnapshot().signIn.progress).toBe('opened-in-commons');
|
|
1564
|
+
|
|
1565
|
+
controller.cancelSignIn();
|
|
1566
|
+
} finally {
|
|
1567
|
+
jest.useRealTimers();
|
|
1568
|
+
}
|
|
1569
|
+
});
|
|
1570
|
+
|
|
1571
|
+
it('treats "opened in Commons" as progress only — it never claims a session', async () => {
|
|
1572
|
+
jest.useFakeTimers();
|
|
1573
|
+
try {
|
|
1574
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1575
|
+
oxy.pollCommonsSignIn.mockResolvedValue({
|
|
1576
|
+
authorized: false,
|
|
1577
|
+
status: 'pending',
|
|
1578
|
+
pushSentAt: '2026-07-27T10:00:00.000Z',
|
|
1579
|
+
openedAt: '2026-07-27T10:00:12.000Z',
|
|
1580
|
+
});
|
|
1581
|
+
|
|
1582
|
+
await controller.showQr();
|
|
1583
|
+
await jest.advanceTimersByTimeAsync(3000);
|
|
1584
|
+
|
|
1585
|
+
expect(controller.getSnapshot().signIn.progress).toBe('opened-in-commons');
|
|
1586
|
+
expect(oxy.claimSessionByToken).not.toHaveBeenCalled();
|
|
1587
|
+
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
1588
|
+
|
|
1589
|
+
controller.cancelSignIn();
|
|
1590
|
+
} finally {
|
|
1591
|
+
jest.useRealTimers();
|
|
1592
|
+
}
|
|
1593
|
+
});
|
|
1594
|
+
});
|
|
1595
|
+
|
|
1596
|
+
describe('AccountDialogController — cancellation converges (#691 phase 5)', () => {
|
|
1597
|
+
it('a denial in Commons converges on the waiting surface and stops the flow', async () => {
|
|
1598
|
+
jest.useFakeTimers();
|
|
1599
|
+
try {
|
|
1600
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1601
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'cancelled' });
|
|
1602
|
+
|
|
1603
|
+
await controller.showQr();
|
|
1604
|
+
await jest.advanceTimersByTimeAsync(1000);
|
|
1605
|
+
|
|
1606
|
+
const snap = controller.getSnapshot();
|
|
1607
|
+
expect(snap.signIn.phase).toBe('error');
|
|
1608
|
+
expect(snap.signIn.error).toMatch(/denied/i);
|
|
1609
|
+
expect(snap.signIn.progress).toBe('idle');
|
|
1610
|
+
|
|
1611
|
+
await jest.advanceTimersByTimeAsync(10_000);
|
|
1612
|
+
expect(oxy.pollCommonsSignIn).toHaveBeenCalledTimes(1);
|
|
1613
|
+
expect(oxy.claimSessionByToken).not.toHaveBeenCalled();
|
|
1614
|
+
} finally {
|
|
1615
|
+
jest.useRealTimers();
|
|
1616
|
+
}
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
it('a server-reported expiry converges and stops the flow', async () => {
|
|
1620
|
+
jest.useFakeTimers();
|
|
1621
|
+
try {
|
|
1622
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1623
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'expired' });
|
|
1624
|
+
|
|
1625
|
+
await controller.showQr();
|
|
1626
|
+
await jest.advanceTimersByTimeAsync(1000);
|
|
1627
|
+
|
|
1628
|
+
expect(controller.getSnapshot().signIn.phase).toBe('error');
|
|
1629
|
+
expect(controller.getSnapshot().signIn.error).toMatch(/expired/i);
|
|
1630
|
+
|
|
1631
|
+
await jest.advanceTimersByTimeAsync(10_000);
|
|
1632
|
+
expect(oxy.pollCommonsSignIn).toHaveBeenCalledTimes(1);
|
|
1633
|
+
} finally {
|
|
1634
|
+
jest.useRealTimers();
|
|
1635
|
+
}
|
|
1636
|
+
});
|
|
1637
|
+
|
|
1638
|
+
it('a locally-observed expiry converges without even asking the server', async () => {
|
|
1639
|
+
jest.useFakeTimers();
|
|
1640
|
+
try {
|
|
1641
|
+
const oxy = makeOxy();
|
|
1642
|
+
oxy.startCommonsSignIn.mockResolvedValue({
|
|
1643
|
+
...DELIVERY_HANDLE,
|
|
1644
|
+
expiresAt: Date.now() + 500, // expires before the first fallback poll
|
|
1645
|
+
});
|
|
1646
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
|
|
1647
|
+
const controller = new AccountDialogController({
|
|
1648
|
+
oxyServices: oxy as unknown as OxyServices,
|
|
1649
|
+
sessionClient: new TestSessionClient(host()),
|
|
1650
|
+
clientId: 'oxy_dk_test',
|
|
1651
|
+
pollIntervalMs: 1000,
|
|
1652
|
+
platform: 'desktop',
|
|
1653
|
+
});
|
|
1654
|
+
|
|
1655
|
+
await controller.showQr();
|
|
1656
|
+
await jest.advanceTimersByTimeAsync(1000);
|
|
1657
|
+
|
|
1658
|
+
expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
|
|
1659
|
+
expect(controller.getSnapshot().signIn.phase).toBe('error');
|
|
1660
|
+
expect(controller.getSnapshot().signIn.error).toMatch(/expired/i);
|
|
1661
|
+
} finally {
|
|
1662
|
+
jest.useRealTimers();
|
|
1663
|
+
}
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1666
|
+
it('closing the surface withdraws the request server-side and stops every timer', async () => {
|
|
1667
|
+
jest.useFakeTimers();
|
|
1668
|
+
try {
|
|
1669
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1670
|
+
|
|
1671
|
+
await controller.showQr();
|
|
1672
|
+
await jest.advanceTimersByTimeAsync(0);
|
|
1673
|
+
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
1674
|
+
|
|
1675
|
+
controller.cancelSignIn();
|
|
1676
|
+
|
|
1677
|
+
expect(oxy.denyCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
|
|
1678
|
+
expect(controller.getSnapshot().signIn).toEqual({
|
|
1679
|
+
phase: 'idle',
|
|
1680
|
+
authorizeCode: null,
|
|
1681
|
+
qrPayload: null,
|
|
1682
|
+
expiresAt: null,
|
|
1683
|
+
error: null,
|
|
1684
|
+
route: null,
|
|
1685
|
+
routeFailed: false,
|
|
1686
|
+
pushSentAt: null,
|
|
1687
|
+
openedAt: null,
|
|
1688
|
+
progress: 'idle',
|
|
1689
|
+
});
|
|
1690
|
+
await jest.advanceTimersByTimeAsync(10_000);
|
|
1691
|
+
expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
|
|
1692
|
+
} finally {
|
|
1693
|
+
jest.useRealTimers();
|
|
1694
|
+
}
|
|
1695
|
+
});
|
|
1696
|
+
|
|
1697
|
+
it('does not withdraw a request that already reached a terminal state', async () => {
|
|
1698
|
+
jest.useFakeTimers();
|
|
1699
|
+
try {
|
|
1700
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1701
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'cancelled' });
|
|
1702
|
+
|
|
1703
|
+
await controller.showQr();
|
|
1704
|
+
await jest.advanceTimersByTimeAsync(1000);
|
|
1705
|
+
expect(controller.getSnapshot().signIn.phase).toBe('error');
|
|
1706
|
+
|
|
1707
|
+
controller.cancelSignIn();
|
|
1708
|
+
// Commons already cancelled it — nothing left to withdraw.
|
|
1709
|
+
expect(oxy.denyCommonsSignIn).not.toHaveBeenCalled();
|
|
1710
|
+
} finally {
|
|
1711
|
+
jest.useRealTimers();
|
|
1712
|
+
}
|
|
1713
|
+
});
|
|
1714
|
+
|
|
1715
|
+
it('swallows a failed withdrawal (an approval may have raced it) without surfacing an error', async () => {
|
|
1716
|
+
jest.useFakeTimers();
|
|
1717
|
+
const debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => undefined);
|
|
1718
|
+
try {
|
|
1719
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1720
|
+
oxy.denyCommonsSignIn.mockRejectedValue(new Error('already authorized'));
|
|
1721
|
+
|
|
1722
|
+
await controller.showQr();
|
|
1723
|
+
await jest.advanceTimersByTimeAsync(0);
|
|
1724
|
+
controller.cancelSignIn();
|
|
1725
|
+
await jest.advanceTimersByTimeAsync(0);
|
|
1726
|
+
|
|
1727
|
+
expect(controller.getSnapshot().signIn.phase).toBe('idle');
|
|
1728
|
+
expect(controller.getSnapshot().error).toBeNull();
|
|
1729
|
+
expect(debugSpy).toHaveBeenCalledWith(
|
|
1730
|
+
'[AccountDialogController] request withdrawal failed',
|
|
1731
|
+
{ component: 'AccountDialogController' },
|
|
1732
|
+
expect.any(Error),
|
|
1733
|
+
);
|
|
1734
|
+
} finally {
|
|
1735
|
+
jest.useRealTimers();
|
|
1736
|
+
debugSpy.mockRestore();
|
|
1737
|
+
}
|
|
1738
|
+
});
|
|
1739
|
+
|
|
1740
|
+
it('destroy tears down the poll timer, the auth-session socket, and the popup watcher', async () => {
|
|
1741
|
+
jest.useFakeTimers();
|
|
1742
|
+
try {
|
|
1743
|
+
const popup = fakePopup();
|
|
1744
|
+
const oxy = makeOxy();
|
|
1745
|
+
oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
|
|
1746
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
|
|
1747
|
+
let socket: { disconnected: boolean } | null = null;
|
|
1748
|
+
const factory = jest.fn((): MinimalSocket => {
|
|
1749
|
+
const s: MinimalSocket & { disconnected: boolean } = {
|
|
1750
|
+
connected: true,
|
|
1751
|
+
disconnected: false,
|
|
1752
|
+
on: () => undefined,
|
|
1753
|
+
off: () => undefined,
|
|
1754
|
+
emit: () => undefined,
|
|
1755
|
+
connect: () => undefined,
|
|
1756
|
+
disconnect() {
|
|
1757
|
+
this.disconnected = true;
|
|
1758
|
+
},
|
|
1759
|
+
};
|
|
1760
|
+
socket = s;
|
|
1761
|
+
return s;
|
|
1762
|
+
});
|
|
1763
|
+
const controller = new AccountDialogController({
|
|
1764
|
+
oxyServices: oxy as unknown as OxyServices,
|
|
1765
|
+
sessionClient: new TestSessionClient(host()),
|
|
1766
|
+
clientId: 'oxy_dk_test',
|
|
1767
|
+
pollIntervalMs: 1000,
|
|
1768
|
+
platform: 'desktop',
|
|
1769
|
+
openPopup: () => popup,
|
|
1770
|
+
socketFactory: factory as unknown as SocketIOFactory,
|
|
1771
|
+
});
|
|
1772
|
+
|
|
1773
|
+
await controller.startPasskeyHubSignIn();
|
|
1774
|
+
await jest.advanceTimersByTimeAsync(0);
|
|
1775
|
+
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
1776
|
+
|
|
1777
|
+
controller.destroy();
|
|
1778
|
+
|
|
1779
|
+
expect(socket).not.toBeNull();
|
|
1780
|
+
expect(socket?.disconnected).toBe(true);
|
|
1781
|
+
expect(popup.close).toHaveBeenCalled();
|
|
1782
|
+
// No poll timer and no popup watchdog survive the teardown.
|
|
1783
|
+
await jest.advanceTimersByTimeAsync(30_000);
|
|
1784
|
+
expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
|
|
1785
|
+
} finally {
|
|
1786
|
+
jest.useRealTimers();
|
|
1787
|
+
}
|
|
1788
|
+
});
|
|
978
1789
|
});
|
|
979
1790
|
|
|
980
1791
|
describe('AccountDialogController — lifecycle', () => {
|