@oxyhq/core 3.4.1 → 3.4.3

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 (174) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/AuthManager.js +91 -319
  3. package/dist/cjs/CrossDomainAuth.js +19 -106
  4. package/dist/cjs/HttpService.js +49 -73
  5. package/dist/cjs/OxyServices.base.js +2 -2
  6. package/dist/cjs/i18n/index.js +7 -1
  7. package/dist/cjs/i18n/locales/ar-SA.json +18 -2
  8. package/dist/cjs/i18n/locales/ca-ES.json +18 -2
  9. package/dist/cjs/i18n/locales/de-DE.json +18 -2
  10. package/dist/cjs/i18n/locales/en-US.json +16 -2
  11. package/dist/cjs/i18n/locales/es-ES.json +16 -2
  12. package/dist/cjs/i18n/locales/fr-FR.json +18 -2
  13. package/dist/cjs/i18n/locales/it-IT.json +18 -2
  14. package/dist/cjs/i18n/locales/ja-JP.json +18 -2
  15. package/dist/cjs/i18n/locales/ko-KR.json +18 -2
  16. package/dist/cjs/i18n/locales/locales/ar-SA.json +18 -2
  17. package/dist/cjs/i18n/locales/locales/ca-ES.json +18 -2
  18. package/dist/cjs/i18n/locales/locales/de-DE.json +18 -2
  19. package/dist/cjs/i18n/locales/locales/en-US.json +17 -3
  20. package/dist/cjs/i18n/locales/locales/es-ES.json +16 -2
  21. package/dist/cjs/i18n/locales/locales/fr-FR.json +18 -2
  22. package/dist/cjs/i18n/locales/locales/it-IT.json +18 -2
  23. package/dist/cjs/i18n/locales/locales/ja-JP.json +18 -2
  24. package/dist/cjs/i18n/locales/locales/ko-KR.json +18 -2
  25. package/dist/cjs/i18n/locales/locales/pt-PT.json +18 -2
  26. package/dist/cjs/i18n/locales/locales/zh-CN.json +18 -2
  27. package/dist/cjs/i18n/locales/pt-PT.json +18 -2
  28. package/dist/cjs/i18n/locales/zh-CN.json +18 -2
  29. package/dist/cjs/mixins/OxyServices.auth.js +20 -63
  30. package/dist/cjs/mixins/OxyServices.fedcm.js +10 -12
  31. package/dist/cjs/mixins/OxyServices.popup.js +50 -299
  32. package/dist/cjs/mixins/OxyServices.redirect.js +84 -348
  33. package/dist/cjs/mixins/OxyServices.silent.js +204 -0
  34. package/dist/cjs/mixins/OxyServices.sso.js +4 -5
  35. package/dist/cjs/mixins/OxyServices.utility.js +6 -15
  36. package/dist/cjs/mixins/index.js +5 -6
  37. package/dist/cjs/server/index.js +21 -0
  38. package/dist/cjs/server/rateLimit.js +77 -0
  39. package/dist/cjs/shared/utils/debugUtils.js +1 -1
  40. package/dist/cjs/utils/accountUtils.js +4 -4
  41. package/dist/cjs/utils/authHelpers.js +21 -15
  42. package/dist/cjs/utils/coldBoot.js +3 -3
  43. package/dist/cjs/utils/fapiAutoDetect.js +1 -1
  44. package/dist/esm/.tsbuildinfo +1 -1
  45. package/dist/esm/AuthManager.js +91 -319
  46. package/dist/esm/CrossDomainAuth.js +19 -106
  47. package/dist/esm/HttpService.js +49 -73
  48. package/dist/esm/OxyServices.base.js +2 -2
  49. package/dist/esm/i18n/index.js +7 -1
  50. package/dist/esm/i18n/locales/ar-SA.json +18 -2
  51. package/dist/esm/i18n/locales/ca-ES.json +18 -2
  52. package/dist/esm/i18n/locales/de-DE.json +18 -2
  53. package/dist/esm/i18n/locales/en-US.json +16 -2
  54. package/dist/esm/i18n/locales/es-ES.json +16 -2
  55. package/dist/esm/i18n/locales/fr-FR.json +18 -2
  56. package/dist/esm/i18n/locales/it-IT.json +18 -2
  57. package/dist/esm/i18n/locales/ja-JP.json +18 -2
  58. package/dist/esm/i18n/locales/ko-KR.json +18 -2
  59. package/dist/esm/i18n/locales/locales/ar-SA.json +18 -2
  60. package/dist/esm/i18n/locales/locales/ca-ES.json +18 -2
  61. package/dist/esm/i18n/locales/locales/de-DE.json +18 -2
  62. package/dist/esm/i18n/locales/locales/en-US.json +17 -3
  63. package/dist/esm/i18n/locales/locales/es-ES.json +16 -2
  64. package/dist/esm/i18n/locales/locales/fr-FR.json +18 -2
  65. package/dist/esm/i18n/locales/locales/it-IT.json +18 -2
  66. package/dist/esm/i18n/locales/locales/ja-JP.json +18 -2
  67. package/dist/esm/i18n/locales/locales/ko-KR.json +18 -2
  68. package/dist/esm/i18n/locales/locales/pt-PT.json +18 -2
  69. package/dist/esm/i18n/locales/locales/zh-CN.json +18 -2
  70. package/dist/esm/i18n/locales/pt-PT.json +18 -2
  71. package/dist/esm/i18n/locales/zh-CN.json +18 -2
  72. package/dist/esm/mixins/OxyServices.auth.js +20 -63
  73. package/dist/esm/mixins/OxyServices.fedcm.js +10 -12
  74. package/dist/esm/mixins/OxyServices.popup.js +52 -301
  75. package/dist/esm/mixins/OxyServices.redirect.js +84 -349
  76. package/dist/esm/mixins/OxyServices.silent.js +202 -0
  77. package/dist/esm/mixins/OxyServices.sso.js +4 -5
  78. package/dist/esm/mixins/OxyServices.utility.js +6 -15
  79. package/dist/esm/mixins/index.js +5 -6
  80. package/dist/esm/server/index.js +17 -0
  81. package/dist/esm/server/rateLimit.js +71 -0
  82. package/dist/esm/shared/utils/debugUtils.js +1 -1
  83. package/dist/esm/utils/accountUtils.js +4 -4
  84. package/dist/esm/utils/authHelpers.js +21 -15
  85. package/dist/esm/utils/coldBoot.js +3 -3
  86. package/dist/esm/utils/fapiAutoDetect.js +1 -1
  87. package/dist/types/.tsbuildinfo +1 -1
  88. package/dist/types/AuthManager.d.ts +26 -53
  89. package/dist/types/AuthManagerTypes.d.ts +5 -9
  90. package/dist/types/CrossDomainAuth.d.ts +13 -52
  91. package/dist/types/HttpService.d.ts +9 -8
  92. package/dist/types/OxyServices.base.d.ts +1 -1
  93. package/dist/types/OxyServices.d.ts +4 -10
  94. package/dist/types/index.d.ts +1 -1
  95. package/dist/types/mixins/OxyServices.analytics.d.ts +1 -1
  96. package/dist/types/mixins/OxyServices.appData.d.ts +1 -1
  97. package/dist/types/mixins/OxyServices.applications.d.ts +1 -1
  98. package/dist/types/mixins/OxyServices.assets.d.ts +1 -1
  99. package/dist/types/mixins/OxyServices.auth.d.ts +10 -31
  100. package/dist/types/mixins/OxyServices.contacts.d.ts +1 -1
  101. package/dist/types/mixins/OxyServices.devices.d.ts +1 -1
  102. package/dist/types/mixins/OxyServices.features.d.ts +1 -1
  103. package/dist/types/mixins/OxyServices.fedcm.d.ts +5 -5
  104. package/dist/types/mixins/OxyServices.language.d.ts +1 -1
  105. package/dist/types/mixins/OxyServices.location.d.ts +1 -1
  106. package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -1
  107. package/dist/types/mixins/OxyServices.payment.d.ts +1 -1
  108. package/dist/types/mixins/OxyServices.popup.d.ts +18 -120
  109. package/dist/types/mixins/OxyServices.privacy.d.ts +1 -1
  110. package/dist/types/mixins/OxyServices.redirect.d.ts +13 -174
  111. package/dist/types/mixins/OxyServices.reputation.d.ts +1 -1
  112. package/dist/types/mixins/OxyServices.security.d.ts +1 -1
  113. package/dist/types/mixins/OxyServices.silent.d.ts +131 -0
  114. package/dist/types/mixins/OxyServices.sso.d.ts +4 -5
  115. package/dist/types/mixins/OxyServices.topics.d.ts +1 -1
  116. package/dist/types/mixins/OxyServices.user.d.ts +1 -1
  117. package/dist/types/mixins/OxyServices.utility.d.ts +3 -8
  118. package/dist/types/mixins/OxyServices.workspaces.d.ts +1 -1
  119. package/dist/types/mixins/index.d.ts +3 -3
  120. package/dist/types/models/interfaces.d.ts +5 -16
  121. package/dist/types/models/session.d.ts +0 -2
  122. package/dist/types/server/index.d.ts +18 -0
  123. package/dist/types/server/rateLimit.d.ts +40 -0
  124. package/dist/types/shared/utils/debugUtils.d.ts +1 -1
  125. package/dist/types/utils/authHelpers.d.ts +4 -3
  126. package/dist/types/utils/coldBoot.d.ts +2 -2
  127. package/dist/types/utils/fapiAutoDetect.d.ts +1 -1
  128. package/package.json +24 -2
  129. package/src/AuthManager.ts +100 -370
  130. package/src/AuthManagerTypes.ts +5 -9
  131. package/src/CrossDomainAuth.ts +22 -129
  132. package/src/HttpService.ts +55 -73
  133. package/src/OxyServices.base.ts +2 -3
  134. package/src/OxyServices.ts +9 -11
  135. package/src/__tests__/authManager.cookiePath.test.ts +19 -17
  136. package/src/__tests__/authManager.security.test.ts +7 -3
  137. package/src/__tests__/crossDomainAuth.test.ts +26 -118
  138. package/src/i18n/index.ts +7 -1
  139. package/src/i18n/locales/ar-SA.json +18 -2
  140. package/src/i18n/locales/ca-ES.json +18 -2
  141. package/src/i18n/locales/de-DE.json +18 -2
  142. package/src/i18n/locales/en-US.json +17 -3
  143. package/src/i18n/locales/es-ES.json +16 -2
  144. package/src/i18n/locales/fr-FR.json +18 -2
  145. package/src/i18n/locales/it-IT.json +18 -2
  146. package/src/i18n/locales/ja-JP.json +18 -2
  147. package/src/i18n/locales/ko-KR.json +18 -2
  148. package/src/i18n/locales/pt-PT.json +18 -2
  149. package/src/i18n/locales/zh-CN.json +18 -2
  150. package/src/index.ts +1 -1
  151. package/src/mixins/OxyServices.auth.ts +23 -75
  152. package/src/mixins/OxyServices.fedcm.ts +10 -12
  153. package/src/mixins/OxyServices.redirect.ts +82 -371
  154. package/src/mixins/OxyServices.silent.ts +272 -0
  155. package/src/mixins/OxyServices.sso.ts +5 -6
  156. package/src/mixins/OxyServices.utility.ts +9 -22
  157. package/src/mixins/__tests__/appData.test.ts +1 -1
  158. package/src/mixins/__tests__/onTokensChanged.test.ts +1 -1
  159. package/src/mixins/__tests__/reputation.test.ts +1 -1
  160. package/src/mixins/__tests__/serviceAuth.test.ts +7 -5
  161. package/src/mixins/__tests__/silent.test.ts +102 -0
  162. package/src/mixins/__tests__/verifyChallenge.test.ts +9 -14
  163. package/src/mixins/index.ts +6 -8
  164. package/src/models/interfaces.ts +5 -16
  165. package/src/models/session.ts +1 -3
  166. package/src/server/index.ts +19 -0
  167. package/src/server/rateLimit.ts +170 -0
  168. package/src/shared/utils/debugUtils.ts +1 -1
  169. package/src/utils/accountUtils.ts +4 -4
  170. package/src/utils/authHelpers.ts +23 -15
  171. package/src/utils/coldBoot.ts +4 -4
  172. package/src/utils/fapiAutoDetect.ts +1 -1
  173. package/src/mixins/OxyServices.popup.ts +0 -631
  174. package/src/mixins/__tests__/popup.test.ts +0 -374
