@bytescale/sdk 3.56.0 → 3.58.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.
@@ -1,14 +1,30 @@
1
1
  import { jest } from "@jest/globals";
2
2
  import { Response as NodeFetchResponse } from "node-fetch";
3
- import type { AuthSwConfigEntryDto, BeginAuthSessionParamsV1, BeginAuthSessionParamsV2 } from "../src/index.browser";
3
+ import { AuthSessionState } from "../src/private/AuthSessionState";
4
+ import type {
5
+ AuthManagerServiceWorkerConfig,
6
+ AuthSwConfigEntryDto,
7
+ BeginAuthSessionParams,
8
+ UrlRewriteRule
9
+ } from "../src/index.browser";
10
+
11
+ type FetchApi = NonNullable<NonNullable<BeginAuthSessionParams["options"]>["fetchApi"]>;
4
12
 
5
13
  interface AuthManagerApi {
6
- beginAuthSession: (params: BeginAuthSessionParamsV1 | BeginAuthSessionParamsV2) => Promise<void>;
14
+ beginAuthSession: (params: BeginAuthSessionParams) => Promise<void>;
7
15
  endAuthSession: () => Promise<void>;
8
16
  isAuthSessionActive: () => boolean;
9
17
  isAuthSessionReady: () => boolean;
10
18
  }
11
19
 
