@tstdl/base 0.93.90 → 0.93.91
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/authentication/client/authentication.service.js +38 -19
- package/authentication/models/subject.model.js +1 -1
- package/authentication/tests/authentication.client-service-methods.test.d.ts +1 -0
- package/authentication/tests/authentication.client-service-methods.test.js +131 -0
- package/authentication/tests/authentication.client-service-refresh.test.d.ts +1 -0
- package/authentication/tests/authentication.client-service-refresh.test.js +124 -0
- package/http/server/http-server.d.ts +8 -2
- package/http/server/node/node-http-server.d.ts +2 -2
- package/http/server/node/node-http-server.js +2 -2
- package/module/modules/web-server.module.d.ts +1 -1
- package/module/modules/web-server.module.js +1 -1
- package/package.json +1 -1
- package/unit-test/integration-setup.js +3 -3
|
@@ -9,7 +9,6 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
|
|
9
9
|
};
|
|
10
10
|
import { Subject, filter, firstValueFrom, race, timer } from 'rxjs';
|
|
11
11
|
import { CancellationSignal, CancellationToken } from '../../cancellation/token.js';
|
|
12
|
-
import { isNode } from '../../environment.js';
|
|
13
12
|
import { BadRequestError } from '../../errors/bad-request.error.js';
|
|
14
13
|
import { ForbiddenError } from '../../errors/forbidden.error.js';
|
|
15
14
|
import { InvalidTokenError } from '../../errors/invalid-token.error.js';
|
|
@@ -24,7 +23,7 @@ import { computed, signal, toObservable } from '../../signals/api.js';
|
|
|
24
23
|
import { currentTimestampSeconds } from '../../utils/date-time.js';
|
|
25
24
|
import { formatError } from '../../utils/format-error.js';
|
|
26
25
|
import { timeout } from '../../utils/timing.js';
|
|
27
|
-
import { assertDefinedPass, isDefined, isNullOrUndefined, isUndefined } from '../../utils/type-guards.js';
|
|
26
|
+
import { assertDefinedPass, isDefined, isNotFunction, isNullOrUndefined, isUndefined } from '../../utils/type-guards.js';
|
|
28
27
|
import { millisecondsPerSecond } from '../../utils/units.js';
|
|
29
28
|
import { AUTHENTICATION_API_CLIENT, INITIAL_AUTHENTICATION_DATA } from './tokens.js';
|
|
30
29
|
const tokenStorageKey = 'AuthenticationService:token';
|
|
@@ -47,7 +46,6 @@ const unrecoverableErrors = [
|
|
|
47
46
|
NotSupportedError,
|
|
48
47
|
UnauthorizedError,
|
|
49
48
|
];
|
|
50
|
-
const localStorage = isNode ? undefined : globalThis.localStorage;
|
|
51
49
|
/**
|
|
52
50
|
* Handles authentication on client side.
|
|
53
51
|
*
|
|
@@ -380,39 +378,52 @@ let AuthenticationClientService = class AuthenticationClientService {
|
|
|
380
378
|
try {
|
|
381
379
|
const token = this.token();
|
|
382
380
|
if (isUndefined(token)) {
|
|
383
|
-
// Wait for login
|
|
384
|
-
|
|
381
|
+
// Wait for login or dispose.
|
|
382
|
+
// We ignore forceRefreshToken here because we can't refresh without a token.
|
|
383
|
+
await firstValueFrom(race([this.definedToken$, this.disposeToken]));
|
|
385
384
|
continue;
|
|
386
385
|
}
|
|
387
386
|
const now = this.estimatedServerTimestampSeconds();
|
|
388
|
-
const
|
|
387
|
+
const forceRefresh = this.forceRefreshToken.isSet;
|
|
388
|
+
const needsRefresh = forceRefresh || (now >= (token.exp - refreshBufferSeconds));
|
|
389
389
|
if (needsRefresh) {
|
|
390
|
-
|
|
391
|
-
// Using tryUse(undefined, ...) ensures we try once and don't block if another instance is already refreshing.
|
|
390
|
+
let lockAcquired = false;
|
|
392
391
|
await this.lock.tryUse(undefined, async () => {
|
|
393
|
-
|
|
392
|
+
lockAcquired = true;
|
|
394
393
|
const currentToken = this.token();
|
|
395
394
|
const currentNow = this.estimatedServerTimestampSeconds();
|
|
396
395
|
const stillNeedsRefresh = isDefined(currentToken) && (this.forceRefreshToken.isSet || (currentNow >= (currentToken.exp - refreshBufferSeconds)));
|
|
397
396
|
if (stillNeedsRefresh) {
|
|
398
|
-
this.forceRefreshToken.unset();
|
|
399
397
|
await this.refresh();
|
|
398
|
+
this.forceRefreshToken.unset();
|
|
400
399
|
}
|
|
401
400
|
});
|
|
401
|
+
if (!lockAcquired) {
|
|
402
|
+
// Lock held by another instance, wait 5 seconds or until state/token changes.
|
|
403
|
+
// We ignore forceRefreshToken here to avoid a busy loop if it is already set.
|
|
404
|
+
await firstValueFrom(race([timer(5000), this.disposeToken, this.token$.pipe(filter((t) => t !== token))]));
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
402
407
|
}
|
|
403
408
|
const delay = ((this.token()?.exp ?? 0) - this.estimatedServerTimestampSeconds() - refreshBufferSeconds) * millisecondsPerSecond;
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
409
|
+
const wakeUpSignals = [
|
|
410
|
+
this.disposeToken,
|
|
411
|
+
this.token$.pipe(filter((t) => t != token)),
|
|
412
|
+
];
|
|
413
|
+
if (!forceRefresh) {
|
|
414
|
+
wakeUpSignals.push(this.forceRefreshToken);
|
|
415
|
+
}
|
|
416
|
+
if (delay > 0) {
|
|
417
|
+
await firstValueFrom(race([timer(delay), ...wakeUpSignals]));
|
|
408
418
|
}
|
|
409
419
|
else {
|
|
410
|
-
|
|
420
|
+
// If expired (or within buffer) and we didn't refresh (e.g. refresh failed or lock contention), wait a bit to avoid tight loop
|
|
421
|
+
await firstValueFrom(race([timer(5000), ...wakeUpSignals]));
|
|
411
422
|
}
|
|
412
423
|
}
|
|
413
424
|
catch (error) {
|
|
414
425
|
this.logger.error(error);
|
|
415
|
-
await firstValueFrom(race([timer(5000), this.disposeToken, this.
|
|
426
|
+
await firstValueFrom(race([timer(5000), this.disposeToken, this.token$.pipe(filter((t) => t !== this.token()))]));
|
|
416
427
|
}
|
|
417
428
|
}
|
|
418
429
|
}
|
|
@@ -454,7 +465,11 @@ let AuthenticationClientService = class AuthenticationClientService {
|
|
|
454
465
|
}
|
|
455
466
|
readFromStorage(key) {
|
|
456
467
|
try {
|
|
457
|
-
const
|
|
468
|
+
const storage = globalThis.localStorage;
|
|
469
|
+
if (isUndefined(storage) || (isNotFunction(storage.getItem))) {
|
|
470
|
+
return undefined;
|
|
471
|
+
}
|
|
472
|
+
const serialized = storage.getItem(key);
|
|
458
473
|
if (isNullOrUndefined(serialized)) {
|
|
459
474
|
return undefined;
|
|
460
475
|
}
|
|
@@ -467,12 +482,16 @@ let AuthenticationClientService = class AuthenticationClientService {
|
|
|
467
482
|
}
|
|
468
483
|
writeToStorage(key, value) {
|
|
469
484
|
try {
|
|
485
|
+
const storage = globalThis.localStorage;
|
|
486
|
+
if (isUndefined(storage) || (isNotFunction(storage.setItem)) || (isNotFunction(storage.removeItem))) {
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
470
489
|
if (isUndefined(value)) {
|
|
471
|
-
|
|
490
|
+
storage.removeItem(key);
|
|
472
491
|
}
|
|
473
492
|
else {
|
|
474
493
|
const serialized = JSON.stringify(value);
|
|
475
|
-
|
|
494
|
+
storage.setItem(key, serialized);
|
|
476
495
|
}
|
|
477
496
|
}
|
|
478
497
|
catch (error) {
|
|
@@ -7,13 +7,13 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
7
7
|
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
8
8
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
9
|
};
|
|
10
|
+
import { match } from 'ts-pattern';
|
|
10
11
|
import { defineEnum } from '../../enumeration/enumeration.js';
|
|
11
12
|
import { formatPersonName } from '../../formats/formats.js';
|
|
12
13
|
import { TenantEntity } from '../../orm/entity.js';
|
|
13
14
|
import { Inheritance, Table, Unique } from '../../orm/index.js';
|
|
14
15
|
import { TimestampProperty } from '../../orm/schemas/timestamp.js';
|
|
15
16
|
import { Enumeration } from '../../schema/index.js';
|
|
16
|
-
import { match } from 'ts-pattern';
|
|
17
17
|
export const SubjectType = defineEnum('SubjectType', {
|
|
18
18
|
System: 'system',
|
|
19
19
|
User: 'user',
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { Subject } from 'rxjs';
|
|
3
|
+
import { AuthenticationClientService } from '../../authentication/client/authentication.service.js';
|
|
4
|
+
import { AUTHENTICATION_API_CLIENT } from '../../authentication/client/tokens.js';
|
|
5
|
+
import { Lock } from '../../lock/index.js';
|
|
6
|
+
import { Logger } from '../../logger/index.js';
|
|
7
|
+
import { MessageBus } from '../../message-bus/index.js';
|
|
8
|
+
import { Injector } from '../../injector/index.js';
|
|
9
|
+
import { CancellationSignal, CancellationToken } from '../../cancellation/token.js';
|
|
10
|
+
import { configureDefaultSignalsImplementation } from '../../signals/implementation/configure.js';
|
|
11
|
+
describe('AuthenticationClientService Methods', () => {
|
|
12
|
+
let injector;
|
|
13
|
+
let service;
|
|
14
|
+
let mockApiClient;
|
|
15
|
+
let mockLock;
|
|
16
|
+
let mockMessageBus;
|
|
17
|
+
let mockLogger;
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
const storage = new Map();
|
|
20
|
+
globalThis.localStorage = {
|
|
21
|
+
getItem: vi.fn((key) => storage.get(key) ?? null),
|
|
22
|
+
setItem: vi.fn((key, value) => storage.set(key, value)),
|
|
23
|
+
removeItem: vi.fn((key) => storage.delete(key)),
|
|
24
|
+
clear: vi.fn(() => storage.clear()),
|
|
25
|
+
};
|
|
26
|
+
configureDefaultSignalsImplementation();
|
|
27
|
+
injector = new Injector('TestInjector');
|
|
28
|
+
mockApiClient = {
|
|
29
|
+
login: vi.fn(),
|
|
30
|
+
refresh: vi.fn(),
|
|
31
|
+
impersonate: vi.fn(),
|
|
32
|
+
unimpersonate: vi.fn(),
|
|
33
|
+
changeSecret: vi.fn(),
|
|
34
|
+
initSecretReset: vi.fn(),
|
|
35
|
+
resetSecret: vi.fn(),
|
|
36
|
+
checkSecret: vi.fn(),
|
|
37
|
+
timestamp: vi.fn().mockResolvedValue(Math.floor(Date.now() / 1000)),
|
|
38
|
+
endSession: vi.fn().mockResolvedValue(undefined),
|
|
39
|
+
};
|
|
40
|
+
mockLock = {
|
|
41
|
+
tryUse: vi.fn(async (_timeout, callback) => {
|
|
42
|
+
const result = await callback({ lost: false });
|
|
43
|
+
return { success: true, result };
|
|
44
|
+
}),
|
|
45
|
+
use: vi.fn(async (_timeout, callback) => {
|
|
46
|
+
return await callback({ lost: false });
|
|
47
|
+
}),
|
|
48
|
+
};
|
|
49
|
+
mockMessageBus = {
|
|
50
|
+
publishAndForget: vi.fn(),
|
|
51
|
+
messages$: new Subject(),
|
|
52
|
+
dispose: vi.fn(),
|
|
53
|
+
};
|
|
54
|
+
mockLogger = {
|
|
55
|
+
error: vi.fn(),
|
|
56
|
+
warn: vi.fn(),
|
|
57
|
+
info: vi.fn(),
|
|
58
|
+
debug: vi.fn(),
|
|
59
|
+
};
|
|
60
|
+
injector.register(AUTHENTICATION_API_CLIENT, { useValue: mockApiClient });
|
|
61
|
+
injector.register(Lock, { useValue: mockLock });
|
|
62
|
+
injector.register(MessageBus, { useValue: mockMessageBus });
|
|
63
|
+
injector.register(Logger, { useValue: mockLogger });
|
|
64
|
+
const disposeToken = new CancellationToken();
|
|
65
|
+
injector.register(CancellationSignal, { useValue: disposeToken.signal });
|
|
66
|
+
service = injector.resolve(AuthenticationClientService);
|
|
67
|
+
});
|
|
68
|
+
afterEach(async () => {
|
|
69
|
+
await service.dispose();
|
|
70
|
+
});
|
|
71
|
+
test('impersonate should acquire lock and call api', async () => {
|
|
72
|
+
const token = { exp: Date.now() + 3600, jti: 'impersonated-token', subject: 'sub', impersonator: 'admin' };
|
|
73
|
+
mockApiClient.impersonate.mockResolvedValue(token);
|
|
74
|
+
await service.impersonate('target-subject');
|
|
75
|
+
expect(mockLock.use).toHaveBeenCalled();
|
|
76
|
+
expect(mockApiClient.impersonate).toHaveBeenCalledWith({ subject: 'target-subject', data: undefined });
|
|
77
|
+
expect(service.token()).toEqual(token);
|
|
78
|
+
expect(service.impersonated()).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
test('impersonate should fail if already impersonating', async () => {
|
|
81
|
+
const token = { exp: Date.now() + 3600, jti: 'impersonated-token', subject: 'sub', impersonator: 'admin' };
|
|
82
|
+
service.setNewToken(token); // Force state
|
|
83
|
+
await expect(service.impersonate('another-target')).rejects.toThrow('Already impersonating');
|
|
84
|
+
});
|
|
85
|
+
test('unimpersonate should acquire lock and call api', async () => {
|
|
86
|
+
const token = { exp: Date.now() + 3600, jti: 'original-token', subject: 'admin' };
|
|
87
|
+
mockApiClient.unimpersonate.mockResolvedValue(token);
|
|
88
|
+
await service.unimpersonate();
|
|
89
|
+
expect(mockLock.use).toHaveBeenCalled();
|
|
90
|
+
expect(mockApiClient.unimpersonate).toHaveBeenCalled();
|
|
91
|
+
expect(service.token()).toEqual(token);
|
|
92
|
+
});
|
|
93
|
+
test('changeSecret should call api', async () => {
|
|
94
|
+
await service.changeSecret({ tenantId: 't', subject: 's' }, 'old', 'new');
|
|
95
|
+
expect(mockApiClient.changeSecret).toHaveBeenCalledWith({ tenantId: 't', subject: 's', currentSecret: 'old', newSecret: 'new' });
|
|
96
|
+
});
|
|
97
|
+
test('initResetSecret should call api', async () => {
|
|
98
|
+
await service.initResetSecret({ tenantId: 't', subject: 's' }, { some: 'data' });
|
|
99
|
+
expect(mockApiClient.initSecretReset).toHaveBeenCalledWith({ tenantId: 't', subject: 's', data: { some: 'data' } });
|
|
100
|
+
});
|
|
101
|
+
test('resetSecret should call api', async () => {
|
|
102
|
+
await service.resetSecret('token', 'new-secret');
|
|
103
|
+
expect(mockApiClient.resetSecret).toHaveBeenCalledWith({ token: 'token', newSecret: 'new-secret' });
|
|
104
|
+
});
|
|
105
|
+
test('updateRawTokens should update signals and storage', async () => {
|
|
106
|
+
service.updateRawTokens('raw', 'refresh', 'impersonator');
|
|
107
|
+
expect(service.rawToken()).toBe('raw');
|
|
108
|
+
expect(service.rawRefreshToken()).toBe('refresh');
|
|
109
|
+
expect(service.rawImpersonatorRefreshToken()).toBe('impersonator');
|
|
110
|
+
expect(globalThis.localStorage.setItem).toHaveBeenCalledWith('AuthenticationService:raw-token', JSON.stringify('raw'));
|
|
111
|
+
});
|
|
112
|
+
test('impersonate should rollback data on failure', async () => {
|
|
113
|
+
service.setAdditionalData({ role: 'admin' });
|
|
114
|
+
const originalData = service.authenticationData;
|
|
115
|
+
mockApiClient.impersonate.mockRejectedValue(new Error('Impersonation failed'));
|
|
116
|
+
await expect(service.impersonate('target')).rejects.toThrow('Impersonation failed');
|
|
117
|
+
// Should have restored original data
|
|
118
|
+
expect(service.authenticationData).toEqual(originalData);
|
|
119
|
+
expect(service.impersonatorAuthenticationData).toBeUndefined();
|
|
120
|
+
});
|
|
121
|
+
test('unimpersonate should handle failure', async () => {
|
|
122
|
+
mockApiClient.unimpersonate.mockRejectedValue(new Error('Unimpersonation failed'));
|
|
123
|
+
await expect(service.unimpersonate()).rejects.toThrow('Unimpersonation failed');
|
|
124
|
+
});
|
|
125
|
+
test('syncClock should handle errors gracefully', async () => {
|
|
126
|
+
mockApiClient.timestamp.mockRejectedValue(new Error('Time sync failed'));
|
|
127
|
+
await service.syncClock();
|
|
128
|
+
expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to synchronize clock'));
|
|
129
|
+
expect(service.clockOffset).toBe(0);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { Subject } from 'rxjs';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
|
3
|
+
import { AuthenticationClientService } from '../../authentication/client/authentication.service.js';
|
|
4
|
+
import { AUTHENTICATION_API_CLIENT } from '../../authentication/client/tokens.js';
|
|
5
|
+
import { CancellationSignal, CancellationToken } from '../../cancellation/token.js';
|
|
6
|
+
import { Injector } from '../../injector/index.js';
|
|
7
|
+
import { Lock } from '../../lock/index.js';
|
|
8
|
+
import { Logger } from '../../logger/index.js';
|
|
9
|
+
import { MessageBus } from '../../message-bus/index.js';
|
|
10
|
+
import { configureDefaultSignalsImplementation } from '../../signals/implementation/configure.js';
|
|
11
|
+
import { timeout } from '../../utils/timing.js';
|
|
12
|
+
describe('AuthenticationClientService Refresh Loop Reproduction', () => {
|
|
13
|
+
let injector;
|
|
14
|
+
let service;
|
|
15
|
+
let mockApiClient;
|
|
16
|
+
let mockLock;
|
|
17
|
+
let mockMessageBus;
|
|
18
|
+
let mockLogger;
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
const storage = new Map();
|
|
21
|
+
globalThis.localStorage = {
|
|
22
|
+
getItem: vi.fn((key) => storage.get(key) ?? null),
|
|
23
|
+
setItem: vi.fn((key, value) => storage.set(key, value)),
|
|
24
|
+
removeItem: vi.fn((key) => storage.delete(key)),
|
|
25
|
+
clear: vi.fn(() => storage.clear()),
|
|
26
|
+
};
|
|
27
|
+
configureDefaultSignalsImplementation();
|
|
28
|
+
injector = new Injector('Test');
|
|
29
|
+
mockApiClient = {
|
|
30
|
+
login: vi.fn(),
|
|
31
|
+
refresh: vi.fn(),
|
|
32
|
+
timestamp: vi.fn().mockResolvedValue(Math.floor(Date.now() / 1000)),
|
|
33
|
+
endSession: vi.fn().mockResolvedValue(undefined),
|
|
34
|
+
};
|
|
35
|
+
mockLock = {
|
|
36
|
+
tryUse: vi.fn(async (_timeout, callback) => {
|
|
37
|
+
const result = await callback({ lost: false });
|
|
38
|
+
return { success: true, result };
|
|
39
|
+
}),
|
|
40
|
+
use: vi.fn(async (_timeout, callback) => {
|
|
41
|
+
return await callback({ lost: false });
|
|
42
|
+
}),
|
|
43
|
+
};
|
|
44
|
+
mockMessageBus = {
|
|
45
|
+
publishAndForget: vi.fn(),
|
|
46
|
+
messages$: new Subject(),
|
|
47
|
+
dispose: vi.fn(),
|
|
48
|
+
};
|
|
49
|
+
mockLogger = {
|
|
50
|
+
error: vi.fn(),
|
|
51
|
+
warn: vi.fn(),
|
|
52
|
+
info: vi.fn(),
|
|
53
|
+
debug: vi.fn(),
|
|
54
|
+
};
|
|
55
|
+
injector.register(AUTHENTICATION_API_CLIENT, { useValue: mockApiClient });
|
|
56
|
+
injector.register(Lock, { useValue: mockLock });
|
|
57
|
+
injector.register(MessageBus, { useValue: mockMessageBus });
|
|
58
|
+
injector.register(Logger, { useValue: mockLogger });
|
|
59
|
+
const disposeToken = new CancellationToken();
|
|
60
|
+
injector.register(CancellationSignal, { useValue: disposeToken.signal });
|
|
61
|
+
});
|
|
62
|
+
afterEach(async () => {
|
|
63
|
+
await service.dispose();
|
|
64
|
+
});
|
|
65
|
+
test('Zombie Timer: loop should wake up immediately when token changes', async () => {
|
|
66
|
+
// 1. Mock a long expiration
|
|
67
|
+
const now = Math.floor(Date.now() / 1000);
|
|
68
|
+
const initialToken = { exp: now + 3600, jti: 'initial' };
|
|
69
|
+
// Set in storage so initialize() (called by resolve) loads it
|
|
70
|
+
globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
|
|
71
|
+
service = injector.resolve(AuthenticationClientService);
|
|
72
|
+
// Wait for loop to enter the race condition (wait phase)
|
|
73
|
+
await timeout(100);
|
|
74
|
+
// 2. Change token
|
|
75
|
+
const newToken = { exp: now + 3600, jti: 'new' };
|
|
76
|
+
mockApiClient.refresh.mockResolvedValue(newToken);
|
|
77
|
+
service.requestRefresh(); // This should trigger immediate wake up
|
|
78
|
+
// Wait for loop to process
|
|
79
|
+
await timeout(100);
|
|
80
|
+
expect(mockApiClient.refresh).toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
test('Forced Refresh Loss: forceRefreshToken should not be cleared on failure', async () => {
|
|
83
|
+
const now = Math.floor(Date.now() / 1000);
|
|
84
|
+
const initialToken = { exp: now + 3600, jti: 'initial' };
|
|
85
|
+
globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
|
|
86
|
+
service = injector.resolve(AuthenticationClientService);
|
|
87
|
+
await timeout(100);
|
|
88
|
+
// 1. Mock refresh failure
|
|
89
|
+
mockApiClient.refresh.mockRejectedValue(new Error('Network Error'));
|
|
90
|
+
service.requestRefresh();
|
|
91
|
+
// Wait for loop to attempt refresh and fail
|
|
92
|
+
await timeout(200);
|
|
93
|
+
expect(mockApiClient.refresh).toHaveBeenCalled();
|
|
94
|
+
expect(service.forceRefreshToken.isSet).toBe(true); // Should STILL be set
|
|
95
|
+
});
|
|
96
|
+
test('Lock Contention Backoff: should wait 5 seconds and not busy-loop', async () => {
|
|
97
|
+
const now = Math.floor(Date.now() / 1000);
|
|
98
|
+
const initialToken = { exp: now + 5, jti: 'initial' }; // Expiring soon
|
|
99
|
+
globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
|
|
100
|
+
// 1. Mock lock already held
|
|
101
|
+
mockLock.tryUse.mockResolvedValue(undefined); // lockAcquired = false
|
|
102
|
+
const startTime = Date.now();
|
|
103
|
+
service = injector.resolve(AuthenticationClientService);
|
|
104
|
+
// We expect it to try once, fail to get lock, and then wait 5 seconds.
|
|
105
|
+
await timeout(300);
|
|
106
|
+
expect(mockLock.tryUse).toHaveBeenCalledTimes(1);
|
|
107
|
+
// Check if it's still waiting (not finished loop)
|
|
108
|
+
const duration = Date.now() - startTime;
|
|
109
|
+
expect(duration).toBeLessThan(1000);
|
|
110
|
+
});
|
|
111
|
+
test('Busy Loop: should not busy loop when forceRefreshToken is set and lock is held', async () => {
|
|
112
|
+
const now = Math.floor(Date.now() / 1000);
|
|
113
|
+
const initialToken = { exp: now + 3600, jti: 'initial' };
|
|
114
|
+
globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
|
|
115
|
+
// Mock lock already held
|
|
116
|
+
mockLock.tryUse.mockResolvedValue(undefined);
|
|
117
|
+
service = injector.resolve(AuthenticationClientService);
|
|
118
|
+
await timeout(100);
|
|
119
|
+
service.requestRefresh(); // Set the flag
|
|
120
|
+
await timeout(300);
|
|
121
|
+
// If it busy loops, this will be much higher than 1.
|
|
122
|
+
expect(mockLock.tryUse.mock.calls.length).toBeLessThan(5);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -7,8 +7,14 @@ export type HttpServerRequestContext<Context = unknown> = {
|
|
|
7
7
|
};
|
|
8
8
|
export declare abstract class HttpServer<Context = unknown> implements AsyncIterable<HttpServerRequestContext<Context>>, AsyncDisposable {
|
|
9
9
|
abstract readonly connectedSocketsCount: number;
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
/**
|
|
11
|
+
* The port the server is listening on, or `null` if not listening.
|
|
12
|
+
*/
|
|
13
|
+
abstract readonly port: number | null;
|
|
14
|
+
/**
|
|
15
|
+
* The address the server is listening on, or `null` if not listening.
|
|
16
|
+
*/
|
|
17
|
+
abstract readonly address: string | null;
|
|
12
18
|
abstract listen(port: number): Promise<void>;
|
|
13
19
|
abstract close(timeout: number): Promise<void>;
|
|
14
20
|
abstract [Symbol.asyncIterator](): AsyncIterator<HttpServerRequestContext<Context>>;
|
|
@@ -9,8 +9,8 @@ export declare class NodeHttpServer extends HttpServer<NodeHttpServerContext> im
|
|
|
9
9
|
#private;
|
|
10
10
|
private untrackConnectedSockets?;
|
|
11
11
|
get connectedSocketsCount(): number;
|
|
12
|
-
get port(): number;
|
|
13
|
-
get address(): string;
|
|
12
|
+
get port(): number | null;
|
|
13
|
+
get address(): string | null;
|
|
14
14
|
[afterResolve](): void;
|
|
15
15
|
[Symbol.asyncDispose](): Promise<void>;
|
|
16
16
|
listen(port: number): Promise<void>;
|
|
@@ -35,10 +35,10 @@ let NodeHttpServer = NodeHttpServer_1 = class NodeHttpServer extends HttpServer
|
|
|
35
35
|
return this.#sockets.size;
|
|
36
36
|
}
|
|
37
37
|
get port() {
|
|
38
|
-
return this.#httpServer.address()
|
|
38
|
+
return this.#httpServer.address()?.port ?? null;
|
|
39
39
|
}
|
|
40
40
|
get address() {
|
|
41
|
-
return this.#httpServer.address()
|
|
41
|
+
return this.#httpServer.address()?.address ?? null;
|
|
42
42
|
}
|
|
43
43
|
[afterResolve]() {
|
|
44
44
|
this.#httpServer.on('request', (request, response) => this.#requestIterable.feed({ request, response }));
|
|
@@ -3,7 +3,7 @@ import { Injector } from '../../injector/injector.js';
|
|
|
3
3
|
import type { resolveArgumentType } from '../../injector/interfaces.js';
|
|
4
4
|
import { Module } from '../module.js';
|
|
5
5
|
export declare class WebServerModuleConfiguration {
|
|
6
|
-
port
|
|
6
|
+
port?: number;
|
|
7
7
|
}
|
|
8
8
|
export declare class WebServerModule extends Module {
|
|
9
9
|
private readonly config;
|
|
@@ -37,7 +37,7 @@ let WebServerModule = class WebServerModule extends Module {
|
|
|
37
37
|
}
|
|
38
38
|
async _run(cancellationSignal) {
|
|
39
39
|
this.initialize();
|
|
40
|
-
await this.httpServer.listen(this.config.port);
|
|
40
|
+
await this.httpServer.listen(this.config.port ?? 8000);
|
|
41
41
|
const closePromise = cancellationSignal.$set.then(async () => {
|
|
42
42
|
await this.httpServer[Symbol.asyncDispose]();
|
|
43
43
|
});
|
package/package.json
CHANGED
|
@@ -21,7 +21,7 @@ import { configureDefaultSignalsImplementation } from '../signals/implementation
|
|
|
21
21
|
import { configurePostgresTaskQueue, migratePostgresTaskQueueSchema } from '../task-queue/postgres/index.js';
|
|
22
22
|
import * as configParser from '../utils/config-parser.js';
|
|
23
23
|
import { objectEntries } from '../utils/object/object.js';
|
|
24
|
-
import { isDefined } from '../utils/type-guards.js';
|
|
24
|
+
import { isDefined, isNotNull } from '../utils/type-guards.js';
|
|
25
25
|
/**
|
|
26
26
|
* Standard setup for integration tests.
|
|
27
27
|
*/
|
|
@@ -109,7 +109,7 @@ export async function setupIntegrationTest(options = {}) {
|
|
|
109
109
|
authenticationApiClient: AuthenticationApiClient,
|
|
110
110
|
registerMiddleware: true,
|
|
111
111
|
}, injector);
|
|
112
|
-
if (options.modules
|
|
112
|
+
if (options.modules.webServer ?? options.modules.api ?? options.modules.authentication) {
|
|
113
113
|
const port = options.api?.port ?? 0;
|
|
114
114
|
configureWebServerModule({ port, injector });
|
|
115
115
|
const webServerModule = await injector.resolveAsync(WebServerModule);
|
|
@@ -118,7 +118,7 @@ export async function setupIntegrationTest(options = {}) {
|
|
|
118
118
|
// Wait for server to be listening
|
|
119
119
|
while (true) {
|
|
120
120
|
try {
|
|
121
|
-
if (
|
|
121
|
+
if (isNotNull(httpServer.port)) {
|
|
122
122
|
break;
|
|
123
123
|
}
|
|
124
124
|
}
|