@@ -1,374 +0,0 @@
1
- /**
2
- * Popup mixin regression tests.
3
- *
4
- * Locks in the §6c fix: cross-domain sign-in popups (auth.oxy.so) were being
5
- * blocked by Chrome on consumer apps (mention.earth, homiio.com, alia.onl)
6
- * because the caller chain awaited FedCM / silent SSO BEFORE
7
- * `signInWithPopup` reached `window.open`. The transient user-activation
8
- * had been consumed by the first `await`, so the popup-blocker killed the
9
- * subsequent `window.open` call.
10
- *
11
- * The fix exposes two new affordances on the popup mixin:
12
- * 1. `openBlankPopup(width?, height?)` — a public, synchronous helper that
13
- * callers invoke from the raw user-gesture handler BEFORE any await, so
14
- * the popup is opened while the activation is still live.
15
- * 2. `signInWithPopup({ popup })` — accepts the pre-opened window handle
16
- * and navigates IT to the auth URL instead of issuing a fresh
17
- * `window.open` (which would now be blocked).
18
- *
19
- * Backward compat: callers that omit `popup` keep the legacy behaviour (the
20
- * mixin opens its own popup via `openCenteredPopup`). The browser globals
21
- * are stubbed so the platform-agnostic mixin can run under the node test
22
- * env.
23
- */
24
-
25
- import { OxyServices } from '../../OxyServices';
26
-
27
- const ORIGIN = 'https://mention.earth';
28
-
29
- interface MockPopup {
30
- closed: boolean;
31
- close: jest.Mock;
32
- location: {
33
- href: string;
34
- replace: jest.Mock;
35
- };
36
- }
37
-
38
- function createMockPopup(overrides: Partial<MockPopup> = {}): MockPopup {
39
- return {
40
- closed: false,
41
- close: jest.fn(),
42
- location: {
43
- href: '',
44
- replace: jest.fn(function (this: { href: string }, url: string) {
45
- this.href = url;
46
- }),
47
- },
48
- ...overrides,
49
- };
50
- }
51
-
52
- function installBrowserGlobals(options: {
53
- windowOpen?: jest.Mock;
54
- postMessageDispatcher?: { current: ((event: { origin: string; data: unknown }) => void) | null };
55
- } = {}): void {
56
- const store = new Map<string, string>();
57
- const sessionStorageStub = {
58
- getItem: (k: string) => (store.has(k) ? (store.get(k) as string) : null),
59
- setItem: (k: string, v: string) => { store.set(k, v); },
60
- removeItem: (k: string) => { store.delete(k); },
61
- };
62
- const messageHandlers: Array<(event: { origin: string; data: unknown }) => void> = [];
63
- const win = {
64
- location: { origin: ORIGIN, hostname: 'mention.earth' },
65
- screenX: 0,
66
- screenY: 0,
67
- outerWidth: 1280,
68
- outerHeight: 800,
69
- sessionStorage: sessionStorageStub,
70
- open: options.windowOpen ?? jest.fn(() => null),
71
- addEventListener: (event: string, handler: (e: { origin: string; data: unknown }) => void) => {
72
- if (event === 'message') {
73
- messageHandlers.push(handler);
74
- if (options.postMessageDispatcher) {
75
- options.postMessageDispatcher.current = handler;
76
- }
77
- }
78
- },
79
- removeEventListener: (event: string, handler: (e: { origin: string; data: unknown }) => void) => {
80
- if (event === 'message') {
81
- const idx = messageHandlers.indexOf(handler);
82
- if (idx >= 0) messageHandlers.splice(idx, 1);
83
- }
84
- },
85
- };
86
- (globalThis as unknown as { window: unknown }).window = win;
87
- (globalThis as unknown as { sessionStorage: unknown }).sessionStorage = sessionStorageStub;
88
- // `crypto.randomUUID` already exists in node 20+ test env — leave it.
89
- }
90
-
91
- function clearBrowserGlobals(): void {
92
- for (const key of ['window', 'sessionStorage'] as const) {
93
- delete (globalThis as Record<string, unknown>)[key];
94
- }
95
- }
96
-
97
- describe('OxyServices popup mixin — pre-opened popup option', () => {
98
- afterEach(() => {
99
- clearBrowserGlobals();
100
- jest.restoreAllMocks();
101
- });
102
-
103
- it('navigates a pre-opened popup to the auth URL instead of opening a new one', async () => {
104
- const windowOpen = jest.fn();
105
- installBrowserGlobals({ windowOpen });
106
- const popup = createMockPopup();
107
-
108
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
109
-
110
- // Resolve `signInWithPopup` as soon as the popup is navigated — we only
111
- // care about the open path, not the full postMessage round-trip.
112
- let dispatchedAuthUrl: string | null = null;
113
- popup.location.replace.mockImplementation(function (this: MockPopup['location'], url: string) {
114
- this.href = url;
115
- dispatchedAuthUrl = url;
116
- });
117
-
118
- // Fire the auth-success message immediately after navigation. We do this
119
- // by intercepting `addEventListener` above.
120
- const messagePromise = new Promise<void>((resolve) => {
121
- // Patch addEventListener to capture the handler and dispatch a fake
122
- // success message on the next microtask.
123
- const origAdd = (globalThis as unknown as { window: { addEventListener: typeof window.addEventListener } }).window.addEventListener;
124
- (globalThis as unknown as { window: { addEventListener: typeof window.addEventListener } }).window.addEventListener =
125
- (event: string, handler: EventListenerOrEventListenerObject) => {
126
- origAdd(event, handler);
127
- if (event === 'message') {
128
- queueMicrotask(() => {
129
- // We need the state from the URL the popup was navigated to.
130
- const url = new URL(dispatchedAuthUrl ?? '');
131
- const state = url.searchParams.get('state') ?? '';
132
- (handler as (e: { origin: string; data: unknown }) => void)({
133
- origin: 'https://auth.oxy.so',
134
- data: {
135
- type: 'oxy_auth_response',
136
- state,
137
- session: {
138
- sessionId: 'sess_pre_opened',
139
- deviceId: 'dev_pre',
140
- expiresAt: new Date(Date.now() + 60000).toISOString(),
141
- accessToken: 'access_pre',
142
- user: { id: 'user_pre', username: 'tester' },
143
- },
144
- },
145
- });
146
- resolve();
147
- });
148
- }
149
- };
150
- });
151
-
152
- const session = await oxy.signInWithPopup({ popup: popup as unknown as Window });
153
- await messagePromise;
154
-
155
- // The pre-opened popup was navigated — `window.open` was NEVER called.
156
- expect(windowOpen).not.toHaveBeenCalled();
157
- expect(popup.location.replace).toHaveBeenCalledTimes(1);
158
- expect(popup.location.replace).toHaveBeenCalledWith(expect.stringContaining('https://auth.oxy.so/login'));
159
- expect(session.sessionId).toBe('sess_pre_opened');
160
- });
161
-
162
- it('throws a "window was closed" (cancelled) error — NOT "popup blocked" — when the pre-opened handle is already closed', async () => {
163
- const windowOpen = jest.fn();
164
- installBrowserGlobals({ windowOpen });
165
- const popup = createMockPopup({ closed: true });
166
-
167
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
168
-
169
- // The popup DID open (the blocker allowed it) and the user closed it.
170
- // The error must communicate a cancel, never a blocker rejection —
171
- // consumers map "blocked" to "please allow popups" UX guidance, which
172
- // would be wrong here.
173
- await expect(
174
- oxy.signInWithPopup({ popup: popup as unknown as Window })
175
- ).rejects.toThrow(/Sign-in window was closed/);
176
- await expect(
177
- oxy.signInWithPopup({ popup: popup as unknown as Window })
178
- ).rejects.not.toThrow(/Popup blocked/);
179
-
180
- // Did not attempt to open a fresh popup either.
181
- expect(windowOpen).not.toHaveBeenCalled();
182
- expect(popup.location.replace).not.toHaveBeenCalled();
183
- });
184
-
185
- it('falls back to assigning `location.href` when `location.replace` throws (sandboxed environments)', async () => {
186
- const windowOpen = jest.fn();
187
- installBrowserGlobals({ windowOpen });
188
-
189
- // Some sandboxed / cross-origin-locked environments make `location.replace`
190
- // throw. The mixin must recover with a plain `href` assignment so the
191
- // popup still gets navigated. This is the only path that exercises the
192
- // catch-and-log branch in OxyServices.popup.ts.
193
- const popup: MockPopup = {
194
- closed: false,
195
- close: jest.fn(),
196
- location: {
197
- href: '',
198
- replace: jest.fn(() => {
199
- throw new Error('SecurityError: replace blocked by sandbox');
200
- }),
201
- },
202
- };
203
-
204
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
205
-
206
- // Drive `signInWithPopup` only until the navigation happens, then abort
207
- // via `closed = true` so the poll loop's cancel path resolves the promise.
208
- const promise = oxy.signInWithPopup({ popup: popup as unknown as Window });
209
- // Let the synchronous popup-navigation path run.
210
- await Promise.resolve();
211
- popup.closed = true;
212
-
213
- await expect(promise).rejects.toThrow(/cancelled|timeout/i);
214
-
215
- // `replace` was attempted (and threw); the fallback wrote to `href`.
216
- expect(popup.location.replace).toHaveBeenCalledTimes(1);
217
- expect(popup.location.href).toMatch(/^https:\/\/auth\.oxy\.so\/login/);
218
- // No fresh popup was opened.
219
- expect(windowOpen).not.toHaveBeenCalled();
220
- });
221
-
222
- it('falls back to opening its own popup when `popup` option is omitted (classic behaviour)', async () => {
223
- const fallbackPopup = createMockPopup();
224
- const windowOpen = jest.fn(() => fallbackPopup);
225
- installBrowserGlobals({ windowOpen });
226
-
227
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
228
-
229
- // Don't actually wait for the full flow; intercept after `window.open`
230
- // is called and abort.
231
- const promise = oxy.signInWithPopup();
232
- // Allow the synchronous `window.open` path to run.
233
- await Promise.resolve();
234
- fallbackPopup.closed = true; // trigger "Authentication cancelled by user"
235
-
236
- await expect(promise).rejects.toThrow(/cancelled|timeout/i);
237
-
238
- expect(windowOpen).toHaveBeenCalledTimes(1);
239
- // The first arg is the auth URL (not 'about:blank').
240
- expect((windowOpen.mock.calls[0] as unknown[])[0]).toMatch(/^https:\/\/auth\.oxy\.so\/login/);
241
- });
242
-
243
- it('throws "popup blocked" when the legacy path is used and `window.open` returns null', async () => {
244
- const windowOpen = jest.fn(() => null);
245
- installBrowserGlobals({ windowOpen });
246
-
247
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
248
-
249
- await expect(oxy.signInWithPopup()).rejects.toThrow(/Popup blocked/);
250
- expect(windowOpen).toHaveBeenCalledTimes(1);
251
- });
252
- });
253
-
254
- describe('OxyServices popup mixin — openBlankPopup helper', () => {
255
- afterEach(() => {
256
- clearBrowserGlobals();
257
- jest.restoreAllMocks();
258
- });
259
-
260
- it('opens about:blank synchronously and returns the window handle', () => {
261
- const fakePopup = createMockPopup();
262
- const windowOpen = jest.fn(() => fakePopup);
263
- installBrowserGlobals({ windowOpen });
264
-
265
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
266
- const popup = oxy.openBlankPopup();
267
-
268
- expect(windowOpen).toHaveBeenCalledTimes(1);
269
- const args = windowOpen.mock.calls[0] as unknown[];
270
- expect(args[0]).toBe('about:blank');
271
- expect(args[1]).toBe('Oxy Sign In');
272
- // Features string should include the default width/height.
273
- expect(typeof args[2]).toBe('string');
274
- expect(args[2] as string).toContain('width=500');
275
- expect(args[2] as string).toContain('height=700');
276
- expect(popup).toBe(fakePopup);
277
- });
278
-
279
- it('returns null when the browser blocks the popup', () => {
280
- const windowOpen = jest.fn(() => null);
281
- installBrowserGlobals({ windowOpen });
282
-
283
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
284
- const popup = oxy.openBlankPopup();
285
-
286
- expect(popup).toBeNull();
287
- });
288
-
289
- it('honors caller-supplied dimensions', () => {
290
- const fakePopup = createMockPopup();
291
- const windowOpen = jest.fn(() => fakePopup);
292
- installBrowserGlobals({ windowOpen });
293
-
294
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
295
- oxy.openBlankPopup(640, 480);
296
-
297
- const args = windowOpen.mock.calls[0] as unknown[];
298
- expect(args[2] as string).toContain('width=640');
299
- expect(args[2] as string).toContain('height=480');
300
- });
301
-
302
- it('returns null in non-browser environments', () => {
303
- // No browser globals installed.
304
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
305
- expect(oxy.openBlankPopup()).toBeNull();
306
- });
307
- });
308
-
309
- /**
310
- * `waitForIframeAuth` fail-fast regression tests.
311
- *
312
- * The cross-domain durable-restore iframe (`/auth/silent` at the per-apex host)
313
- * posts a message on success. On a FAILED load — host unreachable, blocked by
314
- * CSP `frame-ancestors`/`X-Frame-Options`, or a dropped network — it never
315
- * posts, so without an `onerror`/`onabort` handler the silent restore would
316
- * block for the FULL timeout (dead latency in the cold-boot critical path). The
317
- * handler must resolve `null` immediately on a load failure, well before the
318
- * timeout fires.
319
- */
320
- interface FakeIframe {
321
- onerror: ((this: unknown, ...args: unknown[]) => unknown) | null;
322
- onabort: ((this: unknown, ...args: unknown[]) => unknown) | null;
323
- }
324
-
325
- describe('OxyServices waitForIframeAuth fail-fast on iframe load error', () => {
326
- afterEach(() => {
327
- clearBrowserGlobals();
328
- jest.restoreAllMocks();
329
- });
330
-
331
- it('resolves null immediately when the iframe fires onerror (does not wait for the timeout)', async () => {
332
- installBrowserGlobals();
333
-
334
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
335
- const iframe: FakeIframe = { onerror: null, onabort: null };
336
-
337
- // A long timeout proves the resolution comes from `onerror`, not the timer.
338
- const LONG_TIMEOUT = 100000;
339
- const settled = oxy.waitForIframeAuth(
340
- iframe as unknown as HTMLIFrameElement,
341
- LONG_TIMEOUT,
342
- 'https://auth.mention.earth',
343
- );
344
-
345
- // The handler is installed synchronously; fire it on the next tick.
346
- await Promise.resolve();
347
- expect(typeof iframe.onerror).toBe('function');
348
- iframe.onerror?.call(iframe);
349
-
350
- await expect(settled).resolves.toBeNull();
351
- // Cleanup detaches the handlers so a late event cannot double-resolve.
352
- expect(iframe.onerror).toBeNull();
353
- expect(iframe.onabort).toBeNull();
354
- });
355
-
356
- it('resolves null immediately when the iframe fires onabort', async () => {
357
- installBrowserGlobals();
358
-
359
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
360
- const iframe: FakeIframe = { onerror: null, onabort: null };
361
-
362
- const settled = oxy.waitForIframeAuth(
363
- iframe as unknown as HTMLIFrameElement,
364
- 100000,
365
- 'https://auth.mention.earth',
366
- );
367
-
368
- await Promise.resolve();
369
- expect(typeof iframe.onabort).toBe('function');
370
- iframe.onabort?.call(iframe);
371
-
372
- await expect(settled).resolves.toBeNull();
373
- });
374
- });