@bytescale/sdk 3.59.0 → 3.61.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.
@@ -60,6 +60,80 @@ describe("API-client AuthManager configuration", () => {
60
60
  expect(requestHeaders(fetchApi, 1).get("Authorization-Token")).toBe("access-refreshed");
61
61
  });
62
62
 
63
+ test("awaits manager-owned authentication at the expiry boundary", async () => {
64
+ const configState = state("customer", accountA, "jwt-old", "access-old");
65
+ configState.expiresAt = Date.now();
66
+ let completeRefresh = (): void => {
67
+ throw new Error("Refresh completion callback was not initialized.");
68
+ };
69
+ const authenticationPromise = new Promise<void>(resolve => {
70
+ completeRefresh = () => {
71
+ configState.accessToken = "access-new";
72
+ configState.expiresAt = Date.now() + 60_000;
73
+ configState.jwt = "jwt-new";
74
+ configState.refreshPromise = undefined;
75
+ resolve();
76
+ };
77
+ });
78
+ configState.authenticationPromise = authenticationPromise;
79
+ configState.refreshPromise = authenticationPromise;
80
+ setModernSession([configState]);
81
+ const { api, fetchApi } = createApi({ apiKey: apiKeyA, authConfigId: "customer" });
82
+
83
+ const requests = [api.get(), api.get()];
84
+ expect(fetchApi).not.toHaveBeenCalled();
85
+ completeRefresh();
86
+ await Promise.all(requests);
87
+
88
+ expect(requestHeaders(fetchApi, 0).get("Authorization-Token")).toBe("access-new");
89
+ expect(requestHeaders(fetchApi, 1).get("Authorization-Token")).toBe("access-new");
90
+ });
91
+
92
+ test("observes authentication that starts while resolving custom headers", async () => {
93
+ const configState = state("customer", accountB, "jwt-old", "access-old");
94
+ let completeRefresh = (): void => {
95
+ throw new Error("Refresh completion callback was not initialized.");
96
+ };
97
+ const authenticationPromise = new Promise<void>(resolve => {
98
+ completeRefresh = () => {
99
+ configState.accessToken = "access-new";
100
+ configState.expiresAt = Date.now() + 60_000;
101
+ configState.jwt = "jwt-new";
102
+ configState.refreshPromise = undefined;
103
+ resolve();
104
+ };
105
+ });
106
+ setModernSession([configState]);
107
+ const { api, fetchApi } = createApi({
108
+ authConfigId: "customer",
109
+ headers: async (): Promise<Record<string, string>> => {
110
+ configState.authenticationPromise = authenticationPromise;
111
+ configState.refreshPromise = authenticationPromise;
112
+ return {};
113
+ }
114
+ });
115
+
116
+ const request = api.get();
117
+ expect(fetchApi).not.toHaveBeenCalled();
118
+ completeRefresh();
119
+ await request;
120
+
121
+ expect(requestHeaders(fetchApi).get("Authorization")).toBe("Bearer jwt-new");
122
+ });
123
+
124
+ test("never invokes the token provider when authentication is not in progress", async () => {
125
+ const getAuthorizationToken = jest.fn(async () => "jwt-new");
126
+ const configState = state("customer", accountB, "jwt-old", "access-old", getAuthorizationToken);
127
+ configState.expiresAt = Date.now();
128
+ setModernSession([configState]);
129
+ const { api, fetchApi } = createApi({ authConfigId: "customer" });
130
+
131
+ await expect(api.get()).rejects.toThrow("not ready");
132
+
133
+ expect(getAuthorizationToken).not.toHaveBeenCalled();
134
+ expect(fetchApi).not.toHaveBeenCalled();
135
+ });
136
+
63
137
  test("uses the raw JWT as sole authentication for an API-key-less named client", async () => {
64
138
  setModernSession([state("customer", accountB, "jwt-b", "access-b")]);
65
139
  const { api, fetchApi } = createApi({ authConfigId: "customer" });
@@ -126,6 +200,29 @@ describe("API-client AuthManager configuration", () => {
126
200
  expect(requestHeaders(optedOut.fetchApi).get("Authorization")).toBe(`Bearer ${apiKeyA}`);
127
201
  });
128
202
 
203
+ test("fails closed for an expired V1 default instead of falling back to its API key", async () => {
204
+ const expired = state(undefined, accountA, "jwt-a", "access-a");
205
+ expired.expiresAt = Date.now() - 1;
206
+ AuthSessionState.setSession({
207
+ accessToken: undefined,
208
+ accessTokenRefreshHandle: undefined,
209
+ authConfigs: [expired],
210
+ authServiceWorker: undefined,
211
+ isActive: true,
212
+ isReady: false,
213
+ params: {
214
+ accountId: accountA,
215
+ authHeaders: async (): Promise<Record<string, string>> => ({}),
216
+ authUrl: "https://app.example.com/auth"
217
+ },
218
+ serviceWorkerConfigured: false
219
+ });
220
+ const { api, fetchApi } = createApi({ apiKey: apiKeyA });
221
+
222
+ await expect(api.get()).rejects.toThrow("not ready");
223
+ expect(fetchApi).not.toHaveBeenCalled();
224
+ });
225
+
129
226
  test("recognizes a 3.54 session as the default supplemental token", async () => {
130
227
  AuthSessionState.setSession({
131
228
  accessToken: "legacy-access",
@@ -184,16 +281,18 @@ function state(
184
281
  authConfigId: string | undefined,
185
282
  accountId: string,
186
283
  jwt: string,
187
- accessToken: string
284
+ accessToken: string,
285
+ getAuthorizationToken: () => Promise<string> = async () => jwt
188
286
  ): AuthSessionConfigState {
189
287
  const config: AuthSessionConfig = {
190
288
  accountId,
191
289
  authConfigId,
192
290
  enableServiceWorkerAuth: false,
193
- getAuthorizationToken: async () => jwt
291
+ getAuthorizationToken
194
292
  };
195
293
  return {
196
294
  accessToken,
295
+ authenticationPromise: Promise.resolve(),
197
296
  config,
198
297
  expiresAt: Date.now() + 60_000,
199
298
  jwt,
@@ -203,6 +203,60 @@ describe("AuthManager browser multi-configuration sessions", () => {
203
203
  });
204
204
  });
205
205
 
206
+ test("uses each config's effective CDN URL for registration, worker prefixes, cleanup, and collisions", async () => {
207
+ const fetchApi = createFetchApi();
208
+ const defaultCdnUrl = "https://downloads-default.example.com";
209
+ const customCdnUrl = "https://downloads-custom.example.com";
210
+ const cookieCdnUrl = "https://downloads-cookie.example.com";
211
+
212
+ await AuthManager.beginAuthSession({
213
+ authConfigs: async () => [
214
+ {
215
+ ...apiOnlyConfig("custom-worker", accountA, async () => jwtA),
216
+ cdnUrl: customCdnUrl,
217
+ enableServiceWorkerAuth: true
218
+ },
219
+ {
220
+ ...apiOnlyConfig("default-worker", accountA, async () => jwtB),
221
+ enableServiceWorkerAuth: true
222
+ },
223
+ {
224
+ ...apiOnlyConfig("cookie", accountA, async () => jwtC),
225
+ cdnUrl: cookieCdnUrl,
226
+ enableCookieAuth: true
227
+ }
228
+ ],
229
+ options: { cdnUrl: defaultCdnUrl, fetchApi },
230
+ serviceWorkerScript: "/auth-sw.js"
231
+ });
232
+
233
+ expect(fetchApi.mock.calls.filter(([, init]) => init?.method === "PUT").map(([input]) => inputUrl(input))).toEqual([
234
+ `${customCdnUrl}/api/v1/access_tokens/${accountA}?set-cookie=false`,
235
+ `${defaultCdnUrl}/api/v1/access_tokens/${accountA}?set-cookie=false`,
236
+ `${cookieCdnUrl}/api/v1/access_tokens/${accountA}?set-cookie=true`
237
+ ]);
238
+ expect((postMessage.mock.calls.at(-1)?.[0] as any).config).toEqual([
239
+ {
240
+ expires: expect.any(Number),
241
+ headers: [{ key: "Authorization", value: `Bearer ${jwtA}` }],
242
+ sourceUrlPrefixes: undefined,
243
+ urlPrefix: `${customCdnUrl}/${accountA}/`
244
+ },
245
+ {
246
+ expires: expect.any(Number),
247
+ headers: [{ key: "Authorization", value: `Bearer ${jwtB}` }],
248
+ sourceUrlPrefixes: undefined,
249
+ urlPrefix: `${defaultCdnUrl}/${accountA}/`
250
+ }
251
+ ]);
252
+
253
+ await AuthManager.endAuthSession();
254
+
255
+ expect(
256
+ fetchApi.mock.calls.filter(([, init]) => init?.method === "DELETE").map(([input]) => inputUrl(input))
257
+ ).toEqual([`${cookieCdnUrl}/api/v1/access_tokens/${accountA}?set-cookie=true`]);
258
+ });
259
+
206
260
  test("supports a manual cookie-only V2 config without a service worker", async () => {
207
261
  delete navigatorValue.serviceWorker;
208
262
  const fetchApi = createFetchApi();
@@ -254,6 +308,53 @@ describe("AuthManager browser multi-configuration sessions", () => {
254
308
  expect(AuthManager.isAuthSessionReady()).toBe(true);
255
309
  });
256
310
 
311
+ test("deduplicates concurrent refreshes for the same auth config", async () => {
312
+ const fetchApi = createFetchApi();
313
+ let resolveRefresh = (_jwt: string): void => {
314
+ throw new Error("Refresh resolver was not initialized.");
315
+ };
316
+ const refreshResult = new Promise<string>(resolve => {
317
+ resolveRefresh = resolve;
318
+ });
319
+ let markRefreshStarted = (): void => {
320
+ throw new Error("Refresh-start resolver was not initialized.");
321
+ };
322
+ const refreshStarted = new Promise<void>(resolve => {
323
+ markRefreshStarted = resolve;
324
+ });
325
+ const provider = jest
326
+ .fn<() => Promise<string>>()
327
+ .mockResolvedValueOnce(jwtA)
328
+ .mockImplementationOnce(async () => {
329
+ markRefreshStarted();
330
+ return await refreshResult;
331
+ });
332
+
333
+ await AuthManager.beginAuthSession({
334
+ authConfigs: async () => [apiOnlyConfig("customer", accountA, provider)],
335
+ options: { fetchApi }
336
+ });
337
+ const session = AuthSessionState.getSession();
338
+ const configState = session?.authConfigs?.[0];
339
+ if (session === undefined || configState === undefined) {
340
+ throw new Error("Expected initialized auth state.");
341
+ }
342
+ const internals = AuthManager as AuthManagerInternals;
343
+
344
+ const firstRefresh = internals.refreshAuthConfig(session, configState);
345
+ const secondRefresh = internals.refreshAuthConfig(session, configState);
346
+ await refreshStarted;
347
+ expect(provider).toHaveBeenCalledTimes(2);
348
+ expect(configState.authenticationPromise).toBe(configState.refreshPromise);
349
+
350
+ resolveRefresh(jwtB);
351
+ await Promise.all([firstRefresh, secondRefresh]);
352
+
353
+ expect(configState.jwt).toBe(jwtB);
354
+ expect(configState.authenticationPromise).toBeDefined();
355
+ expect(configState.refreshPromise).toBeUndefined();
356
+ });
357
+
257
358
  test("clears the cookie-enabled config and the complete worker config on end", async () => {
258
359
  const fetchApi = createFetchApi();
259
360
  await AuthManager.beginAuthSession({
@@ -340,6 +441,14 @@ describe("AuthManager browser multi-configuration sessions", () => {
340
441
  params: (fetchApi: FetchApi) => v2Params(fetchApi, [apiOnlyConfig("a", "A12/abc", async () => jwtA)]),
341
442
  error: "Invalid Bytescale account ID"
342
443
  },
444
+ {
445
+ name: "invalid config CDN URL",
446
+ params: (fetchApi: FetchApi) =>
447
+ v2Params(fetchApi, [
448
+ { ...apiOnlyConfig("a", accountA, async () => jwtA), cdnUrl: 123 } as unknown as AuthSessionConfig
449
+ ]),
450
+ error: "cdnUrl"
451
+ },
343
452
  {
344
453
  name: "overlapping cookie and worker accounts",
345
454
  params: (fetchApi: FetchApi): BeginAuthSessionParamsV2 => ({
@@ -150,6 +150,15 @@ describe("Auth service-worker URL rewriting", () => {
150
150
  expect(untouched.responded).toBe(false);
151
151
  });
152
152
 
153
+ test("waits for delayed persistent authentication state on a cold start", async () => {
154
+ const harness = new AuthServiceWorkerHarness();
155
+ harness.setPersistentConfig([authConfig("account-a/", "token-a")], 300);
156
+
157
+ const result = await harness.dispatchFetch("https://upcdn.io/account-a/file.pdf");
158
+
159
+ expect(result.outboundRequest?.headers.get("Authorization")).toBe("Bearer token-a");
160
+ });
161
+
153
162
  test("keeps source-page authorization independent across multiple client IDs", async () => {
154
163
  const harness = new AuthServiceWorkerHarness();
155
164
  harness.setWindowClient("client-a", "https://app.example.com/a/");
@@ -221,6 +230,8 @@ type WorkerEventListener = (event: unknown) => void;
221
230
 
222
231
  class AuthServiceWorkerHarness {
223
232
  private readonly clientsById = new Map<string, { type: "window"; url: string }>();
233
+ private readonly cacheEntries = new Map<string, NodeFetchResponse>();
234
+ private cacheReadDelayMilliseconds = 0;
224
235
  private readonly context: {
225
236
  getRewrittenUrl: (url: string, rules: UrlRewriteRule[]) => string | undefined;
226
237
  setConfig: (config: AuthSwConfigEntryDto[], rules?: UrlRewriteRule[]) => Promise<void>;
@@ -231,7 +242,6 @@ class AuthServiceWorkerHarness {
231
242
 
232
243
  constructor(private readonly upstreamResponse: NodeFetchResponse = new NodeFetchResponse("ok")) {
233
244
  const listeners = new Map<string, WorkerEventListener>();
234
- const cacheEntries = new Map<string, NodeFetchResponse>();
235
245
  this.fetchMock = jest.fn(async (_request: TestRequest): Promise<NodeFetchResponse> => this.upstreamResponse);
236
246
 
237
247
  const self = {
@@ -246,9 +256,14 @@ class AuthServiceWorkerHarness {
246
256
  skipWaiting: async (): Promise<void> => {}
247
257
  };
248
258
  const cache = {
249
- match: async (key: string): Promise<NodeFetchResponse | undefined> => cacheEntries.get(key)?.clone(),
259
+ match: async (key: string): Promise<NodeFetchResponse | undefined> => {
260
+ if (this.cacheReadDelayMilliseconds > 0) {
261
+ await new Promise(resolve => setTimeout(resolve, this.cacheReadDelayMilliseconds));
262
+ }
263
+ return this.cacheEntries.get(key)?.clone();
264
+ },
250
265
  put: async (key: string, value: NodeFetchResponse): Promise<void> => {
251
- cacheEntries.set(key, value.clone());
266
+ this.cacheEntries.set(key, value.clone());
252
267
  }
253
268
  };
254
269
  const sandbox = {
@@ -284,6 +299,11 @@ class AuthServiceWorkerHarness {
284
299
  this.clientsById.set(clientId, { type: "window", url });
285
300
  }
286
301
 
302
+ setPersistentConfig(config: AuthSwConfigEntryDto[], cacheReadDelayMilliseconds: number): void {
303
+ this.cacheEntries.set("config", new NodeFetchResponse(JSON.stringify(config)));
304
+ this.cacheReadDelayMilliseconds = cacheReadDelayMilliseconds;
305
+ }
306
+
287
307
  async dispatchFetch(url: string, options: FetchOptions = {}): Promise<FetchResult> {
288
308
  const request = new TestRequest(url, { headers: options.headers as NodeFetchRequestInit["headers"] });
289
309
  if (options.navigation === true) {
@@ -0,0 +1,135 @@
1
+ import { jest } from "@jest/globals";
2
+ import { Scheduler } from "../src/private/Scheduler";
3
+
4
+ class TestEventTarget {
5
+ private readonly listeners = new Map<string, Set<EventListener>>();
6
+ visibilityState: DocumentVisibilityState = "visible";
7
+
8
+ addEventListener(type: string, listener: EventListener): void {
9
+ const listeners = this.listeners.get(type) ?? new Set<EventListener>();
10
+ listeners.add(listener);
11
+ this.listeners.set(type, listeners);
12
+ }
13
+
14
+ removeEventListener(type: string, listener: EventListener): void {
15
+ this.listeners.get(type)?.delete(listener);
16
+ }
17
+
18
+ dispatch(type: string): void {
19
+ for (const listener of this.listeners.get(type) ?? []) {
20
+ listener(new Event(type));
21
+ }
22
+ }
23
+
24
+ listenerCount(type: string): number {
25
+ return this.listeners.get(type)?.size ?? 0;
26
+ }
27
+ }
28
+
29
+ describe("Scheduler", () => {
30
+ const originalDocument = Object.getOwnPropertyDescriptor(globalThis, "document");
31
+ const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
32
+ let documentEvents: TestEventTarget;
33
+ let windowEvents: TestEventTarget;
34
+ let now: number;
35
+
36
+ beforeEach(() => {
37
+ jest.useFakeTimers();
38
+ now = 1000;
39
+ jest.spyOn(Date, "now").mockImplementation(() => now);
40
+ documentEvents = new TestEventTarget();
41
+ windowEvents = new TestEventTarget();
42
+ Object.defineProperty(globalThis, "document", { configurable: true, value: documentEvents });
43
+ Object.defineProperty(globalThis, "window", { configurable: true, value: windowEvents });
44
+ });
45
+
46
+ afterEach(() => {
47
+ jest.useRealTimers();
48
+ jest.restoreAllMocks();
49
+ restoreGlobal("document", originalDocument);
50
+ restoreGlobal("window", originalWindow);
51
+ });
52
+
53
+ test.each(["focus", "online", "pageshow"])("runs overdue callbacks immediately on %s", eventName => {
54
+ const scheduler = new Scheduler();
55
+ const callback = jest.fn();
56
+ scheduler.schedule(2000, callback);
57
+
58
+ now = 3000;
59
+ windowEvents.dispatch(eventName);
60
+
61
+ expect(callback).toHaveBeenCalledTimes(1);
62
+ });
63
+
64
+ test("runs overdue callbacks when a visible document resumes", () => {
65
+ const scheduler = new Scheduler();
66
+ const callback = jest.fn();
67
+ scheduler.schedule(2000, callback);
68
+
69
+ now = 3000;
70
+ documentEvents.visibilityState = "hidden";
71
+ documentEvents.dispatch("visibilitychange");
72
+ expect(callback).not.toHaveBeenCalled();
73
+
74
+ documentEvents.visibilityState = "visible";
75
+ documentEvents.dispatch("visibilitychange");
76
+ expect(callback).toHaveBeenCalledTimes(1);
77
+ });
78
+
79
+ test("runs scheduled callbacks immediately after a material backwards clock change", () => {
80
+ const scheduler = new Scheduler();
81
+ const callback = jest.fn();
82
+ now = 10_000;
83
+ scheduler.schedule(20_000, callback);
84
+
85
+ now = 8000;
86
+ documentEvents.dispatch("resume");
87
+
88
+ expect(callback).toHaveBeenCalledTimes(1);
89
+ });
90
+
91
+ test("retains normal polling behavior", () => {
92
+ const scheduler = new Scheduler();
93
+ const callback = jest.fn();
94
+ scheduler.schedule(2000, callback);
95
+
96
+ now = 1999;
97
+ jest.advanceTimersByTime(1000);
98
+ expect(callback).not.toHaveBeenCalled();
99
+
100
+ now = 2000;
101
+ jest.advanceTimersByTime(1000);
102
+ expect(callback).toHaveBeenCalledTimes(1);
103
+ });
104
+
105
+ test("does not refresh early for a small clock correction", () => {
106
+ const scheduler = new Scheduler();
107
+ const callback = jest.fn();
108
+ const handle = scheduler.schedule(20_000, callback);
109
+
110
+ now = 500;
111
+ windowEvents.dispatch("focus");
112
+
113
+ expect(callback).not.toHaveBeenCalled();
114
+ scheduler.unschedule(handle);
115
+ });
116
+
117
+ test("removes lifecycle listeners after the final callback is removed", () => {
118
+ const scheduler = new Scheduler();
119
+ const handle = scheduler.schedule(20_000, jest.fn());
120
+
121
+ expect(windowEvents.listenerCount("focus")).toBe(1);
122
+ expect(documentEvents.listenerCount("resume")).toBe(1);
123
+ scheduler.unschedule(handle);
124
+ expect(windowEvents.listenerCount("focus")).toBe(0);
125
+ expect(documentEvents.listenerCount("resume")).toBe(0);
126
+ });
127
+ });
128
+
129
+ function restoreGlobal(name: "document" | "window", descriptor: PropertyDescriptor | undefined): void {
130
+ if (descriptor === undefined) {
131
+ Reflect.deleteProperty(globalThis, name);
132
+ } else {
133
+ Object.defineProperty(globalThis, name, descriptor);
134
+ }
135
+ }
@@ -80,6 +80,35 @@ describe("UploadManager AuthManager configuration", () => {
80
80
  await expect(manager.upload({ data: "x" })).rejects.toThrow("provide an API key");
81
81
  expect(uploadApi.beginMultipartUpload).not.toHaveBeenCalled();
82
82
  });
83
+
84
+ test("awaits manager-owned authentication before starting an upload", async () => {
85
+ const configState = state("customer", accountA);
86
+ configState.expiresAt = Date.now();
87
+ let completeRefresh = (): void => {
88
+ throw new Error("Refresh completion callback was not initialized.");
89
+ };
90
+ const refreshPromise = new Promise<void>(resolve => {
91
+ completeRefresh = () => {
92
+ configState.accessToken = "access-token-new";
93
+ configState.expiresAt = Date.now() + 60_000;
94
+ configState.jwt = "jwt-new";
95
+ configState.refreshPromise = undefined;
96
+ resolve();
97
+ };
98
+ });
99
+ configState.authenticationPromise = refreshPromise;
100
+ configState.refreshPromise = refreshPromise;
101
+ setSession(configState);
102
+ const manager = new TestUploadManager({ authConfigId: "customer" });
103
+ const uploadApi = installUploadApiMock(manager);
104
+
105
+ const upload = manager.upload({ data: "x" });
106
+ expect(uploadApi.beginMultipartUpload).not.toHaveBeenCalled();
107
+ completeRefresh();
108
+ await upload;
109
+
110
+ expect(uploadApi.beginMultipartUpload).toHaveBeenCalled();
111
+ });
83
112
  });
84
113
 
85
114
  interface UploadApiMock {
@@ -127,6 +156,7 @@ function installUploadApiMock(manager: TestUploadManager): UploadApiMock {
127
156
  function state(authConfigId: string, accountId: string): AuthSessionConfigState {
128
157
  return {
129
158
  accessToken: "access-token",
159
+ authenticationPromise: Promise.resolve(),
130
160
  config: {
131
161
  accountId,
132
162
  authConfigId,