@tstdl/base 0.93.151 → 0.93.153
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/authentication.api.d.ts +3 -0
- package/authentication/authentication.api.js +2 -1
- package/authentication/client/authentication.service.d.ts +2 -1
- package/authentication/client/authentication.service.js +3 -2
- package/authentication/models/token.model.d.ts +2 -0
- package/authentication/server/authentication.api-controller.d.ts +2 -1
- package/authentication/server/authentication.api-controller.js +7 -6
- package/authentication/server/authentication.audit.d.ts +2 -0
- package/authentication/server/authentication.service.d.ts +15 -3
- package/authentication/server/authentication.service.js +27 -14
- package/authentication/tests/remember.api.test.d.ts +1 -0
- package/authentication/tests/remember.api.test.js +109 -0
- package/authentication/tests/remember.service.test.d.ts +1 -0
- package/authentication/tests/remember.service.test.js +76 -0
- package/package.json +1 -1
- package/serializer/handlers/error.d.ts +3 -1
- package/serializer/handlers/error.js +11 -1
- package/task-queue/index.d.ts +1 -0
- package/task-queue/index.js +1 -0
- package/task-queue/postgres/task-queue.d.ts +1 -1
- package/task-queue/postgres/task-queue.js +4 -2
- package/task-queue/task.error.d.ts +7 -0
- package/task-queue/task.error.js +18 -0
- package/testing/README.md +23 -12
|
@@ -30,6 +30,7 @@ export declare const authenticationApiDefinition: {
|
|
|
30
30
|
readonly tenantId: string | undefined;
|
|
31
31
|
readonly subject: string;
|
|
32
32
|
readonly secret: string;
|
|
33
|
+
readonly remember: boolean;
|
|
33
34
|
readonly data: undefined;
|
|
34
35
|
}>;
|
|
35
36
|
result: ObjectSchema<TokenPayload<import("type-fest").EmptyObject>>;
|
|
@@ -155,6 +156,7 @@ export declare function getAuthenticationApiDefinition<AdditionalTokenPayload ex
|
|
|
155
156
|
readonly tenantId: string | undefined;
|
|
156
157
|
readonly subject: string;
|
|
157
158
|
readonly secret: string;
|
|
159
|
+
readonly remember: boolean;
|
|
158
160
|
readonly data: AuthenticationData;
|
|
159
161
|
}>;
|
|
160
162
|
result: ObjectSchema<TokenPayload<AdditionalTokenPayload>>;
|
|
@@ -275,6 +277,7 @@ export declare function getAuthenticationApiEndpointsDefinition<AdditionalTokenP
|
|
|
275
277
|
readonly tenantId: string | undefined;
|
|
276
278
|
readonly subject: string;
|
|
277
279
|
readonly secret: string;
|
|
280
|
+
readonly remember: boolean;
|
|
278
281
|
readonly data: AuthenticationData;
|
|
279
282
|
}>;
|
|
280
283
|
result: ObjectSchema<TokenPayload<AdditionalTokenPayload>>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineApi } from '../api/types.js';
|
|
2
|
-
import { assign, emptyObjectSchema, explicitObject, literal, never, number, object, optional, string } from '../schema/index.js';
|
|
2
|
+
import { assign, boolean, defaulted, emptyObjectSchema, explicitObject, literal, never, number, object, optional, string } from '../schema/index.js';
|
|
3
3
|
import { SecretCheckResult } from './models/secret-check-result.model.js';
|
|
4
4
|
import { TokenPayloadBase } from './models/token-payload-base.model.js';
|
|
5
5
|
/**
|
|
@@ -51,6 +51,7 @@ export function getAuthenticationApiEndpointsDefinition(additionalTokenPayloadSc
|
|
|
51
51
|
tenantId: optional(string()),
|
|
52
52
|
subject: string(),
|
|
53
53
|
secret: string(),
|
|
54
|
+
remember: defaulted(boolean(), false),
|
|
54
55
|
data: authenticationDataSchema,
|
|
55
56
|
}),
|
|
56
57
|
result: tokenResultSchema,
|
|
@@ -127,8 +127,9 @@ export declare class AuthenticationClientService<AdditionalTokenPayload extends
|
|
|
127
127
|
* @param subjectInput The subject to login with
|
|
128
128
|
* @param secret The secret to login with
|
|
129
129
|
* @param data Additional authentication data
|
|
130
|
+
* @param remember Whether to remember the session
|
|
130
131
|
*/
|
|
131
|
-
login(subjectInput: SubjectInput, secret: string, data?: AuthenticationData): Promise<void>;
|
|
132
|
+
login(subjectInput: SubjectInput, secret: string, data?: AuthenticationData, remember?: boolean): Promise<void>;
|
|
132
133
|
/**
|
|
133
134
|
* Logout from the current session.
|
|
134
135
|
* This will attempt to end the session on the server and then clear local credentials.
|
|
@@ -220,13 +220,14 @@ let AuthenticationClientService = class AuthenticationClientService {
|
|
|
220
220
|
* @param subjectInput The subject to login with
|
|
221
221
|
* @param secret The secret to login with
|
|
222
222
|
* @param data Additional authentication data
|
|
223
|
+
* @param remember Whether to remember the session
|
|
223
224
|
*/
|
|
224
|
-
async login(subjectInput, secret, data) {
|
|
225
|
+
async login(subjectInput, secret, data, remember = false) {
|
|
225
226
|
if (isDefined(data)) {
|
|
226
227
|
this.setAdditionalData(data);
|
|
227
228
|
}
|
|
228
229
|
const [token] = await Promise.all([
|
|
229
|
-
this.client.login({ tenantId: subjectInput.tenantId, subject: subjectInput.subject, secret, data: this.authenticationData }),
|
|
230
|
+
this.client.login({ tenantId: subjectInput.tenantId, subject: subjectInput.subject, secret, remember, data: this.authenticationData }),
|
|
230
231
|
this.syncClock(),
|
|
231
232
|
]);
|
|
232
233
|
this.setNewToken(token);
|
|
@@ -26,6 +26,8 @@ export type RefreshToken = JwtToken<{
|
|
|
26
26
|
impersonator?: string;
|
|
27
27
|
/** The id of the session. */
|
|
28
28
|
session: string;
|
|
29
|
+
/** Whether to remember the session. */
|
|
30
|
+
remember: boolean;
|
|
29
31
|
/** The secret to use for refreshing the token. */
|
|
30
32
|
secret: string;
|
|
31
33
|
}>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/** biome-ignore-all lint/nursery/noExcessiveClassesPerFile: <explanation> */
|
|
1
2
|
import type { ApiController, ApiRequestContext, ApiServerResult } from '../../api/types.js';
|
|
2
3
|
import { HttpServerResponse } from '../../http/server/index.js';
|
|
3
4
|
import type { ObjectSchemaOrType, SchemaTestable } from '../../schema/index.js';
|
|
@@ -71,7 +72,7 @@ export declare class AuthenticationApiController<AdditionalTokenPayload extends
|
|
|
71
72
|
* @returns The current server timestamp in seconds.
|
|
72
73
|
*/
|
|
73
74
|
timestamp(): ApiServerResult<AuthenticationApiDefinition<AdditionalTokenPayload, AuthenticationData, AdditionalInitSecretResetData>, 'timestamp'>;
|
|
74
|
-
protected getTokenResponse({ token, jsonToken, refreshToken, omitImpersonatorRefreshToken, impersonatorRefreshToken, impersonatorRefreshTokenExpiration }: TokenResult<AdditionalTokenPayload>): HttpServerResponse;
|
|
75
|
+
protected getTokenResponse({ token, jsonToken, refreshToken, remember, omitImpersonatorRefreshToken, impersonatorRefreshToken, impersonatorRefreshTokenExpiration }: TokenResult<AdditionalTokenPayload>): HttpServerResponse;
|
|
75
76
|
}
|
|
76
77
|
/**
|
|
77
78
|
* Get an authentication API controller.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/** biome-ignore-all lint/nursery/noExcessiveClassesPerFile: <explanation> */
|
|
1
2
|
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
3
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
4
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -8,7 +9,7 @@ import { apiController } from '../../api/server/index.js';
|
|
|
8
9
|
import { HttpServerResponse } from '../../http/server/index.js';
|
|
9
10
|
import { inject } from '../../injector/index.js';
|
|
10
11
|
import { currentTimestampSeconds } from '../../utils/date-time.js';
|
|
11
|
-
import {
|
|
12
|
+
import { isDefined } from '../../utils/type-guards.js';
|
|
12
13
|
import { authenticationApiDefinition, getAuthenticationApiDefinition } from '../authentication.api.js';
|
|
13
14
|
import { AuthenticationService } from './authentication.service.js';
|
|
14
15
|
import { tryGetAuthorizationTokenStringFromRequest } from './helper.js';
|
|
@@ -29,7 +30,7 @@ let AuthenticationApiController = class AuthenticationApiController {
|
|
|
29
30
|
* @returns The token result.
|
|
30
31
|
*/
|
|
31
32
|
async login({ parameters, getAuditor }) {
|
|
32
|
-
const result = await this.authenticationService.login({ tenantId: parameters.tenantId, subject: parameters.subject }, parameters.secret, parameters.data, await getAuditor());
|
|
33
|
+
const result = await this.authenticationService.login({ tenantId: parameters.tenantId, subject: parameters.subject }, parameters.secret, parameters.data, await getAuditor(), parameters.remember);
|
|
33
34
|
return this.getTokenResponse(result);
|
|
34
35
|
}
|
|
35
36
|
/**
|
|
@@ -140,7 +141,7 @@ let AuthenticationApiController = class AuthenticationApiController {
|
|
|
140
141
|
timestamp() {
|
|
141
142
|
return currentTimestampSeconds();
|
|
142
143
|
}
|
|
143
|
-
getTokenResponse({ token, jsonToken, refreshToken, omitImpersonatorRefreshToken, impersonatorRefreshToken, impersonatorRefreshTokenExpiration }) {
|
|
144
|
+
getTokenResponse({ token, jsonToken, refreshToken, remember, omitImpersonatorRefreshToken, impersonatorRefreshToken, impersonatorRefreshTokenExpiration }) {
|
|
144
145
|
const result = jsonToken.payload;
|
|
145
146
|
const options = {
|
|
146
147
|
headers: {
|
|
@@ -151,12 +152,12 @@ let AuthenticationApiController = class AuthenticationApiController {
|
|
|
151
152
|
authorization: {
|
|
152
153
|
value: `Bearer ${token}`,
|
|
153
154
|
...cookieBaseOptions,
|
|
154
|
-
expires: jsonToken.payload.exp * 1000,
|
|
155
|
+
expires: remember ? (jsonToken.payload.exp * 1000) : undefined,
|
|
155
156
|
},
|
|
156
157
|
refreshToken: {
|
|
157
158
|
value: `Bearer ${refreshToken}`,
|
|
158
159
|
...cookieBaseOptions,
|
|
159
|
-
expires: jsonToken.payload.refreshTokenExp * 1000,
|
|
160
|
+
expires: remember ? (jsonToken.payload.refreshTokenExp * 1000) : undefined,
|
|
160
161
|
},
|
|
161
162
|
},
|
|
162
163
|
body: {
|
|
@@ -168,7 +169,7 @@ let AuthenticationApiController = class AuthenticationApiController {
|
|
|
168
169
|
options.cookies['impersonatorRefreshToken'] = {
|
|
169
170
|
value: `Bearer ${impersonatorRefreshToken}`,
|
|
170
171
|
...cookieBaseOptions,
|
|
171
|
-
expires:
|
|
172
|
+
expires: (remember && isDefined(impersonatorRefreshTokenExpiration)) ? (impersonatorRefreshTokenExpiration * 1000) : undefined,
|
|
172
173
|
};
|
|
173
174
|
}
|
|
174
175
|
if (omitImpersonatorRefreshToken == true) {
|
|
@@ -2,6 +2,7 @@ import type { SubjectInput } from '../types.js';
|
|
|
2
2
|
export type AuthenticationAuditEvents = {
|
|
3
3
|
'login-success': {
|
|
4
4
|
sessionId: string;
|
|
5
|
+
remember: boolean;
|
|
5
6
|
};
|
|
6
7
|
'login-failure': {
|
|
7
8
|
subjectInput: SubjectInput;
|
|
@@ -12,6 +13,7 @@ export type AuthenticationAuditEvents = {
|
|
|
12
13
|
};
|
|
13
14
|
'refresh-success': {
|
|
14
15
|
sessionId: string;
|
|
16
|
+
remember: boolean;
|
|
15
17
|
};
|
|
16
18
|
'refresh-failure': {
|
|
17
19
|
reason: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/** biome-ignore-all lint/nursery/noExcessiveClassesPerFile: <explanation> */
|
|
1
2
|
import { Auditor } from '../../audit/index.js';
|
|
2
3
|
import { afterResolve, type AfterResolve } from '../../injector/index.js';
|
|
3
4
|
import type { BinaryData, Record } from '../../types/index.js';
|
|
@@ -56,9 +57,15 @@ export declare class AuthenticationServiceOptions {
|
|
|
56
57
|
/**
|
|
57
58
|
* How long a refresh token is valid in milliseconds. Implies session time to live.
|
|
58
59
|
*
|
|
59
|
-
* @default
|
|
60
|
+
* @default 1 hour
|
|
60
61
|
*/
|
|
61
62
|
refreshTokenTimeToLive?: number;
|
|
63
|
+
/**
|
|
64
|
+
* How long a refresh token is valid in milliseconds if "remember" is checked.
|
|
65
|
+
*
|
|
66
|
+
* @default 30 days
|
|
67
|
+
*/
|
|
68
|
+
rememberRefreshTokenTimeToLive?: number;
|
|
62
69
|
/**
|
|
63
70
|
* How long a secret reset token is valid in milliseconds.
|
|
64
71
|
*
|
|
@@ -97,6 +104,7 @@ export type TokenResult<AdditionalTokenPayload extends Record> = {
|
|
|
97
104
|
token: string;
|
|
98
105
|
jsonToken: Token<AdditionalTokenPayload>;
|
|
99
106
|
refreshToken: string;
|
|
107
|
+
remember: boolean;
|
|
100
108
|
omitImpersonatorRefreshToken?: boolean;
|
|
101
109
|
impersonatorRefreshToken?: string;
|
|
102
110
|
impersonatorRefreshTokenExpiration?: number;
|
|
@@ -161,6 +169,7 @@ export declare class AuthenticationService<AdditionalTokenPayload extends Record
|
|
|
161
169
|
private readonly tokenVersion;
|
|
162
170
|
private readonly tokenTimeToLive;
|
|
163
171
|
private readonly refreshTokenTimeToLive;
|
|
172
|
+
private readonly rememberRefreshTokenTimeToLive;
|
|
164
173
|
private readonly secretResetTokenTimeToLive;
|
|
165
174
|
private derivedTokenSigningSecret;
|
|
166
175
|
private derivedRefreshTokenSigningSecret;
|
|
@@ -196,8 +205,9 @@ export declare class AuthenticationService<AdditionalTokenPayload extends Record
|
|
|
196
205
|
* @param options Options for getting the token.
|
|
197
206
|
* @returns The token result.
|
|
198
207
|
*/
|
|
199
|
-
getToken(subject: Subject, authenticationData: AuthenticationData, { impersonator }?: {
|
|
208
|
+
getToken(subject: Subject, authenticationData: AuthenticationData, { impersonator, remember }?: {
|
|
200
209
|
impersonator?: string;
|
|
210
|
+
remember?: boolean;
|
|
201
211
|
}): Promise<TokenResult<AdditionalTokenPayload>>;
|
|
202
212
|
/**
|
|
203
213
|
* Logs in a subject.
|
|
@@ -205,9 +215,10 @@ export declare class AuthenticationService<AdditionalTokenPayload extends Record
|
|
|
205
215
|
* @param secret The secret to log in with.
|
|
206
216
|
* @param data Additional authentication data.
|
|
207
217
|
* @param auditor Auditor for auditing.
|
|
218
|
+
* @param remember Whether to remember the session.
|
|
208
219
|
* @returns Token
|
|
209
220
|
*/
|
|
210
|
-
login(subjectInput: SubjectInput, secret: string, data: AuthenticationData, auditor: Auditor): Promise<TokenResult<AdditionalTokenPayload>>;
|
|
221
|
+
login(subjectInput: SubjectInput, secret: string, data: AuthenticationData, auditor: Auditor, remember?: boolean): Promise<TokenResult<AdditionalTokenPayload>>;
|
|
211
222
|
/**
|
|
212
223
|
* Ends a session.
|
|
213
224
|
* @param sessionId The id of the session to end.
|
|
@@ -366,6 +377,7 @@ export declare class AuthenticationService<AdditionalTokenPayload extends Record
|
|
|
366
377
|
*/
|
|
367
378
|
createRefreshToken(subject: Subject, sessionId: string, expirationTimestamp: number, options?: {
|
|
368
379
|
impersonator?: string;
|
|
380
|
+
remember?: boolean;
|
|
369
381
|
}): Promise<CreateRefreshTokenResult>;
|
|
370
382
|
defaultResolveSubjects({ tenantId, subject }: {
|
|
371
383
|
tenantId?: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/** biome-ignore-all lint/nursery/noExcessiveClassesPerFile: <explanation> */
|
|
1
2
|
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
3
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
4
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -27,7 +28,7 @@ import { createJwtTokenString } from '../../utils/jwt.js';
|
|
|
27
28
|
import { isUuid } from '../../utils/patterns.js';
|
|
28
29
|
import { getRandomBytes, getRandomString } from '../../utils/random.js';
|
|
29
30
|
import { isBinaryData, isDefined, isString, isUndefined } from '../../utils/type-guards.js';
|
|
30
|
-
import { millisecondsPerDay, millisecondsPerMinute } from '../../utils/units.js';
|
|
31
|
+
import { millisecondsPerDay, millisecondsPerHour, millisecondsPerMinute } from '../../utils/units.js';
|
|
31
32
|
import { AuthenticationCredentials, AuthenticationSession, Subject, User } from '../models/index.js';
|
|
32
33
|
import { AuthenticationAncillaryService, GetTokenPayloadContextAction } from './authentication-ancillary.service.js';
|
|
33
34
|
import { AuthenticationSecretRequirementsValidator } from './authentication-secret-requirements.validator.js';
|
|
@@ -54,9 +55,15 @@ export class AuthenticationServiceOptions {
|
|
|
54
55
|
/**
|
|
55
56
|
* How long a refresh token is valid in milliseconds. Implies session time to live.
|
|
56
57
|
*
|
|
57
|
-
* @default
|
|
58
|
+
* @default 1 hour
|
|
58
59
|
*/
|
|
59
60
|
refreshTokenTimeToLive;
|
|
61
|
+
/**
|
|
62
|
+
* How long a refresh token is valid in milliseconds if "remember" is checked.
|
|
63
|
+
*
|
|
64
|
+
* @default 30 days
|
|
65
|
+
*/
|
|
66
|
+
rememberRefreshTokenTimeToLive;
|
|
60
67
|
/**
|
|
61
68
|
* How long a secret reset token is valid in milliseconds.
|
|
62
69
|
*
|
|
@@ -119,7 +126,8 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
|
|
|
119
126
|
};
|
|
120
127
|
tokenVersion = this.#options.version ?? 1;
|
|
121
128
|
tokenTimeToLive = this.#options.tokenTimeToLive ?? (5 * millisecondsPerMinute);
|
|
122
|
-
refreshTokenTimeToLive = this.#options.refreshTokenTimeToLive ?? (
|
|
129
|
+
refreshTokenTimeToLive = this.#options.refreshTokenTimeToLive ?? (1 * millisecondsPerHour);
|
|
130
|
+
rememberRefreshTokenTimeToLive = this.#options.rememberRefreshTokenTimeToLive ?? (30 * millisecondsPerDay);
|
|
123
131
|
secretResetTokenTimeToLive = this.#options.secretResetTokenTimeToLive ?? (10 * millisecondsPerMinute);
|
|
124
132
|
derivedTokenSigningSecret;
|
|
125
133
|
derivedRefreshTokenSigningSecret;
|
|
@@ -201,9 +209,10 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
|
|
|
201
209
|
* @param options Options for getting the token.
|
|
202
210
|
* @returns The token result.
|
|
203
211
|
*/
|
|
204
|
-
async getToken(subject, authenticationData, { impersonator } = {}) {
|
|
212
|
+
async getToken(subject, authenticationData, { impersonator, remember = false } = {}) {
|
|
205
213
|
const now = currentTimestamp();
|
|
206
|
-
const
|
|
214
|
+
const ttl = remember ? this.rememberRefreshTokenTimeToLive : this.refreshTokenTimeToLive;
|
|
215
|
+
const end = now + ttl;
|
|
207
216
|
return await this.#sessionRepository.transaction(async (tx) => {
|
|
208
217
|
const session = await this.#sessionRepository.withTransaction(tx).insert({
|
|
209
218
|
tenantId: subject.tenantId,
|
|
@@ -216,14 +225,14 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
|
|
|
216
225
|
});
|
|
217
226
|
const tokenPayload = await this.#authenticationAncillaryService?.getTokenPayload(subject, authenticationData, { action: GetTokenPayloadContextAction.GetToken });
|
|
218
227
|
const { token, jsonToken } = await this.createToken({ additionalTokenPayload: tokenPayload, subject, impersonator, sessionId: session.id, refreshTokenExpiration: end, timestamp: now });
|
|
219
|
-
const refreshToken = await this.createRefreshToken(subject, session.id, end, { impersonator });
|
|
228
|
+
const refreshToken = await this.createRefreshToken(subject, session.id, end, { impersonator, remember });
|
|
220
229
|
await this.#sessionRepository.withTransaction(tx).update(session.id, {
|
|
221
230
|
end,
|
|
222
231
|
refreshTokenHashVersion: 1,
|
|
223
232
|
refreshTokenSalt: refreshToken.salt,
|
|
224
233
|
refreshTokenHash: refreshToken.hash,
|
|
225
234
|
});
|
|
226
|
-
return { token, jsonToken, refreshToken: refreshToken.token };
|
|
235
|
+
return { token, jsonToken, refreshToken: refreshToken.token, remember };
|
|
227
236
|
});
|
|
228
237
|
}
|
|
229
238
|
/**
|
|
@@ -232,9 +241,10 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
|
|
|
232
241
|
* @param secret The secret to log in with.
|
|
233
242
|
* @param data Additional authentication data.
|
|
234
243
|
* @param auditor Auditor for auditing.
|
|
244
|
+
* @param remember Whether to remember the session.
|
|
235
245
|
* @returns Token
|
|
236
246
|
*/
|
|
237
|
-
async login(subjectInput, secret, data, auditor) {
|
|
247
|
+
async login(subjectInput, secret, data, auditor, remember = false) {
|
|
238
248
|
const authAuditor = auditor.fork(AuthenticationService_1.name);
|
|
239
249
|
const authenticationResult = await this.authenticate(subjectInput, secret);
|
|
240
250
|
if (!authenticationResult.success) {
|
|
@@ -249,7 +259,7 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
|
|
|
249
259
|
throw new InvalidCredentialsError();
|
|
250
260
|
}
|
|
251
261
|
await this.hooks.beforeLogin.trigger({ subject: authenticationResult.subject });
|
|
252
|
-
const token = await this.getToken(authenticationResult.subject, data);
|
|
262
|
+
const token = await this.getToken(authenticationResult.subject, data, { remember });
|
|
253
263
|
await this.hooks.afterLogin.trigger({ subject: authenticationResult.subject });
|
|
254
264
|
const sessionId = token.jsonToken.payload.session;
|
|
255
265
|
await authAuditor.info('login-success', {
|
|
@@ -259,7 +269,7 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
|
|
|
259
269
|
targetId: authenticationResult.subject.id,
|
|
260
270
|
targetType: 'User',
|
|
261
271
|
network: { sessionId },
|
|
262
|
-
details: { sessionId },
|
|
272
|
+
details: { sessionId, remember },
|
|
263
273
|
});
|
|
264
274
|
return token;
|
|
265
275
|
}
|
|
@@ -361,11 +371,13 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
|
|
|
361
371
|
}
|
|
362
372
|
const now = currentTimestamp();
|
|
363
373
|
const impersonator = (options.omitImpersonator == true) ? undefined : validatedRefreshToken.payload.impersonator;
|
|
364
|
-
const
|
|
374
|
+
const remember = validatedRefreshToken.payload.remember;
|
|
375
|
+
const ttl = remember ? this.rememberRefreshTokenTimeToLive : this.refreshTokenTimeToLive;
|
|
376
|
+
const newEnd = now + ttl;
|
|
365
377
|
const subject = await this.#subjectRepository.loadByQuery({ tenantId: session.tenantId, id: session.subjectId });
|
|
366
378
|
const tokenPayload = await this.#authenticationAncillaryService?.getTokenPayload(subject, authenticationData, { action: GetTokenPayloadContextAction.Refresh });
|
|
367
379
|
const { token, jsonToken } = await this.createToken({ additionalTokenPayload: tokenPayload, subject, sessionId, refreshTokenExpiration: newEnd, impersonator, timestamp: now });
|
|
368
|
-
const newRefreshToken = await this.createRefreshToken(subject, sessionId, newEnd, { impersonator });
|
|
380
|
+
const newRefreshToken = await this.createRefreshToken(subject, sessionId, newEnd, { impersonator, remember });
|
|
369
381
|
await this.#sessionRepository.update(sessionId, {
|
|
370
382
|
end: newEnd,
|
|
371
383
|
refreshTokenHashVersion: 1,
|
|
@@ -378,9 +390,9 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
|
|
|
378
390
|
actorType: ActorType.Subject,
|
|
379
391
|
targetId: session.subjectId,
|
|
380
392
|
targetType: 'User',
|
|
381
|
-
details: { sessionId },
|
|
393
|
+
details: { sessionId, remember },
|
|
382
394
|
});
|
|
383
|
-
return { token, jsonToken, refreshToken: newRefreshToken.token, omitImpersonatorRefreshToken: options.omitImpersonator };
|
|
395
|
+
return { token, jsonToken, refreshToken: newRefreshToken.token, remember, omitImpersonatorRefreshToken: options.omitImpersonator };
|
|
384
396
|
}
|
|
385
397
|
catch (error) {
|
|
386
398
|
await authAuditor.warn('refresh-failure', {
|
|
@@ -715,6 +727,7 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
|
|
|
715
727
|
tenant: subject.tenantId,
|
|
716
728
|
impersonator: options?.impersonator,
|
|
717
729
|
session: sessionId,
|
|
730
|
+
remember: options?.remember ?? false,
|
|
718
731
|
secret,
|
|
719
732
|
},
|
|
720
733
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'vitest';
|
|
2
|
+
import { Auditor } from '../../audit/index.js';
|
|
3
|
+
import { HttpHeaders } from '../../http/index.js';
|
|
4
|
+
import { HttpServerResponse } from '../../http/server/index.js';
|
|
5
|
+
import { clearTenantData, setupIntegrationTest } from '../../testing/index.js';
|
|
6
|
+
import { toArray } from '../../utils/array/array.js';
|
|
7
|
+
import { AuthenticationApiController } from '../server/authentication.api-controller.js';
|
|
8
|
+
import { AuthenticationService } from '../server/authentication.service.js';
|
|
9
|
+
import { SubjectService } from '../server/subject.service.js';
|
|
10
|
+
import { DefaultAuthenticationAncillaryService } from './authentication.test-ancillary-service.js';
|
|
11
|
+
describe('AuthenticationApiController Remember Functionality', () => {
|
|
12
|
+
let injector;
|
|
13
|
+
let database;
|
|
14
|
+
let controller;
|
|
15
|
+
let authenticationService;
|
|
16
|
+
let subjectService;
|
|
17
|
+
let auditor;
|
|
18
|
+
const schema = 'authentication';
|
|
19
|
+
const tenantId = crypto.randomUUID();
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
({ injector, database } = await setupIntegrationTest({
|
|
22
|
+
modules: { authentication: true, audit: true, keyValueStore: true },
|
|
23
|
+
authenticationAncillaryService: DefaultAuthenticationAncillaryService,
|
|
24
|
+
}));
|
|
25
|
+
authenticationService = await injector.resolveAsync(AuthenticationService);
|
|
26
|
+
subjectService = await injector.resolveAsync(SubjectService);
|
|
27
|
+
auditor = injector.resolve(Auditor);
|
|
28
|
+
controller = injector.resolve(AuthenticationApiController);
|
|
29
|
+
});
|
|
30
|
+
afterAll(async () => {
|
|
31
|
+
await injector?.dispose();
|
|
32
|
+
});
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
await clearTenantData(database, schema, ['credentials', 'session', 'user', 'service_account', 'system_account', 'subject'], tenantId);
|
|
35
|
+
});
|
|
36
|
+
test('login with remember: true should have Expires in cookies', async () => {
|
|
37
|
+
const user = await subjectService.createUser({ tenantId, email: 'api-rem@example.com', firstName: 'A', lastName: 'L' });
|
|
38
|
+
await authenticationService.setCredentials(user, 'Pass-R3m3mb3r-2026!');
|
|
39
|
+
const context = {
|
|
40
|
+
parameters: { tenantId, subject: user.id, secret: 'Pass-R3m3mb3r-2026!', remember: true, data: undefined },
|
|
41
|
+
getAuditor: async () => auditor,
|
|
42
|
+
};
|
|
43
|
+
const response = await controller.login(context);
|
|
44
|
+
expect(response).toBeInstanceOf(HttpServerResponse);
|
|
45
|
+
const setCookieHeaders = toArray(response.headers.tryGet('Set-Cookie') ?? []);
|
|
46
|
+
expect(setCookieHeaders).toHaveLength(2); // authorization and refreshToken
|
|
47
|
+
for (const cookie of setCookieHeaders) {
|
|
48
|
+
expect(cookie).toMatch(/Expires=/);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
test('login with remember: false should NOT have Expires in cookies', async () => {
|
|
52
|
+
const user = await subjectService.createUser({ tenantId, email: 'api-no-rem@example.com', firstName: 'A', lastName: 'L' });
|
|
53
|
+
await authenticationService.setCredentials(user, 'Pass-R3m3mb3r-2026!');
|
|
54
|
+
const context = {
|
|
55
|
+
parameters: { tenantId, subject: user.id, secret: 'Pass-R3m3mb3r-2026!', remember: false, data: undefined },
|
|
56
|
+
getAuditor: async () => auditor,
|
|
57
|
+
};
|
|
58
|
+
const response = await controller.login(context);
|
|
59
|
+
const setCookieHeaders = toArray(response.headers.tryGet('Set-Cookie') ?? []);
|
|
60
|
+
for (const cookie of setCookieHeaders) {
|
|
61
|
+
expect(cookie).not.toMatch(/Expires=/);
|
|
62
|
+
expect(cookie).not.toMatch(/Max-Age=/);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
test('refresh should propagate remember status to cookies', async () => {
|
|
66
|
+
const user = await subjectService.createUser({ tenantId, email: 'api-refresh-rem@example.com', firstName: 'A', lastName: 'L' });
|
|
67
|
+
await authenticationService.setCredentials(user, 'Pass-R3m3mb3r-2026!');
|
|
68
|
+
// 1. Login with remember: true
|
|
69
|
+
const loginResult = await authenticationService.login({ tenantId, subject: user.id }, 'Pass-R3m3mb3r-2026!', undefined, auditor, true);
|
|
70
|
+
// 2. Refresh
|
|
71
|
+
const context = {
|
|
72
|
+
request: {
|
|
73
|
+
headers: new HttpHeaders({
|
|
74
|
+
'X-Refresh-Token': `Bearer ${loginResult.refreshToken}`
|
|
75
|
+
}),
|
|
76
|
+
cookies: {
|
|
77
|
+
tryGet: () => undefined
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
parameters: { data: undefined },
|
|
81
|
+
getAuditor: async () => auditor,
|
|
82
|
+
};
|
|
83
|
+
const response = await controller.refresh(context);
|
|
84
|
+
const setCookieHeaders = toArray(response.headers.tryGet('Set-Cookie') ?? []);
|
|
85
|
+
for (const cookie of setCookieHeaders) {
|
|
86
|
+
expect(cookie).toMatch(/Expires=/);
|
|
87
|
+
}
|
|
88
|
+
// 3. Login with remember: false
|
|
89
|
+
const loginResultNoRem = await authenticationService.login({ tenantId, subject: user.id }, 'Pass-R3m3mb3r-2026!', undefined, auditor, false);
|
|
90
|
+
// 4. Refresh
|
|
91
|
+
const contextNoRem = {
|
|
92
|
+
request: {
|
|
93
|
+
headers: new HttpHeaders({
|
|
94
|
+
'X-Refresh-Token': `Bearer ${loginResultNoRem.refreshToken}`
|
|
95
|
+
}),
|
|
96
|
+
cookies: {
|
|
97
|
+
tryGet: () => undefined
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
parameters: { data: undefined },
|
|
101
|
+
getAuditor: async () => auditor,
|
|
102
|
+
};
|
|
103
|
+
const responseNoRem = await controller.refresh(contextNoRem);
|
|
104
|
+
const setCookieHeadersNoRem = toArray(responseNoRem.headers.tryGet('Set-Cookie') ?? []);
|
|
105
|
+
for (const cookie of setCookieHeadersNoRem) {
|
|
106
|
+
expect(cookie).not.toMatch(/Expires=/);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'vitest';
|
|
2
|
+
import { Auditor } from '../../audit/index.js';
|
|
3
|
+
import { clearTenantData, setupIntegrationTest } from '../../testing/index.js';
|
|
4
|
+
import { AuthenticationService } from '../server/authentication.service.js';
|
|
5
|
+
import { getRefreshTokenFromString } from '../server/helper.js';
|
|
6
|
+
import { SubjectService } from '../server/subject.service.js';
|
|
7
|
+
import { DefaultAuthenticationAncillaryService } from './authentication.test-ancillary-service.js';
|
|
8
|
+
describe('AuthenticationService Remember Functionality', () => {
|
|
9
|
+
let injector;
|
|
10
|
+
let database;
|
|
11
|
+
let authenticationService;
|
|
12
|
+
let subjectService;
|
|
13
|
+
let auditor;
|
|
14
|
+
const schema = 'authentication';
|
|
15
|
+
const tenantId = crypto.randomUUID();
|
|
16
|
+
beforeAll(async () => {
|
|
17
|
+
({ injector, database } = await setupIntegrationTest({
|
|
18
|
+
modules: { authentication: true },
|
|
19
|
+
authenticationAncillaryService: DefaultAuthenticationAncillaryService,
|
|
20
|
+
}));
|
|
21
|
+
authenticationService = await injector.resolveAsync(AuthenticationService);
|
|
22
|
+
subjectService = await injector.resolveAsync(SubjectService);
|
|
23
|
+
auditor = injector.resolve(Auditor);
|
|
24
|
+
});
|
|
25
|
+
afterAll(async () => {
|
|
26
|
+
await injector?.dispose();
|
|
27
|
+
});
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
await clearTenantData(database, schema, ['credentials', 'session', 'user', 'service_account', 'system_account', 'subject'], tenantId);
|
|
30
|
+
});
|
|
31
|
+
test('RefreshToken should contain remember flag', async () => {
|
|
32
|
+
const user = await subjectService.createUser({
|
|
33
|
+
tenantId,
|
|
34
|
+
email: 'remember@example.com',
|
|
35
|
+
firstName: 'Rem',
|
|
36
|
+
lastName: 'Ember',
|
|
37
|
+
});
|
|
38
|
+
const tokenResult = await authenticationService.getToken(user, undefined, { remember: true });
|
|
39
|
+
const refreshToken = await getRefreshTokenFromString(tokenResult.refreshToken, authenticationService.derivedRefreshTokenSigningSecret);
|
|
40
|
+
expect(refreshToken.payload).toHaveProperty('remember', true);
|
|
41
|
+
expect(tokenResult.remember).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
test('RefreshToken should respect remember flag for expiration', async () => {
|
|
44
|
+
const user = await subjectService.createUser({ tenantId, email: 'ttl@example.com', firstName: 'T', lastName: 'L' });
|
|
45
|
+
const normalResult = await authenticationService.getToken(user, undefined, { remember: false });
|
|
46
|
+
const rememberResult = await authenticationService.getToken(user, undefined, { remember: true });
|
|
47
|
+
const normalToken = await getRefreshTokenFromString(normalResult.refreshToken, authenticationService.derivedRefreshTokenSigningSecret);
|
|
48
|
+
const rememberToken = await getRefreshTokenFromString(rememberResult.refreshToken, authenticationService.derivedRefreshTokenSigningSecret);
|
|
49
|
+
expect(rememberToken.payload.exp).toBeGreaterThan(normalToken.payload.exp);
|
|
50
|
+
});
|
|
51
|
+
test('login should pass remember flag to getToken', async () => {
|
|
52
|
+
const user = await subjectService.createUser({ tenantId, email: 'login-rem@example.com', firstName: 'L', lastName: 'R' });
|
|
53
|
+
await authenticationService.setCredentials(user, 'Strong-Password-2026!');
|
|
54
|
+
const result = await authenticationService.login({ tenantId, subject: user.id }, 'Strong-Password-2026!', undefined, auditor, true);
|
|
55
|
+
expect(result.remember).toBe(true);
|
|
56
|
+
const refreshToken = await getRefreshTokenFromString(result.refreshToken, authenticationService.derivedRefreshTokenSigningSecret);
|
|
57
|
+
expect(refreshToken.payload.remember).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
test('refresh should propagate remember flag', async () => {
|
|
60
|
+
const user = await subjectService.createUser({ tenantId, email: 'refresh-rem@example.com', firstName: 'R', lastName: 'R' });
|
|
61
|
+
await authenticationService.setCredentials(user, 'Strong-Password-2026!');
|
|
62
|
+
const loginResult = await authenticationService.login({ tenantId, subject: user.id }, 'Strong-Password-2026!', undefined, auditor, true);
|
|
63
|
+
expect(loginResult.remember).toBe(true);
|
|
64
|
+
const refreshResult = await authenticationService.refresh(loginResult.refreshToken, undefined, {}, auditor);
|
|
65
|
+
expect(refreshResult.remember).toBe(true);
|
|
66
|
+
const newRefreshToken = await getRefreshTokenFromString(refreshResult.refreshToken, authenticationService.derivedRefreshTokenSigningSecret);
|
|
67
|
+
expect(newRefreshToken.payload.remember).toBe(true);
|
|
68
|
+
// Verify it also works when not remembered
|
|
69
|
+
const loginResultNoRem = await authenticationService.login({ tenantId, subject: user.id }, 'Strong-Password-2026!', undefined, auditor, false);
|
|
70
|
+
expect(loginResultNoRem.remember).toBe(false);
|
|
71
|
+
const refreshResultNoRem = await authenticationService.refresh(loginResultNoRem.refreshToken, undefined, {}, auditor);
|
|
72
|
+
expect(refreshResultNoRem.remember).toBe(false);
|
|
73
|
+
const newRefreshTokenNoRem = await getRefreshTokenFromString(refreshResultNoRem.refreshToken, authenticationService.derivedRefreshTokenSigningSecret);
|
|
74
|
+
expect(newRefreshTokenNoRem.payload.remember).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
});
|
package/package.json
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
type ErrorData = Pick<Error, 'name' | 'message' | 'stack'
|
|
1
|
+
type ErrorData = Pick<Error, 'name' | 'message' | 'stack'> & {
|
|
2
|
+
cause?: ErrorData;
|
|
3
|
+
};
|
|
2
4
|
export declare function serializeError(error: Error): ErrorData;
|
|
3
5
|
export declare function deserializeError(data: ErrorData): Error;
|
|
4
6
|
export {};
|
|
@@ -1,9 +1,19 @@
|
|
|
1
|
+
import { formatError } from '../../errors/format.js';
|
|
2
|
+
import { isDefined, isError, isString } from '../../utils/type-guards.js';
|
|
1
3
|
export function serializeError(error) {
|
|
2
|
-
|
|
4
|
+
const cause = isError(error.cause)
|
|
5
|
+
? serializeError(error.cause)
|
|
6
|
+
: isString(error.cause)
|
|
7
|
+
? { name: 'Error', message: error.cause, stack: undefined }
|
|
8
|
+
: isDefined(error.cause)
|
|
9
|
+
? { name: 'Error', message: formatError(error.cause), stack: undefined }
|
|
10
|
+
: undefined;
|
|
11
|
+
return { name: error.name, message: error.message, stack: error.stack, cause };
|
|
3
12
|
}
|
|
4
13
|
export function deserializeError(data) {
|
|
5
14
|
const error = new Error(data.message);
|
|
6
15
|
error.name = data.name;
|
|
7
16
|
error.stack = data.stack;
|
|
17
|
+
error.cause = isDefined(data.cause) ? deserializeError(data.cause) : undefined;
|
|
8
18
|
return error;
|
|
9
19
|
}
|
package/task-queue/index.d.ts
CHANGED
package/task-queue/index.js
CHANGED
|
@@ -103,7 +103,7 @@ export declare class PostgresTaskQueue<Definitions extends TaskDefinitionMap = T
|
|
|
103
103
|
results?: TasksResults<Tasks>;
|
|
104
104
|
transaction?: Transaction;
|
|
105
105
|
}): Promise<void>;
|
|
106
|
-
fail(task: Task<Definitions>,
|
|
106
|
+
fail(task: Task<Definitions>, caughtError: unknown, options?: {
|
|
107
107
|
fatal?: boolean;
|
|
108
108
|
transaction?: Transaction;
|
|
109
109
|
}): Promise<void>;
|
|
@@ -74,6 +74,7 @@ import { cancelableTimeout } from '../../utils/timing.js';
|
|
|
74
74
|
import { isArray, isDefined, isNotNull, isNull, isNumber, isString, isUndefined } from '../../utils/type-guards.js';
|
|
75
75
|
import { millisecondsPerMinute, millisecondsPerSecond } from '../../utils/units.js';
|
|
76
76
|
import { defaultQueueConfig, queueableOrWaitableStatuses, queueableStatuses, TaskDependencyType, TaskQueue, TaskStatus, terminalStatuses } from '../task-queue.js';
|
|
77
|
+
import { ensureTaskError } from '../task.error.js';
|
|
77
78
|
import { PostgresTaskQueueModuleConfig } from './module.js';
|
|
78
79
|
import { taskArchive as taskArchiveTable, taskDependency as taskDependencyTable, taskDependencyType, taskStatus, task as taskTable } from './schemas.js';
|
|
79
80
|
import { PostgresTask, PostgresTaskArchive } from './task.model.js';
|
|
@@ -849,7 +850,8 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
|
|
|
849
850
|
}
|
|
850
851
|
});
|
|
851
852
|
}
|
|
852
|
-
async fail(task,
|
|
853
|
+
async fail(task, caughtError, options) {
|
|
854
|
+
const error = ensureTaskError(caughtError);
|
|
853
855
|
const isRetryable = (options?.fatal != true) && (task.tries < this.maxTries);
|
|
854
856
|
const nextStatus = isRetryable ? TaskStatus.Retrying : TaskStatus.Dead;
|
|
855
857
|
const delay = isRetryable
|
|
@@ -883,7 +885,7 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
|
|
|
883
885
|
}
|
|
884
886
|
await this.#repository.useTransaction(options?.transaction, async (tx) => {
|
|
885
887
|
const rows = tasks.map((task, index) => {
|
|
886
|
-
const error = errors[index];
|
|
888
|
+
const error = ensureTaskError(errors[index]);
|
|
887
889
|
const isRetryable = (task.tries < this.maxTries);
|
|
888
890
|
const nextStatus = isRetryable ? TaskStatus.Retrying : TaskStatus.Dead;
|
|
889
891
|
const delay = isRetryable
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { CustomError, type CustomErrorOptions } from '../errors/custom.error.js';
|
|
2
|
+
import type { TypedOmit } from '../types/types.js';
|
|
3
|
+
export declare class TaskError extends CustomError {
|
|
4
|
+
static readonly errorName = "TaskError";
|
|
5
|
+
constructor(message: string, options?: TypedOmit<CustomErrorOptions, 'message' | 'cause'>);
|
|
6
|
+
}
|
|
7
|
+
export declare function ensureTaskError(error: unknown): Error;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { CustomError } from '../errors/custom.error.js';
|
|
2
|
+
import { formatError } from '../errors/format.js';
|
|
3
|
+
import { isString } from '../utils/type-guards.js';
|
|
4
|
+
export class TaskError extends CustomError {
|
|
5
|
+
static errorName = 'TaskError';
|
|
6
|
+
constructor(message, options) {
|
|
7
|
+
super({ message, ...options });
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function ensureTaskError(error) {
|
|
11
|
+
if (error instanceof Error) {
|
|
12
|
+
return error;
|
|
13
|
+
}
|
|
14
|
+
if (isString(error)) {
|
|
15
|
+
return new TaskError(error, undefined);
|
|
16
|
+
}
|
|
17
|
+
return new TaskError(`A task error occurred: ${formatError(error)}`);
|
|
18
|
+
}
|
package/testing/README.md
CHANGED
|
@@ -80,37 +80,48 @@ The `setupIntegrationTest` utility (found in `source/testing/integration-setup.t
|
|
|
80
80
|
4. **Schema Isolation**: Automatically creates and uses PostgreSQL schemas for isolation.
|
|
81
81
|
5. **Modules**: Optional configuration for `taskQueue`, `authentication`, `objectStorage`, `notification`, etc.
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
### Injection Context: `testInInjector`
|
|
84
|
+
|
|
85
|
+
Integration tests may require an injection context to resolve services and repositories. You can use the `testInInjector` (or `itInInjector`) helper to automatically wrap your test body.
|
|
84
86
|
|
|
85
87
|
> [!IMPORTANT]
|
|
86
|
-
> `testInInjector` is
|
|
88
|
+
> `testInInjector` is **only required** for code that uses `inject()` or `injectRepository()` outside of a class constructor/initializer (e.g. ad-hoc repository injection in a test body).
|
|
87
89
|
>
|
|
88
|
-
> If
|
|
90
|
+
> If you resolve your services in `beforeAll` or `beforeEach`, calling their methods usually does **not** require `testInInjector`.
|
|
91
|
+
|
|
92
|
+
> [!TIP]
|
|
93
|
+
> **Performance & Reusability**: For most integration tests, resolve services once in `beforeAll` and reuse the variables in your tests.
|
|
89
94
|
|
|
90
95
|
```typescript
|
|
91
|
-
import { beforeAll, describe, expect } from 'vitest';
|
|
96
|
+
import { beforeAll, describe, expect, test } from 'vitest';
|
|
92
97
|
import { setupIntegrationTest, testInInjector } from '#/testing/index.js';
|
|
93
98
|
import { MyService } from '../my-service.js';
|
|
94
99
|
|
|
95
100
|
describe('MyService Integration', () => {
|
|
96
|
-
let injector;
|
|
101
|
+
let injector: Injector;
|
|
102
|
+
let myService: MyService;
|
|
97
103
|
|
|
98
104
|
beforeAll(async () => {
|
|
99
|
-
// Setup with database and specific modules
|
|
100
105
|
({ injector } = await setupIntegrationTest({
|
|
101
106
|
modules: { taskQueue: true },
|
|
102
|
-
orm: { schema: 'test_my_service' },
|
|
103
107
|
}));
|
|
108
|
+
|
|
109
|
+
// Resolve reused services once
|
|
110
|
+
myService = injector.resolve(MyService);
|
|
104
111
|
});
|
|
105
112
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const result = await
|
|
113
|
+
// Calling methods on resolved services does NOT require testInInjector
|
|
114
|
+
test('should process data', async () => {
|
|
115
|
+
const result = await myService.process();
|
|
109
116
|
expect(result.success).toBe(true);
|
|
110
117
|
});
|
|
111
118
|
|
|
112
|
-
//
|
|
113
|
-
testInInjector('should
|
|
119
|
+
// testInInjector is ONLY needed if you use inject() directly in the test body
|
|
120
|
+
testInInjector('should work with ad-hoc injection', () => injector, async () => {
|
|
121
|
+
const repo = injectRepository(SomeEntity);
|
|
122
|
+
// ...
|
|
123
|
+
},
|
|
124
|
+
);
|
|
114
125
|
});
|
|
115
126
|
```
|
|
116
127
|
|