@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.
- package/dist/browser/cjs/main.js +513 -164
- package/dist/browser/esm/main.mjs +513 -164
- package/dist/node/cjs/main.js +351 -97
- package/dist/node/esm/main.mjs +351 -97
- package/dist/types/private/AuthSessionState.d.ts +2 -0
- package/dist/types/private/Scheduler.d.ts +12 -3
- package/dist/types/private/model/AuthSession.d.ts +4 -0
- package/dist/types/private/model/AuthSessionConfigBase.d.ts +2 -0
- package/dist/types/public/browser/AuthManagerBrowser.d.ts +2 -0
- package/dist/types/public/shared/generated/runtime.d.ts +6 -0
- package/dist/worker/cjs/main.js +351 -97
- package/dist/worker/esm/main.mjs +351 -97
- package/package.json +1 -1
- package/tests/ApiClientAuth.test.ts +101 -2
- package/tests/AuthManagerBrowser.test.ts +109 -0
- package/tests/AuthServiceWorkerRewrite.test.ts +23 -3
- package/tests/Scheduler.test.ts +135 -0
- package/tests/UploadManagerAuth.test.ts +30 -0
|
@@ -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
|
|
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> =>
|
|
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,
|