@bytescale/sdk 3.60.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,
@@ -308,6 +308,53 @@ describe("AuthManager browser multi-configuration sessions", () => {
308
308
  expect(AuthManager.isAuthSessionReady()).toBe(true);
309
309
  });
310
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
+
311
358
  test("clears the cookie-enabled config and the complete worker config on end", async () => {
312
359
  const fetchApi = createFetchApi();
313
360
  await AuthManager.beginAuthSession({
@@ -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,