20
+ interface AuthManagerInternals extends AuthManagerApi {
21
+ refreshAccessToken: (
22
+ session: NonNullable<ReturnType<typeof AuthSessionState.getSession>>,
23
+ params: BeginAuthSessionParams
24
+ ) => Promise<void>;
25
+ scheduler: { unschedule: (handle: number) => void };
26
+ }
27
+
12
28
  describe("AuthManager browser service-worker config", () => {
13
29
  const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator");
14
30
  const originalFetch = Object.getOwnPropertyDescriptor(globalThis, "fetch");
@@ -67,168 +83,316 @@ describe("AuthManager browser service-worker config", () => {
67
83
  jest.restoreAllMocks();
68
84
  });
69
85
 
70
- test("V2 applies multiple entries and refreshes before the earliest expiry", async () => {
71
- const firstConfig: AuthSwConfigEntryDto[] = [
72
- {
73
- expires: Date.now() + 21_000,
74
- headers: [{ key: "Authorization", value: "Bearer account-a" }],
75
- sourceUrlPrefixes: ["https://app.example.com/account-a/"],
76
- urlPrefix: "https://upcdn.io/account-a/"
77
- },
86
+ test("retains the existing cookie fallback when no additional config is requested", async () => {
87
+ delete navigatorValue.serviceWorker;
88
+ const fetchApi = createPrimaryFetchApi();
89
+
90
+ await AuthManager.beginAuthSession(createParams(fetchApi));
91
+
92
+ expect((fetchApi.mock.calls[1][0] as string).endsWith("?set-cookie=true")).toBe(true);
93
+ expect(postMessage).not.toHaveBeenCalled();
94
+ expect(AuthManager.isAuthSessionReady()).toBe(true);
95
+ });
96
+
97
+ test("retains the existing primary-only service-worker flow", async () => {
98
+ const fetchApi = createPrimaryFetchApi();
99
+
100
+ await AuthManager.beginAuthSession({
101
+ ...createParams(fetchApi),
102
+ serviceWorkerScript: "/bytescale-auth-sw.js"
103
+ });
104
+
105
+ expect(AuthManager.isAuthSessionReady()).toBe(true);
106
+ expect(postMessage.mock.calls[0][0]).toEqual({
107
+ config: [
108
+ {
109
+ expires: expect.any(Number),
110
+ headers: [{ key: "Authorization", value: "Bearer jwt-a" }],
111
+ urlPrefix: "https://upcdn.io/account-a/"
112
+ }
113
+ ],
114
+ type: "SET_BYTESCALE_AUTH_CONFIG"
115
+ });
116
+ expect(fetchApi.mock.calls.map(([, init]) => init?.method)).toEqual(["GET", "PUT"]);
117
+ });
118
+
119
+ test("merges the primary API/download context with additional download-only contexts", async () => {
120
+ const fetchApi = createPrimaryFetchApi();
121
+ const additionalConfig: AuthSwConfigEntryDto[] = [
78
122
  {
79
123
  expires: Date.now() + 60_000,
80
- headers: [{ key: "Authorization", value: "Bearer account-b" }],
124
+ headers: [{ key: "Authorization", value: "Bearer jwt-b" }],
125
+ sourceUrlPrefixes: ["https://app.example.com/account-b/"],
81
126
  urlPrefix: "https://upcdn.io/account-b/"
127
+ },
128
+ {
129
+ expires: undefined,
130
+ headers: [{ key: "Authorization", value: "Bearer jwt-c" }],
131
+ urlPrefix: "https://upcdn.io/account-c/"
82
132
  }
83
133
  ];
84
- const refreshedConfig: AuthSwConfigEntryDto[] = [
134
+ const urlRewriteRules: UrlRewriteRule[] = [
85
135
  {
86
- expires: undefined,
87
- headers: [{ key: "Authorization", value: "Bearer refreshed" }],
88
- urlPrefix: "https://upcdn.io/account-a/"
136
+ fromUrlPrefix: "https://app.example.com/__authenticated-download/",
137
+ toUrlPrefix: "https://upcdn.io/account-b/"
89
138
  }
90
139
  ];
91
- const getServiceWorkerConfig = jest
92
- .fn<BeginAuthSessionParamsV2["getServiceWorkerConfig"]>()
93
- .mockResolvedValueOnce(firstConfig)
94
- .mockResolvedValueOnce(refreshedConfig);
140
+ const serviceWorkerConfig = jest.fn(
141
+ async (): Promise<AuthManagerServiceWorkerConfig> => ({
142
+ additionalConfig,
143
+ sourceUrlPrefixes: ["https://app.example.com/"],
144
+ urlRewriteRules
145
+ })
146
+ );
95
147
 
96
148
  await AuthManager.beginAuthSession({
97
- getServiceWorkerConfig,
149
+ ...createParams(fetchApi),
150
+ serviceWorkerConfig,
98
151
  serviceWorkerScript: "/bytescale-auth-sw.js"
99
152
  });
100
153
 
101
- expect(AuthManager.isAuthSessionActive()).toBe(true);
102
154
  expect(AuthManager.isAuthSessionReady()).toBe(true);
155
+ expect(serviceWorkerConfig).toHaveBeenCalledTimes(1);
103
156
  expect(postMessage.mock.calls[0][0]).toEqual({
104
157
  config: [
105
158
  {
106
- ...firstConfig[0],
159
+ expires: expect.any(Number),
160
+ headers: [{ key: "Authorization", value: "Bearer jwt-a" }],
161
+ sourceUrlPrefixes: ["https://app.example.com/"],
107
162
  urlPrefix: "!bytescale-source-scoped!https://upcdn.io/account-a/"
108
163
  },
109
- firstConfig[1]
164
+ {
165
+ ...additionalConfig[0],
166
+ urlPrefix: "!bytescale-source-scoped!https://upcdn.io/account-b/"
167
+ },
168
+ additionalConfig[1]
110
169
  ],
111
- type: "SET_BYTESCALE_AUTH_CONFIG"
170
+ type: "SET_BYTESCALE_AUTH_CONFIG",
171
+ urlRewriteRules
112
172
  });
113
- expect(firstConfig[0].urlPrefix).toBe("https://upcdn.io/account-a/");
173
+ expect(additionalConfig[0].urlPrefix).toBe("https://upcdn.io/account-b/");
174
+ expect(AuthSessionState.getSession()?.accessToken).toBe("access-a");
175
+ expect(fetchApi.mock.calls.map(([, init]) => init?.method)).toEqual(["GET", "PUT"]);
176
+
177
+ await AuthManager.endAuthSession();
114
178
 
179
+ expect(fetchApi.mock.calls.map(([, init]) => init?.method)).toEqual(["GET", "PUT", "DELETE"]);
180
+ expect(postMessage.mock.calls[1][0]).toEqual({ config: [], type: "SET_BYTESCALE_AUTH_CONFIG" });
181
+ });
182
+
183
+ test("refreshes additional rules independently while retaining the primary context", async () => {
184
+ const fetchApi = createPrimaryFetchApi();
185
+ const initialAdditionalConfig: AuthSwConfigEntryDto[] = [
186
+ {
187
+ expires: Date.now() + 21_000,
188
+ headers: [{ key: "Authorization", value: "Bearer jwt-b" }],
189
+ urlPrefix: "https://upcdn.io/account-b/"
190
+ }
191
+ ];
192
+ const serviceWorkerConfig = jest
193
+ .fn<() => Promise<AuthManagerServiceWorkerConfig>>()
194
+ .mockResolvedValueOnce({
195
+ additionalConfig: initialAdditionalConfig,
196
+ sourceUrlPrefixes: ["https://app.example.com/initial/"],
197
+ urlRewriteRules: [
198
+ {
199
+ fromUrlPrefix: "https://app.example.com/download/",
200
+ toUrlPrefix: "https://upcdn.io/account-b/"
201
+ }
202
+ ]
203
+ })
204
+ .mockResolvedValueOnce({
205
+ additionalConfig: [],
206
+ sourceUrlPrefixes: ["https://app.example.com/refreshed/"],
207
+ urlRewriteRules: [
208
+ {
209
+ fromUrlPrefix: "https://app.example.com/download/",
210
+ toUrlPrefix: "https://upcdn.io/account-c/"
211
+ }
212
+ ]
213
+ });
214
+
215
+ await AuthManager.beginAuthSession({
216
+ ...createParams(fetchApi),
217
+ serviceWorkerConfig,
218
+ serviceWorkerScript: "/bytescale-auth-sw.js"
219
+ });
115
220
  await new Promise(resolve => setTimeout(resolve, 1_500));
116
221
 
117
- expect(getServiceWorkerConfig).toHaveBeenCalledTimes(2);
222
+ expect(serviceWorkerConfig).toHaveBeenCalledTimes(2);
118
223
  expect(postMessage.mock.calls[1][0]).toEqual({
119
- config: refreshedConfig,
120
- type: "SET_BYTESCALE_AUTH_CONFIG"
224
+ config: [
225
+ {
226
+ expires: expect.any(Number),
227
+ headers: [{ key: "Authorization", value: "Bearer jwt-a" }],
228
+ sourceUrlPrefixes: ["https://app.example.com/refreshed/"],
229
+ urlPrefix: "!bytescale-source-scoped!https://upcdn.io/account-a/"
230
+ }
231
+ ],
232
+ type: "SET_BYTESCALE_AUTH_CONFIG",
233
+ urlRewriteRules: [
234
+ {
235
+ fromUrlPrefix: "https://app.example.com/download/",
236
+ toUrlPrefix: "https://upcdn.io/account-c/"
237
+ }
238
+ ]
121
239
  });
240
+ expect(fetchApi.mock.calls.map(([, init]) => init?.method)).toEqual(["GET", "PUT"]);
241
+ expect(AuthManager.isAuthSessionReady()).toBe(true);
122
242
  });
123
243
 
124
- test("V2 clears service-worker config without calling access-token endpoints", async () => {
244
+ test("retains additional rules when the primary JWT refreshes", async () => {
245
+ const fetchApi = createPrimaryFetchApi();
246
+ const additionalConfig: AuthSwConfigEntryDto[] = [
247
+ {
248
+ expires: undefined,
249
+ headers: [{ key: "Authorization", value: "Bearer jwt-b" }],
250
+ urlPrefix: "https://upcdn.io/account-b/"
251
+ }
252
+ ];
253
+ const serviceWorkerConfig = jest.fn(
254
+ async (): Promise<AuthManagerServiceWorkerConfig> => ({
255
+ additionalConfig,
256
+ urlRewriteRules: [
257
+ {
258
+ fromUrlPrefix: "https://app.example.com/download/",
259
+ toUrlPrefix: "https://upcdn.io/account-b/"
260
+ }
261
+ ]
262
+ })
263
+ );
264
+
125
265
  await AuthManager.beginAuthSession({
126
- getServiceWorkerConfig: async () => [
266
+ ...createParams(fetchApi),
267
+ serviceWorkerConfig,
268
+ serviceWorkerScript: "/bytescale-auth-sw.js"
269
+ });
270
+
271
+ const session = AuthSessionState.getSession();
272
+ if (session?.accessTokenRefreshHandle === undefined) {
273
+ throw new Error("Expected the primary access-token refresh to be scheduled.");
274
+ }
275
+ const authManagerInternals = AuthManager as AuthManagerInternals;
276
+ authManagerInternals.scheduler.unschedule(session.accessTokenRefreshHandle);
277
+ await authManagerInternals.refreshAccessToken(session, session.params);
278
+
279
+ expect(serviceWorkerConfig).toHaveBeenCalledTimes(1);
280
+ expect(fetchApi.mock.calls.map(([, init]) => init?.method)).toEqual(["GET", "PUT", "GET", "PUT"]);
281
+ expect(postMessage.mock.calls[1][0]).toEqual({
282
+ config: [
127
283
  {
128
- expires: undefined,
129
- headers: [{ key: "Authorization", value: "Bearer account-a" }],
284
+ expires: expect.any(Number),
285
+ headers: [{ key: "Authorization", value: "Bearer jwt-a" }],
130
286
  urlPrefix: "https://upcdn.io/account-a/"
131
- }
287
+ },
288
+ additionalConfig[0]
132
289
  ],
133
- serviceWorkerScript: "/bytescale-auth-sw.js"
290
+ type: "SET_BYTESCALE_AUTH_CONFIG",
291
+ urlRewriteRules: [
292
+ {
293
+ fromUrlPrefix: "https://app.example.com/download/",
294
+ toUrlPrefix: "https://upcdn.io/account-b/"
295
+ }
296
+ ]
134
297
  });
135
- await AuthManager.endAuthSession();
136
-
137
- expect(globalFetch).not.toHaveBeenCalled();
138
- expect(postMessage.mock.calls[1][0]).toEqual({ config: [], type: "SET_BYTESCALE_AUTH_CONFIG" });
139
- expect(AuthManager.isAuthSessionActive()).toBe(false);
140
- expect(AuthManager.isAuthSessionReady()).toBe(false);
141
298
  });
142
299
 
143
- test("V2 rejects instead of falling back to cookies when service workers are unavailable", async () => {
144
- delete navigatorValue.serviceWorker;
145
- const getServiceWorkerConfig = jest.fn(async (): Promise<AuthSwConfigEntryDto[]> => []);
300
+ test("rejects malformed URL rewrite rules", async () => {
301
+ jest.spyOn(console, "warn").mockImplementation(() => {});
302
+ const fetchApi = createPrimaryFetchApi();
146
303
 
147
- await expect(
148
- AuthManager.beginAuthSession({ getServiceWorkerConfig, serviceWorkerScript: "/bytescale-auth-sw.js" })
149
- ).rejects.toThrow("requires service workers");
304
+ await AuthManager.beginAuthSession({
305
+ ...createParams(fetchApi),
306
+ serviceWorkerConfig: async (): Promise<AuthManagerServiceWorkerConfig> => ({
307
+ additionalConfig: [],
308
+ urlRewriteRules: [{ fromUrlPrefix: "https://app.example.com/download/" }] as UrlRewriteRule[]
309
+ }),
310
+ serviceWorkerScript: "/bytescale-auth-sw.js"
311
+ });
150
312
 
151
- expect(getServiceWorkerConfig).not.toHaveBeenCalled();
152
- expect(AuthManager.isAuthSessionActive()).toBe(false);
313
+ expect(postMessage).not.toHaveBeenCalled();
314
+ expect(AuthManager.isAuthSessionReady()).toBe(false);
153
315
  });
154
316
 
155
- test("V1 retains cookie fallback when service workers are unavailable", async () => {
156
- delete navigatorValue.serviceWorker;
157
- const fetchApi = jest.fn(async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
158
- switch (init?.method) {
159
- case "GET":
160
- return new NodeFetchResponse("jwt-a", {
161
- headers: { "Content-Type": "text/plain" }
162
- }) as unknown as Response;
163
- case "PUT":
164
- return new NodeFetchResponse(
165
- JSON.stringify({ accessToken: "access-a", ttlSeconds: 3600 })
166
- ) as unknown as Response;
167
- case "DELETE":
168
- return new NodeFetchResponse(null, { status: 204 }) as unknown as Response;
169
- default:
170
- throw new Error(`Unexpected method: ${init?.method ?? "undefined"}`);
171
- }
172
- });
317
+ test("fails closed until the initial service-worker config callback succeeds", async () => {
318
+ jest.spyOn(console, "warn").mockImplementation(() => {});
319
+ const fetchApi = createPrimaryFetchApi();
173
320
 
174
321
  await AuthManager.beginAuthSession({
175
- accountId: "account-a",
176
- authHeaders: async (): Promise<Record<string, string>> => ({}),
177
- authUrl: "https://app.example.com/auth",
178
- options: { fetchApi },
322
+ ...createParams(fetchApi),
323
+ serviceWorkerConfig: async () => null as unknown as AuthManagerServiceWorkerConfig,
179
324
  serviceWorkerScript: "/bytescale-auth-sw.js"
180
325
  });
181
326
 
182
- expect((fetchApi.mock.calls[1][0] as string).endsWith("?set-cookie=true")).toBe(true);
327
+ expect(fetchApi.mock.calls.map(([, init]) => init?.method)).toEqual(["GET", "PUT"]);
183
328
  expect(postMessage).not.toHaveBeenCalled();
184
- expect(AuthManager.isAuthSessionReady()).toBe(true);
329
+ expect(AuthManager.isAuthSessionReady()).toBe(false);
185
330
  });
186
331
 
187
- test("V1 retains the existing JWT, access-token, and single-entry service-worker flow", async () => {
188
- const fetchApi = jest.fn(async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
189
- switch (init?.method) {
190
- case "GET":
191
- return new NodeFetchResponse("jwt-a", {
192
- headers: { "Content-Type": "text/plain" }
193
- }) as unknown as Response;
194
- case "PUT":
195
- return new NodeFetchResponse(
196
- JSON.stringify({ accessToken: "access-a", ttlSeconds: 3600 })
197
- ) as unknown as Response;
198
- case "DELETE":
199
- return new NodeFetchResponse(null, { status: 204 }) as unknown as Response;
200
- default:
201
- throw new Error(`Unexpected method: ${init?.method ?? "undefined"}`);
202
- }
203
- });
204
- const params: BeginAuthSessionParamsV1 = {
205
- accountId: "account-a",
206
- authHeaders: async (): Promise<Record<string, string>> => ({ "X-App-Authorization": "app-token" }),
207
- authUrl: "https://app.example.com/auth",
208
- options: { fetchApi },
209
- serviceWorkerScript: "/bytescale-auth-sw.js",
210
- sourceUrlPrefixes: ["https://app.example.com/"]
211
- };
332
+ test("requires a service-worker script for additional configuration", async () => {
333
+ const fetchApi = createPrimaryFetchApi();
334
+ const serviceWorkerConfig = jest.fn(
335
+ async (): Promise<AuthManagerServiceWorkerConfig> => ({
336
+ additionalConfig: []
337
+ })
338
+ );
212
339
 
213
- await AuthManager.beginAuthSession(params);
340
+ await expect(AuthManager.beginAuthSession({ ...createParams(fetchApi), serviceWorkerConfig })).rejects.toThrow(
341
+ "'serviceWorkerScript' field is required"
342
+ );
214
343
 
215
- expect(AuthManager.isAuthSessionReady()).toBe(true);
216
- expect(postMessage.mock.calls[0][0]).toEqual({
217
- config: [
218
- {
219
- expires: expect.any(Number),
220
- headers: [{ key: "Authorization", value: "Bearer jwt-a" }],
221
- sourceUrlPrefixes: params.sourceUrlPrefixes,
222
- urlPrefix: "!bytescale-source-scoped!https://upcdn.io/account-a/"
223
- }
224
- ],
225
- type: "SET_BYTESCALE_AUTH_CONFIG"
226
- });
227
- expect(fetchApi.mock.calls.map(([, init]) => init?.method)).toEqual(["GET", "PUT"]);
344
+ expect(serviceWorkerConfig).not.toHaveBeenCalled();
345
+ expect(fetchApi).not.toHaveBeenCalled();
346
+ expect(AuthManager.isAuthSessionActive()).toBe(false);
347
+ });
228
348
 
229
- await AuthManager.endAuthSession();
349
+ test("rejects additional configuration when service workers are unavailable", async () => {
350
+ delete navigatorValue.serviceWorker;
351
+ const fetchApi = createPrimaryFetchApi();
352
+ const serviceWorkerConfig = jest.fn(
353
+ async (): Promise<AuthManagerServiceWorkerConfig> => ({
354
+ additionalConfig: []
355
+ })
356
+ );
230
357
 
231
- expect(fetchApi.mock.calls.map(([, init]) => init?.method)).toEqual(["GET", "PUT", "DELETE"]);
232
- expect(postMessage.mock.calls[1][0]).toEqual({ config: [], type: "SET_BYTESCALE_AUTH_CONFIG" });
358
+ await expect(
359
+ AuthManager.beginAuthSession({
360
+ ...createParams(fetchApi),
361
+ serviceWorkerConfig,
362
+ serviceWorkerScript: "/bytescale-auth-sw.js"
363
+ })
364
+ ).rejects.toThrow("requires service workers");
365
+
366
+ expect(serviceWorkerConfig).not.toHaveBeenCalled();
367
+ expect(fetchApi).not.toHaveBeenCalled();
368
+ expect(AuthManager.isAuthSessionActive()).toBe(false);
233
369
  });
234
370
  });
371
+
372
+ function createParams(fetchApi: FetchApi): BeginAuthSessionParams {
373
+ return {
374
+ accountId: "account-a",
375
+ authHeaders: async (): Promise<Record<string, string>> => ({ "X-App-Authorization": "app-token" }),
376
+ authUrl: "https://app.example.com/auth",
377
+ options: { fetchApi }
378
+ };
379
+ }
380
+
381
+ function createPrimaryFetchApi(): jest.MockedFunction<FetchApi> {
382
+ return jest.fn<FetchApi>(async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
383
+ switch (init?.method) {
384
+ case "GET":
385
+ return new NodeFetchResponse("jwt-a", {
386
+ headers: { "Content-Type": "text/plain" }
387
+ }) as unknown as Response;
388
+ case "PUT":
389
+ return new NodeFetchResponse(
390
+ JSON.stringify({ accessToken: "access-a", ttlSeconds: 3600 })
391
+ ) as unknown as Response;
392
+ case "DELETE":
393
+ return new NodeFetchResponse(null, { status: 204 }) as unknown as Response;
394
+ default:
395
+ throw new Error(`Unexpected method: ${init?.method ?? "undefined"}`);
396
+ }
397
+ });
398
+ }