@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.
Files changed (126) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/sessionColdBoot.js +142 -37
  3. package/dist/cjs/i18n/locales/ar-SA.json +13 -1
  4. package/dist/cjs/i18n/locales/ca-ES.json +13 -1
  5. package/dist/cjs/i18n/locales/de-DE.json +13 -1
  6. package/dist/cjs/i18n/locales/en-US.json +13 -4
  7. package/dist/cjs/i18n/locales/es-ES.json +13 -4
  8. package/dist/cjs/i18n/locales/fr-FR.json +13 -1
  9. package/dist/cjs/i18n/locales/it-IT.json +13 -1
  10. package/dist/cjs/i18n/locales/ja-JP.json +13 -1
  11. package/dist/cjs/i18n/locales/ko-KR.json +13 -1
  12. package/dist/cjs/i18n/locales/locales/ar-SA.json +13 -1
  13. package/dist/cjs/i18n/locales/locales/ca-ES.json +13 -1
  14. package/dist/cjs/i18n/locales/locales/de-DE.json +13 -1
  15. package/dist/cjs/i18n/locales/locales/en-US.json +13 -4
  16. package/dist/cjs/i18n/locales/locales/es-ES.json +13 -4
  17. package/dist/cjs/i18n/locales/locales/fr-FR.json +13 -1
  18. package/dist/cjs/i18n/locales/locales/it-IT.json +13 -1
  19. package/dist/cjs/i18n/locales/locales/ja-JP.json +13 -1
  20. package/dist/cjs/i18n/locales/locales/ko-KR.json +13 -1
  21. package/dist/cjs/i18n/locales/locales/pt-PT.json +13 -1
  22. package/dist/cjs/i18n/locales/locales/zh-CN.json +13 -1
  23. package/dist/cjs/i18n/locales/pt-PT.json +13 -1
  24. package/dist/cjs/i18n/locales/zh-CN.json +13 -1
  25. package/dist/cjs/index.js +31 -5
  26. package/dist/cjs/mixins/OxyServices.auth.js +257 -17
  27. package/dist/cjs/mixins/OxyServices.deviceBoot.js +54 -3
  28. package/dist/cjs/mixins/OxyServices.notifications.js +63 -0
  29. package/dist/cjs/mixins/index.js +5 -0
  30. package/dist/cjs/session/SessionClient.js +40 -4
  31. package/dist/cjs/session/accountDialogController.js +288 -41
  32. package/dist/cjs/session/identityPin.js +256 -0
  33. package/dist/cjs/session/identitySession.js +135 -0
  34. package/dist/cjs/session/projectSessionState.js +54 -15
  35. package/dist/cjs/session/refresh.js +73 -8
  36. package/dist/cjs/utils/commonsDelivery.js +59 -0
  37. package/dist/cjs/utils/oauthPkce.js +33 -3
  38. package/dist/esm/.tsbuildinfo +1 -1
  39. package/dist/esm/boot/sessionColdBoot.js +142 -37
  40. package/dist/esm/i18n/locales/ar-SA.json +13 -1
  41. package/dist/esm/i18n/locales/ca-ES.json +13 -1
  42. package/dist/esm/i18n/locales/de-DE.json +13 -1
  43. package/dist/esm/i18n/locales/en-US.json +13 -4
  44. package/dist/esm/i18n/locales/es-ES.json +13 -4
  45. package/dist/esm/i18n/locales/fr-FR.json +13 -1
  46. package/dist/esm/i18n/locales/it-IT.json +13 -1
  47. package/dist/esm/i18n/locales/ja-JP.json +13 -1
  48. package/dist/esm/i18n/locales/ko-KR.json +13 -1
  49. package/dist/esm/i18n/locales/locales/ar-SA.json +13 -1
  50. package/dist/esm/i18n/locales/locales/ca-ES.json +13 -1
  51. package/dist/esm/i18n/locales/locales/de-DE.json +13 -1
  52. package/dist/esm/i18n/locales/locales/en-US.json +13 -4
  53. package/dist/esm/i18n/locales/locales/es-ES.json +13 -4
  54. package/dist/esm/i18n/locales/locales/fr-FR.json +13 -1
  55. package/dist/esm/i18n/locales/locales/it-IT.json +13 -1
  56. package/dist/esm/i18n/locales/locales/ja-JP.json +13 -1
  57. package/dist/esm/i18n/locales/locales/ko-KR.json +13 -1
  58. package/dist/esm/i18n/locales/locales/pt-PT.json +13 -1
  59. package/dist/esm/i18n/locales/locales/zh-CN.json +13 -1
  60. package/dist/esm/i18n/locales/pt-PT.json +13 -1
  61. package/dist/esm/i18n/locales/zh-CN.json +13 -1
  62. package/dist/esm/index.js +14 -1
  63. package/dist/esm/mixins/OxyServices.auth.js +257 -17
  64. package/dist/esm/mixins/OxyServices.deviceBoot.js +52 -3
  65. package/dist/esm/mixins/OxyServices.notifications.js +60 -0
  66. package/dist/esm/mixins/index.js +5 -0
  67. package/dist/esm/session/SessionClient.js +40 -4
  68. package/dist/esm/session/accountDialogController.js +288 -41
  69. package/dist/esm/session/identityPin.js +249 -0
  70. package/dist/esm/session/identitySession.js +131 -0
  71. package/dist/esm/session/projectSessionState.js +54 -15
  72. package/dist/esm/session/refresh.js +73 -8
  73. package/dist/esm/utils/commonsDelivery.js +54 -0
  74. package/dist/esm/utils/oauthPkce.js +31 -2
  75. package/dist/types/.tsbuildinfo +1 -1
  76. package/dist/types/boot/sessionColdBoot.d.ts +38 -1
  77. package/dist/types/index.d.ts +12 -4
  78. package/dist/types/mixins/OxyServices.auth.d.ts +262 -11
  79. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +31 -1
  80. package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
  81. package/dist/types/mixins/index.d.ts +2 -1
  82. package/dist/types/session/SessionClient.d.ts +28 -0
  83. package/dist/types/session/accountDialogController.d.ts +187 -9
  84. package/dist/types/session/identityPin.d.ts +87 -0
  85. package/dist/types/session/identitySession.d.ts +105 -0
  86. package/dist/types/session/projectSessionState.d.ts +21 -19
  87. package/dist/types/session/refresh.d.ts +32 -1
  88. package/dist/types/utils/commonsDelivery.d.ts +105 -0
  89. package/dist/types/utils/oauthPkce.d.ts +21 -1
  90. package/package.json +2 -2
  91. package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
  92. package/src/boot/sessionColdBoot.ts +183 -37
  93. package/src/i18n/locales/ar-SA.json +13 -1
  94. package/src/i18n/locales/ca-ES.json +13 -1
  95. package/src/i18n/locales/de-DE.json +13 -1
  96. package/src/i18n/locales/en-US.json +13 -4
  97. package/src/i18n/locales/es-ES.json +13 -4
  98. package/src/i18n/locales/fr-FR.json +13 -1
  99. package/src/i18n/locales/it-IT.json +13 -1
  100. package/src/i18n/locales/ja-JP.json +13 -1
  101. package/src/i18n/locales/ko-KR.json +13 -1
  102. package/src/i18n/locales/pt-PT.json +13 -1
  103. package/src/i18n/locales/zh-CN.json +13 -1
  104. package/src/index.ts +49 -0
  105. package/src/mixins/OxyServices.auth.ts +464 -24
  106. package/src/mixins/OxyServices.deviceBoot.ts +54 -2
  107. package/src/mixins/OxyServices.notifications.ts +142 -0
  108. package/src/mixins/__tests__/commonsSignIn.test.ts +754 -2
  109. package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
  110. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +18 -0
  111. package/src/mixins/index.ts +6 -0
  112. package/src/session/SessionClient.ts +57 -4
  113. package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
  114. package/src/session/__tests__/accountDialogController.test.ts +812 -1
  115. package/src/session/__tests__/identityPin.test.ts +131 -0
  116. package/src/session/__tests__/identitySession.test.ts +194 -0
  117. package/src/session/__tests__/projectSessionState.test.ts +88 -0
  118. package/src/session/__tests__/refresh.identityPin.test.ts +306 -0
  119. package/src/session/accountDialogController.ts +435 -44
  120. package/src/session/identityPin.ts +310 -0
  121. package/src/session/identitySession.ts +206 -0
  122. package/src/session/projectSessionState.ts +61 -13
  123. package/src/session/refresh.ts +103 -9
  124. package/src/utils/__tests__/oauthPkce.test.ts +51 -0
  125. package/src/utils/commonsDelivery.ts +132 -0
  126. 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
- expect(controller.getSnapshot().signIn.phase).toBe('idle');
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', () => {