@bytescale/sdk 3.55.0 → 3.57.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.
@@ -0,0 +1,342 @@
1
+ import { jest } from "@jest/globals";
2
+ import { Response as NodeFetchResponse } from "node-fetch";
3
+ import { AuthSessionState } from "../src/private/AuthSessionState";
4
+ import type {
5
+ AuthManagerServiceWorkerConfig,
6
+ AuthSwConfigEntryDto,
7
+ BeginAuthSessionParams
8
+ } from "../src/index.browser";
9
+
10
+ type FetchApi = NonNullable<NonNullable<BeginAuthSessionParams["options"]>["fetchApi"]>;
11
+
12
+ interface AuthManagerApi {
13
+ beginAuthSession: (params: BeginAuthSessionParams) => Promise<void>;
14
+ endAuthSession: () => Promise<void>;
15
+ isAuthSessionActive: () => boolean;
16
+ isAuthSessionReady: () => boolean;
17
+ }
18
+
19
+ interface AuthManagerInternals extends AuthManagerApi {
20
+ refreshAccessToken: (
21
+ session: NonNullable<ReturnType<typeof AuthSessionState.getSession>>,
22
+ params: BeginAuthSessionParams
23
+ ) => Promise<void>;
24
+ scheduler: { unschedule: (handle: number) => void };
25
+ }
26
+
27
+ describe("AuthManager browser service-worker config", () => {
28
+ const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator");
29
+ const originalFetch = Object.getOwnPropertyDescriptor(globalThis, "fetch");
30
+ const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
31
+ const globalFetch = jest.fn(async (): Promise<Response> => {
32
+ throw new Error("This test must not use the global fetch API.");
33
+ });
34
+ const postMessage = jest.fn((_message: unknown): void => {});
35
+ const activeWorker = { postMessage, state: "activated" } as unknown as ServiceWorker;
36
+ const registration = {
37
+ active: activeWorker,
38
+ installing: null,
39
+ scope: "https://app.example.com/",
40
+ waiting: null
41
+ } as unknown as ServiceWorkerRegistration;
42
+ const serviceWorkerApi = {
43
+ controller: activeWorker,
44
+ getRegistrations: jest.fn(async (): Promise<ServiceWorkerRegistration[]> => [registration]),
45
+ register: jest.fn(async (): Promise<ServiceWorkerRegistration> => registration)
46
+ };
47
+ const navigatorValue: { serviceWorker?: typeof serviceWorkerApi } = { serviceWorker: serviceWorkerApi };
48
+ let AuthManager: AuthManagerApi;
49
+
50
+ beforeAll(async () => {
51
+ Object.defineProperty(globalThis, "fetch", { configurable: true, value: globalFetch });
52
+ Object.defineProperty(globalThis, "window", { configurable: true, value: {} });
53
+ Object.defineProperty(globalThis, "navigator", { configurable: true, value: navigatorValue });
54
+ AuthManager = (await import("../src/public/browser/AuthManagerBrowser")).AuthManager;
55
+ });
56
+
57
+ beforeEach(() => {
58
+ navigatorValue.serviceWorker = serviceWorkerApi;
59
+ globalFetch.mockClear();
60
+ postMessage.mockClear();
61
+ serviceWorkerApi.getRegistrations.mockClear();
62
+ serviceWorkerApi.register.mockClear();
63
+ });
64
+
65
+ afterAll(() => {
66
+ for (const [key, descriptor] of [
67
+ ["fetch", originalFetch],
68
+ ["navigator", originalNavigator],
69
+ ["window", originalWindow]
70
+ ] as const) {
71
+ if (descriptor === undefined) {
72
+ Reflect.deleteProperty(globalThis, key);
73
+ } else {
74
+ Object.defineProperty(globalThis, key, descriptor);
75
+ }
76
+ }
77
+ });
78
+
79
+ afterEach(async () => {
80
+ navigatorValue.serviceWorker = serviceWorkerApi;
81
+ await AuthManager.endAuthSession();
82
+ jest.restoreAllMocks();
83
+ });
84
+
85
+ test("retains the existing cookie fallback when no additional config is requested", async () => {
86
+ delete navigatorValue.serviceWorker;
87
+ const fetchApi = createPrimaryFetchApi();
88
+
89
+ await AuthManager.beginAuthSession(createParams(fetchApi));
90
+
91
+ expect((fetchApi.mock.calls[1][0] as string).endsWith("?set-cookie=true")).toBe(true);
92
+ expect(postMessage).not.toHaveBeenCalled();
93
+ expect(AuthManager.isAuthSessionReady()).toBe(true);
94
+ });
95
+
96
+ test("retains the existing primary-only service-worker flow", async () => {
97
+ const fetchApi = createPrimaryFetchApi();
98
+
99
+ await AuthManager.beginAuthSession({
100
+ ...createParams(fetchApi),
101
+ serviceWorkerScript: "/bytescale-auth-sw.js"
102
+ });
103
+
104
+ expect(AuthManager.isAuthSessionReady()).toBe(true);
105
+ expect(postMessage.mock.calls[0][0]).toEqual({
106
+ config: [
107
+ {
108
+ expires: expect.any(Number),
109
+ headers: [{ key: "Authorization", value: "Bearer jwt-a" }],
110
+ urlPrefix: "https://upcdn.io/account-a/"
111
+ }
112
+ ],
113
+ type: "SET_BYTESCALE_AUTH_CONFIG"
114
+ });
115
+ expect(fetchApi.mock.calls.map(([, init]) => init?.method)).toEqual(["GET", "PUT"]);
116
+ });
117
+
118
+ test("merges the primary API/download context with additional download-only contexts", async () => {
119
+ const fetchApi = createPrimaryFetchApi();
120
+ const additionalConfig: AuthSwConfigEntryDto[] = [
121
+ {
122
+ expires: Date.now() + 60_000,
123
+ headers: [{ key: "Authorization", value: "Bearer jwt-b" }],
124
+ sourceUrlPrefixes: ["https://app.example.com/account-b/"],
125
+ urlPrefix: "https://upcdn.io/account-b/"
126
+ },
127
+ {
128
+ expires: undefined,
129
+ headers: [{ key: "Authorization", value: "Bearer jwt-c" }],
130
+ urlPrefix: "https://upcdn.io/account-c/"
131
+ }
132
+ ];
133
+ const serviceWorkerConfig = jest.fn(
134
+ async (): Promise<AuthManagerServiceWorkerConfig> => ({
135
+ additionalConfig,
136
+ sourceUrlPrefixes: ["https://app.example.com/"]
137
+ })
138
+ );
139
+
140
+ await AuthManager.beginAuthSession({
141
+ ...createParams(fetchApi),
142
+ serviceWorkerConfig,
143
+ serviceWorkerScript: "/bytescale-auth-sw.js"
144
+ });
145
+
146
+ expect(AuthManager.isAuthSessionReady()).toBe(true);
147
+ expect(serviceWorkerConfig).toHaveBeenCalledTimes(1);
148
+ expect(postMessage.mock.calls[0][0]).toEqual({
149
+ config: [
150
+ {
151
+ expires: expect.any(Number),
152
+ headers: [{ key: "Authorization", value: "Bearer jwt-a" }],
153
+ sourceUrlPrefixes: ["https://app.example.com/"],
154
+ urlPrefix: "!bytescale-source-scoped!https://upcdn.io/account-a/"
155
+ },
156
+ {
157
+ ...additionalConfig[0],
158
+ urlPrefix: "!bytescale-source-scoped!https://upcdn.io/account-b/"
159
+ },
160
+ additionalConfig[1]
161
+ ],
162
+ type: "SET_BYTESCALE_AUTH_CONFIG"
163
+ });
164
+ expect(additionalConfig[0].urlPrefix).toBe("https://upcdn.io/account-b/");
165
+ expect(AuthSessionState.getSession()?.accessToken).toBe("access-a");
166
+ expect(fetchApi.mock.calls.map(([, init]) => init?.method)).toEqual(["GET", "PUT"]);
167
+
168
+ await AuthManager.endAuthSession();
169
+
170
+ expect(fetchApi.mock.calls.map(([, init]) => init?.method)).toEqual(["GET", "PUT", "DELETE"]);
171
+ expect(postMessage.mock.calls[1][0]).toEqual({ config: [], type: "SET_BYTESCALE_AUTH_CONFIG" });
172
+ });
173
+
174
+ test("refreshes additional rules independently while retaining the primary context", async () => {
175
+ const fetchApi = createPrimaryFetchApi();
176
+ const initialAdditionalConfig: AuthSwConfigEntryDto[] = [
177
+ {
178
+ expires: Date.now() + 21_000,
179
+ headers: [{ key: "Authorization", value: "Bearer jwt-b" }],
180
+ urlPrefix: "https://upcdn.io/account-b/"
181
+ }
182
+ ];
183
+ const serviceWorkerConfig = jest
184
+ .fn<() => Promise<AuthManagerServiceWorkerConfig>>()
185
+ .mockResolvedValueOnce({
186
+ additionalConfig: initialAdditionalConfig,
187
+ sourceUrlPrefixes: ["https://app.example.com/initial/"]
188
+ })
189
+ .mockResolvedValueOnce({
190
+ additionalConfig: [],
191
+ sourceUrlPrefixes: ["https://app.example.com/refreshed/"]
192
+ });
193
+
194
+ await AuthManager.beginAuthSession({
195
+ ...createParams(fetchApi),
196
+ serviceWorkerConfig,
197
+ serviceWorkerScript: "/bytescale-auth-sw.js"
198
+ });
199
+ await new Promise(resolve => setTimeout(resolve, 1_500));
200
+
201
+ expect(serviceWorkerConfig).toHaveBeenCalledTimes(2);
202
+ expect(postMessage.mock.calls[1][0]).toEqual({
203
+ config: [
204
+ {
205
+ expires: expect.any(Number),
206
+ headers: [{ key: "Authorization", value: "Bearer jwt-a" }],
207
+ sourceUrlPrefixes: ["https://app.example.com/refreshed/"],
208
+ urlPrefix: "!bytescale-source-scoped!https://upcdn.io/account-a/"
209
+ }
210
+ ],
211
+ type: "SET_BYTESCALE_AUTH_CONFIG"
212
+ });
213
+ expect(fetchApi.mock.calls.map(([, init]) => init?.method)).toEqual(["GET", "PUT"]);
214
+ expect(AuthManager.isAuthSessionReady()).toBe(true);
215
+ });
216
+
217
+ test("retains additional rules when the primary JWT refreshes", async () => {
218
+ const fetchApi = createPrimaryFetchApi();
219
+ const additionalConfig: AuthSwConfigEntryDto[] = [
220
+ {
221
+ expires: undefined,
222
+ headers: [{ key: "Authorization", value: "Bearer jwt-b" }],
223
+ urlPrefix: "https://upcdn.io/account-b/"
224
+ }
225
+ ];
226
+ const serviceWorkerConfig = jest.fn(
227
+ async (): Promise<AuthManagerServiceWorkerConfig> => ({
228
+ additionalConfig
229
+ })
230
+ );
231
+
232
+ await AuthManager.beginAuthSession({
233
+ ...createParams(fetchApi),
234
+ serviceWorkerConfig,
235
+ serviceWorkerScript: "/bytescale-auth-sw.js"
236
+ });
237
+
238
+ const session = AuthSessionState.getSession();
239
+ if (session?.accessTokenRefreshHandle === undefined) {
240
+ throw new Error("Expected the primary access-token refresh to be scheduled.");
241
+ }
242
+ const authManagerInternals = AuthManager as AuthManagerInternals;
243
+ authManagerInternals.scheduler.unschedule(session.accessTokenRefreshHandle);
244
+ await authManagerInternals.refreshAccessToken(session, session.params);
245
+
246
+ expect(serviceWorkerConfig).toHaveBeenCalledTimes(1);
247
+ expect(fetchApi.mock.calls.map(([, init]) => init?.method)).toEqual(["GET", "PUT", "GET", "PUT"]);
248
+ expect(postMessage.mock.calls[1][0]).toEqual({
249
+ config: [
250
+ {
251
+ expires: expect.any(Number),
252
+ headers: [{ key: "Authorization", value: "Bearer jwt-a" }],
253
+ urlPrefix: "https://upcdn.io/account-a/"
254
+ },
255
+ additionalConfig[0]
256
+ ],
257
+ type: "SET_BYTESCALE_AUTH_CONFIG"
258
+ });
259
+ });
260
+
261
+ test("fails closed until the initial service-worker config callback succeeds", async () => {
262
+ jest.spyOn(console, "warn").mockImplementation(() => {});
263
+ const fetchApi = createPrimaryFetchApi();
264
+
265
+ await AuthManager.beginAuthSession({
266
+ ...createParams(fetchApi),
267
+ serviceWorkerConfig: async () => null as unknown as AuthManagerServiceWorkerConfig,
268
+ serviceWorkerScript: "/bytescale-auth-sw.js"
269
+ });
270
+
271
+ expect(fetchApi.mock.calls.map(([, init]) => init?.method)).toEqual(["GET", "PUT"]);
272
+ expect(postMessage).not.toHaveBeenCalled();
273
+ expect(AuthManager.isAuthSessionReady()).toBe(false);
274
+ });
275
+
276
+ test("requires a service-worker script for additional configuration", async () => {
277
+ const fetchApi = createPrimaryFetchApi();
278
+ const serviceWorkerConfig = jest.fn(
279
+ async (): Promise<AuthManagerServiceWorkerConfig> => ({
280
+ additionalConfig: []
281
+ })
282
+ );
283
+
284
+ await expect(AuthManager.beginAuthSession({ ...createParams(fetchApi), serviceWorkerConfig })).rejects.toThrow(
285
+ "'serviceWorkerScript' field is required"
286
+ );
287
+
288
+ expect(serviceWorkerConfig).not.toHaveBeenCalled();
289
+ expect(fetchApi).not.toHaveBeenCalled();
290
+ expect(AuthManager.isAuthSessionActive()).toBe(false);
291
+ });
292
+
293
+ test("rejects additional configuration when service workers are unavailable", async () => {
294
+ delete navigatorValue.serviceWorker;
295
+ const fetchApi = createPrimaryFetchApi();
296
+ const serviceWorkerConfig = jest.fn(
297
+ async (): Promise<AuthManagerServiceWorkerConfig> => ({
298
+ additionalConfig: []
299
+ })
300
+ );
301
+
302
+ await expect(
303
+ AuthManager.beginAuthSession({
304
+ ...createParams(fetchApi),
305
+ serviceWorkerConfig,
306
+ serviceWorkerScript: "/bytescale-auth-sw.js"
307
+ })
308
+ ).rejects.toThrow("requires service workers");
309
+
310
+ expect(serviceWorkerConfig).not.toHaveBeenCalled();
311
+ expect(fetchApi).not.toHaveBeenCalled();
312
+ expect(AuthManager.isAuthSessionActive()).toBe(false);
313
+ });
314
+ });
315
+
316
+ function createParams(fetchApi: FetchApi): BeginAuthSessionParams {
317
+ return {
318
+ accountId: "account-a",
319
+ authHeaders: async (): Promise<Record<string, string>> => ({ "X-App-Authorization": "app-token" }),
320
+ authUrl: "https://app.example.com/auth",
321
+ options: { fetchApi }
322
+ };
323
+ }
324
+
325
+ function createPrimaryFetchApi(): jest.MockedFunction<FetchApi> {
326
+ return jest.fn<FetchApi>(async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
327
+ switch (init?.method) {
328
+ case "GET":
329
+ return new NodeFetchResponse("jwt-a", {
330
+ headers: { "Content-Type": "text/plain" }
331
+ }) as unknown as Response;
332
+ case "PUT":
333
+ return new NodeFetchResponse(
334
+ JSON.stringify({ accessToken: "access-a", ttlSeconds: 3600 })
335
+ ) as unknown as Response;
336
+ case "DELETE":
337
+ return new NodeFetchResponse(null, { status: 204 }) as unknown as Response;
338
+ default:
339
+ throw new Error(`Unexpected method: ${init?.method ?? "undefined"}`);
340
+ }
341
+ });
342
+ }