@lenne.tech/nest-server 11.21.2 → 11.21.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.21.2",
3
+ "version": "11.21.3",
4
4
  "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
5
5
  "keywords": [
6
6
  "node",
@@ -1019,32 +1019,36 @@ export class CoreBetterAuthUserMapper {
1019
1019
  const migrationPercentage = totalUsers > 0 ? Math.round((fullyMigratedUsers / totalUsers) * 100 * 100) / 100 : 0;
1020
1020
 
1021
1021
  // Get emails of pending users (limit to 100)
1022
- const pendingUsers = await usersCollection
1023
- .aggregate([
1024
- {
1025
- $lookup: {
1026
- as: 'accounts',
1027
- foreignField: 'userId',
1028
- from: 'account',
1029
- localField: '_id',
1030
- },
1031
- },
1032
- {
1033
- $match: {
1034
- $or: [
1035
- { iamId: { $exists: false } },
1036
- { iamId: null },
1037
- {
1038
- $and: [{ iamId: { $exists: true, $ne: null } }, { 'accounts.providerId': { $ne: 'credential' } }],
1039
- },
1040
- ],
1041
- },
1042
- },
1043
- { $limit: 100 },
1044
- { $project: { email: 1 } },
1045
- ])
1022
+ // Two-phase approach: first get users without iamId (no $lookup needed),
1023
+ // then check users with iamId but missing credential account
1024
+ const usersWithoutIamId = await usersCollection
1025
+ .find({ $or: [{ iamId: { $exists: false } }, { iamId: null }] })
1026
+ .limit(100)
1027
+ .project({ email: 1 })
1046
1028
  .toArray();
1047
- const pendingUserEmails = pendingUsers.map((u) => u.email).filter(Boolean);
1029
+
1030
+ const remaining = 100 - usersWithoutIamId.length;
1031
+ let usersWithIamButNoAccount: { email?: string }[] = [];
1032
+ if (remaining > 0) {
1033
+ usersWithIamButNoAccount = await usersCollection
1034
+ .aggregate([
1035
+ { $match: { iamId: { $exists: true, $ne: null } } },
1036
+ {
1037
+ $lookup: {
1038
+ as: 'accounts',
1039
+ foreignField: 'userId',
1040
+ from: 'account',
1041
+ localField: '_id',
1042
+ },
1043
+ },
1044
+ { $match: { 'accounts.providerId': { $ne: 'credential' } } },
1045
+ { $limit: remaining },
1046
+ { $project: { email: 1 } },
1047
+ ])
1048
+ .toArray();
1049
+ }
1050
+
1051
+ const pendingUserEmails = [...usersWithoutIamId, ...usersWithIamButNoAccount].map((u) => u.email).filter(Boolean);
1048
1052
 
1049
1053
  // Can disable legacy auth only if ALL users are fully migrated
1050
1054
  const canDisableLegacyAuth = totalUsers > 0 && fullyMigratedUsers === totalUsers;
@@ -88,15 +88,19 @@ export class CoreBetterAuthService implements OnModuleInit {
88
88
  try {
89
89
  const db = this.connection.db;
90
90
 
91
- // Session collection: token lookup (getSessionByToken) and user+expiry lookup (getActiveSessionForUser)
92
- await db.collection('session').createIndex({ token: 1 });
93
- await db.collection('session').createIndex({ userId: 1, expiresAt: 1 });
94
-
95
- // Users collection: iamId lookup (mapSessionUser uses $or with email and iamId)
96
- // email is typically already indexed by Mongoose schema, but iamId may not be
97
- await db.collection('users').createIndex({ iamId: 1 }, { sparse: true });
98
-
99
- this.logger.debug('Performance indices ensured on session and users collections');
91
+ // All indices are idempotent (no-op if already exists) and independent run in parallel
92
+ await Promise.all([
93
+ // Session: token lookup (getSessionByToken) and user+expiry lookup (getActiveSessionForUser)
94
+ db.collection('session').createIndex({ token: 1 }),
95
+ db.collection('session').createIndex({ userId: 1, expiresAt: 1 }),
96
+ // Users: iamId lookup (mapSessionUser uses $or with email and iamId)
97
+ db.collection('users').createIndex({ iamId: 1 }, { sparse: true }),
98
+ // Account: userId lookup ($lookup in getMigrationStatus) and providerId filtering
99
+ db.collection('account').createIndex({ userId: 1 }),
100
+ db.collection('account').createIndex({ providerId: 1, userId: 1 }),
101
+ ]);
102
+
103
+ this.logger.debug('Performance indices ensured on session, users, and account collections');
100
104
  } catch (error) {
101
105
  // Non-fatal: indices improve performance but are not required for correctness
102
106
  this.logger.warn(`Could not create performance indices: ${error instanceof Error ? error.message : 'unknown'}`);
@@ -121,7 +121,18 @@ Normal (non-hierarchy) roles also work:
121
121
  async viewAuditLog(...) { ... }
122
122
  ```
123
123
 
124
- ### 8. Skip Tenant Check for Non-Tenant Endpoints
124
+ ### 8. System Roles as OR Alternatives
125
+
126
+ System roles (`S_EVERYONE`, `S_USER`, `S_VERIFIED`) are checked as OR alternatives **before** real role checks in `CoreTenantGuard`. If a system role grants access, real roles in the same `@Roles()` are alternatives, not requirements.
127
+
128
+ When `X-Tenant-Id` header is present and a system role grants access:
129
+
130
+ - `S_EVERYONE`: public — membership is optionally enriched but never blocks
131
+ - `S_USER`/`S_VERIFIED`: membership is validated (403 if not a member; admin bypass applies)
132
+
133
+ `@SkipTenantCheck()` suppresses membership validation for `S_USER`/`S_VERIFIED` paths — authentication/verification is still enforced, but no membership check runs even with a header present.
134
+
135
+ ### 9. Skip Tenant Check for Non-Tenant Endpoints
125
136
 
126
137
  Use `@SkipTenantCheck()` for endpoints that intentionally work without tenant context:
127
138
 
@@ -133,7 +144,7 @@ import { SkipTenantCheck, Roles, RoleEnum } from '@lenne.tech/nest-server';
133
144
  async listMyTenants() { ... }
134
145
  ```
135
146
 
136
- ### 9. BetterAuth (IAM) Coexistence
147
+ ### 10. BetterAuth (IAM) Coexistence
137
148
 
138
149
  When both `multiTenancy` and `betterAuth` are active, IAM endpoints (sign-in, sign-up, session, etc.)
139
150
  automatically skip tenant validation when no `X-Tenant-Id` header is sent (`betterAuth.skipTenantCheck: true`, default).
@@ -138,9 +138,33 @@ Roles not in `roleHierarchy` use exact match — no higher role can compensate:
138
138
  async auditLog(@CurrentTenant() tenantId: string) { ... }
139
139
  ```
140
140
 
141
+ ### System Roles as OR Alternatives
142
+
143
+ When `@Roles()` includes system roles (`S_EVERYONE`, `S_USER`, `S_VERIFIED`), `CoreTenantGuard` checks them **before** real roles in priority order. If a system role is satisfied, access is granted immediately — real roles in the same decorator are OR alternatives, not additional requirements.
144
+
145
+ ```typescript
146
+ @Roles(RoleEnum.ADMIN) // Class: admin can always access all methods
147
+ @Controller('users')
148
+ class UserController {
149
+ @Roles(RoleEnum.S_USER) // Method: any authenticated user (extends class ADMIN via OR)
150
+ getProfile() { ... } // → admin OR authenticated user can access
151
+
152
+ @Roles(RoleEnum.S_EVERYONE) // Method: public endpoint (extends class ADMIN via OR)
153
+ getPublicInfo() { ... } // → anyone can access
154
+ }
155
+ ```
156
+
157
+ **Method-level system roles take precedence** for the system role check. Class `@Roles(S_EVERYONE)` does not make a method `@Roles(S_USER)` endpoint public — the method's `S_USER` applies.
158
+
159
+ | System Role | No Header | Header Present |
160
+ | ----------- | --------------------- | -------------------------------------------------- |
161
+ | S_EVERYONE | Pass (public) | Pass + optional tenant context enrichment |
162
+ | S_USER | Pass if authenticated | Pass if authenticated member; admin bypass applies |
163
+ | S_VERIFIED | Pass if verified | Pass if verified member; admin bypass applies |
164
+
141
165
  ### Tenant Context Rule
142
166
 
143
- **When a tenant header is present:** Only `membership.role` is checked. `user.roles` is ignored (except ADMIN bypass).
167
+ **When a tenant header is present:** For real role checks, only `membership.role` is checked. `user.roles` is ignored (except ADMIN bypass). For system role checks (`S_USER`, `S_VERIFIED`), membership is validated to set tenant context (`tenantId`, `tenantRole`), but access depends on membership existence, not role level.
144
168
 
145
169
  **When no tenant header:** `user.roles` is checked instead. Hierarchy roles use level comparison, normal roles use exact match.
146
170
 
@@ -161,6 +185,7 @@ async listMyTenants() { ... }
161
185
  ```
162
186
 
163
187
  Note: `@SkipTenantCheck()` with hierarchy roles still checks `user.roles` (no tenant context).
188
+ It also suppresses tenant membership validation for `S_USER`/`S_VERIFIED` system role paths — when set, these roles still enforce authentication/verification but skip the membership check even when a tenant header is present.
164
189
 
165
190
  ### Admin Bypass
166
191
 
@@ -47,6 +47,10 @@ interface CachedTenantIds {
47
47
  * - Plugin level: Safety net — ForbiddenException when tenantId-schema accessed without context
48
48
  *
49
49
  * Role check semantics:
50
+ * - System roles are OR alternatives, checked in order before real roles:
51
+ * S_EVERYONE → immediate pass; S_USER → pass if authenticated; S_VERIFIED → pass if verified
52
+ * - When a system role grants access and X-Tenant-Id header is present, membership is still
53
+ * validated to set tenant context (tenantId + tenantRole) on the request.
50
54
  * - Hierarchy roles (in roleHierarchy config): level comparison — higher includes lower
51
55
  * - Normal roles (not in roleHierarchy): exact match — no compensation by higher role
52
56
  * - Tenant context (header present): checks against membership.role only (user.roles ignored)
@@ -61,9 +65,13 @@ interface CachedTenantIds {
61
65
  * Flow:
62
66
  * 1. Config check: multiTenancy enabled?
63
67
  * 2. Parse header (X-Tenant-Id, max 128 chars, trimmed)
64
- * 3. @SkipTenantCheck role check against user.roles, no tenant context
65
- * 4. Read @Roles() metadata, filter out system roles
66
- * 5. BetterAuth auto-skip (betterAuth.skipTenantCheck config + no header) skip, no tenant context
68
+ * 3. Read @Roles() metadata (method + class level)
69
+ * 4. System role early-exit checks (OR alternatives):
70
+ * S_EVERYONEpass immediately
71
+ * S_USER → pass if authenticated (+ optional membership check when header present)
72
+ * S_VERIFIED → pass if user is verified (+ optional membership check when header present)
73
+ * 5. @SkipTenantCheck → role check against user.roles, no tenant context
74
+ * 6. BetterAuth auto-skip (betterAuth.skipTenantCheck config + no header) → skip, no tenant context
67
75
  *
68
76
  * HEADER PRESENT:
69
77
  * - System ADMIN (adminBypass: true) → set req.tenantId + isAdminBypass
@@ -184,24 +192,111 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
184
192
  const headerTenantId =
185
193
  rawHeader && typeof rawHeader === 'string' && rawHeader.length <= 128 ? rawHeader.trim() : undefined;
186
194
 
187
- // Read @Roles() metadata and filter to non-system roles
195
+ // Two role sets for different purposes:
196
+ //
197
+ // 1. systemCheckRoles (method-takes-precedence): Used for system role early-returns.
198
+ // Method-level system roles override class-level ones to prevent e.g. class @Roles(S_EVERYONE)
199
+ // from making a method @Roles(S_USER) endpoint public.
200
+ //
201
+ // 2. roles (OR/merged): Used for real role checks (checkableRoles).
202
+ // Class-level roles serve as a base that method-level roles extend.
203
+ // E.g., class @Roles(ADMIN) + method @Roles('editor') → both are alternatives.
204
+ //
205
+ // S_EVERYONE check — access is always granted; no authentication or membership required.
206
+ //
207
+ // Header handling for S_EVERYONE:
208
+ // - No header → return true immediately (no tenant context needed)
209
+ // - Header present + authenticated user that IS a member → optionally enrich with tenant
210
+ // context (sets request.tenantId/tenantRole) so downstream consumers can use it.
211
+ // Access is NOT blocked if user is not a member — S_EVERYONE means public access.
188
212
  const rolesMetadata = this.reflector.getAll<string[][]>('roles', [context.getHandler(), context.getClass()]);
189
213
  const roles = mergeRolesMetadata(rolesMetadata);
214
+ const methodRoles: string[] = rolesMetadata[0] ?? [];
215
+ const systemCheckRoles = methodRoles.length > 0 ? methodRoles : roles;
216
+
217
+ // Defense-in-depth: S_NO_ONE is normally caught by RolesGuard/BetterAuthRolesGuard upstream,
218
+ // but guard it here too in case CoreTenantGuard runs standalone (e.g., custom guard chains).
219
+ if (roles.includes(RoleEnum.S_NO_ONE)) {
220
+ throw new ForbiddenException('Access denied');
221
+ }
222
+
223
+ const sEveryoneGrantsAccess: boolean = systemCheckRoles.includes(RoleEnum.S_EVERYONE);
224
+ if (sEveryoneGrantsAccess) {
225
+ // Optionally enrich with tenant context when header is present and user is an active member.
226
+ // Never block access — S_EVERYONE endpoints are always public.
227
+ if (headerTenantId && request.user?.id) {
228
+ const membership = await this.findMembershipCached(request.user.id, headerTenantId);
229
+ if (membership) {
230
+ request.tenantId = headerTenantId;
231
+ request.tenantRole = membership.role as string;
232
+ }
233
+ }
234
+ return true;
235
+ }
190
236
 
191
237
  const user = request.user;
192
238
  const adminBypass = config.adminBypass !== false;
193
239
  const isAdmin = adminBypass && user?.roles?.includes(RoleEnum.ADMIN);
194
240
 
195
- // Filter to checkable (non-system) roles only when needed (avoids array allocation on fast paths)
196
- const hasNonSystemRoles = roles.some((r) => !isSystemRole(r));
197
- const checkableRoles = hasNonSystemRoles ? roles.filter((r) => !isSystemRole(r)) : [];
198
- const minRequiredLevel = checkableRoles.length > 0 ? getMinRequiredLevel(checkableRoles) : undefined;
199
-
200
- // @SkipTenantCheck decorator → no tenant context, but role check against user.roles
241
+ // Read @SkipTenantCheck early it suppresses tenant membership validation for system roles too.
242
+ // When set, S_USER and S_VERIFIED still enforce authentication/verification, but no membership
243
+ // check is performed even when a tenant header is present.
201
244
  const hasSkipDecorator = this.reflector.getAllAndOverride<boolean>(SKIP_TENANT_CHECK_KEY, [
202
245
  context.getHandler(),
203
246
  context.getClass(),
204
247
  ]);
248
+
249
+ // S_USER check — any authenticated user satisfies this system role.
250
+ //
251
+ // OR semantics: if S_USER is in the active role set, a logged-in user gets through.
252
+ // Real roles in the same @Roles() are ignored when S_USER is satisfied (they are alternatives).
253
+ // Example: @Roles(S_USER, 'owner') → a plain logged-in user passes (owner is an alternative, not required).
254
+ //
255
+ // Tenant header behavior: when X-Tenant-Id is present and @SkipTenantCheck is NOT set,
256
+ // membership is validated so that tenant context (tenantId, tenantRole) is set on the request.
257
+ // A non-member will still get 403 when a tenant header is provided (unless @SkipTenantCheck).
258
+ const sUserGrantsAccess: boolean = systemCheckRoles.includes(RoleEnum.S_USER);
259
+ if (sUserGrantsAccess) {
260
+ if (!user) {
261
+ throw new ForbiddenException('Authentication required');
262
+ }
263
+ if (headerTenantId && !hasSkipDecorator) {
264
+ return this.handleSystemRoleWithTenantHeader(user, headerTenantId, request, isAdmin);
265
+ }
266
+ return true;
267
+ }
268
+
269
+ // S_VERIFIED check — any verified authenticated user satisfies this system role.
270
+ //
271
+ // A user is considered verified when any of these properties is truthy:
272
+ // user.verified, user.verifiedAt, user.emailVerified
273
+ //
274
+ // Tenant header behavior: same as S_USER — membership is validated when header is present
275
+ // (unless @SkipTenantCheck is set).
276
+ const sVerifiedGrantsAccess: boolean = systemCheckRoles.includes(RoleEnum.S_VERIFIED);
277
+ if (sVerifiedGrantsAccess) {
278
+ if (!user) {
279
+ throw new ForbiddenException('Authentication required');
280
+ }
281
+ const isVerified = !!(user.verified || user.verifiedAt || user.emailVerified);
282
+ if (!isVerified) {
283
+ throw new ForbiddenException('Verification required');
284
+ }
285
+ if (headerTenantId && !hasSkipDecorator) {
286
+ return this.handleSystemRoleWithTenantHeader(user, headerTenantId, request, isAdmin);
287
+ }
288
+ return true;
289
+ }
290
+
291
+ // Extract checkable (non-system) roles from the merged set.
292
+ // System roles that grant access (S_EVERYONE, S_USER, S_VERIFIED) have been
293
+ // early-returned above. Remaining system roles (S_SELF, S_CREATOR) are object-level
294
+ // and handled by interceptors.
295
+ const checkableRoles = roles.filter((r: string) => !isSystemRole(r));
296
+
297
+ const minRequiredLevel = checkableRoles.length > 0 ? getMinRequiredLevel(checkableRoles) : undefined;
298
+
299
+ // @SkipTenantCheck decorator → no tenant context, but role check against user.roles
205
300
  if (hasSkipDecorator) {
206
301
  return this.skipWithUserRoleCheck(checkableRoles, user, isAdmin);
207
302
  }
@@ -238,7 +333,7 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
238
333
  const requiredRole = checkableRoles.length > 0 ? checkableRoles.join(',') : 'none';
239
334
  // Sanitize control characters to prevent log injection
240
335
  const safeTenantId = headerTenantId.replace(/[\r\n\t]/g, '_');
241
- this.logger.log(`Admin bypass: user ${user.id} accessing tenant ${safeTenantId} (required: ${requiredRole})`);
336
+ this.logger.debug(`Admin bypass: user ${user.id} accessing tenant ${safeTenantId} (required: ${requiredRole})`);
242
337
  return true;
243
338
  }
244
339
 
@@ -465,6 +560,42 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
465
560
  }
466
561
  }
467
562
 
563
+ /**
564
+ * Validate tenant membership for a request that was granted access via a system role
565
+ * (S_USER or S_VERIFIED). When a tenant header is present, the user must be an active member
566
+ * of that tenant — unless the user is an admin with adminBypass enabled.
567
+ *
568
+ * On success, sets request.tenantId and request.tenantRole for downstream consumers.
569
+ *
570
+ * @param user - The authenticated request user
571
+ * @param headerTenantId - The validated, non-empty tenant ID from the request header
572
+ * @param request - The HTTP/GraphQL request object
573
+ * @param isAdmin - Whether the user has admin bypass privileges
574
+ */
575
+ private async handleSystemRoleWithTenantHeader(
576
+ user: any,
577
+ headerTenantId: string,
578
+ request: any,
579
+ isAdmin: boolean,
580
+ ): Promise<true> {
581
+ // Admin bypass: same behavior as the HEADER PRESENT admin path below
582
+ if (isAdmin) {
583
+ request.tenantId = headerTenantId;
584
+ request.isAdminBypass = true;
585
+ const safeTenantId = headerTenantId.replace(/[\r\n\t]/g, '_');
586
+ this.logger.debug(`Admin bypass (system-role path): user ${user.id} accessing tenant ${safeTenantId}`);
587
+ return true;
588
+ }
589
+
590
+ const membership = await this.findMembershipCached(user.id, headerTenantId);
591
+ if (!membership) {
592
+ throw new ForbiddenException('Not a member of this tenant');
593
+ }
594
+ request.tenantId = headerTenantId;
595
+ request.tenantRole = membership.role as string;
596
+ return true;
597
+ }
598
+
468
599
  /**
469
600
  * Skip tenant validation but still check non-system roles against user.roles.
470
601
  * Shared by @SkipTenantCheck decorator path and BetterAuth auto-skip path.
@@ -5,7 +5,10 @@ const SYSTEM_ROLE_PREFIX = 's_';
5
5
 
6
6
  /**
7
7
  * Merge handler-level and class-level @Roles() metadata arrays into a single flat array.
8
- * Used by RolesGuard, BetterAuthRolesGuard, and CoreTenantGuard to avoid code duplication.
8
+ * Used by RolesGuard, BetterAuthRolesGuard, and CoreTenantGuard.
9
+ *
10
+ * OR semantics: class-level roles serve as a base that method-level roles extend.
11
+ * Example: class @Roles(ADMIN) + method @Roles(S_USER) → [S_USER, ADMIN] — both are alternatives.
9
12
  *
10
13
  * @param meta - Two-element tuple [handlerRoles, classRoles] from Reflector.getAll or Reflect.getMetadata
11
14
  */
@@ -22,7 +25,8 @@ export function getRoleHierarchy(): Record<string, number> {
22
25
 
23
26
  /**
24
27
  * Check if a role is a system role (S_USER, S_EVERYONE, etc.).
25
- * System roles are handled by RolesGuard, not CoreTenantGuard.
28
+ * System roles are checked by RolesGuard/BetterAuthRolesGuard for authentication
29
+ * and by CoreTenantGuard as OR alternatives before real role checks.
26
30
  */
27
31
  export function isSystemRole(role: string): boolean {
28
32
  return role.startsWith(SYSTEM_ROLE_PREFIX);
@@ -174,6 +174,53 @@ await testHelper.rest('/protected-endpoint', {
174
174
  });
175
175
  ```
176
176
 
177
+ ## File Download Testing (`testHelper.download()` / `testHelper.downloadBuffer()`)
178
+
179
+ ### `download(url, tokenOrOptions?)`
180
+
181
+ Download a file and return the response with a `data` string property for content comparison.
182
+
183
+ ```typescript
184
+ // No authentication
185
+ const res = await testHelper.download('/files/id/abc123');
186
+ expect(res.statusCode).toEqual(200);
187
+ expect(res.data).toEqual('file content');
188
+ ```
189
+
190
+ ### `downloadBuffer(url, tokenOrOptions?)`
191
+
192
+ Download a file and return a `Buffer` for binary comparison or saving.
193
+
194
+ ```typescript
195
+ const buffer = await testHelper.downloadBuffer('/files/id/abc123', jwtToken);
196
+ await fs.promises.writeFile('/tmp/downloaded.bin', buffer);
197
+ ```
198
+
199
+ ### TestDownloadOptions
200
+
201
+ The second parameter accepts either a plain token string or a `TestDownloadOptions` object:
202
+
203
+ | Option | Type | Description |
204
+ | --------- | -------- | ------------------------------------------------------------- |
205
+ | `token` | `string` | Bearer token via Authorization header (JWT) |
206
+ | `cookies` | `string` | Plain session token, converted via `buildBetterAuthCookies()` |
207
+
208
+ Both can be used simultaneously — `token` sets the Authorization header while `cookies` sets the Cookie header.
209
+
210
+ ```typescript
211
+ // String form (backward compatible) — sets Authorization: bearer <token>
212
+ await testHelper.download('/files/id/abc123', jwtToken);
213
+
214
+ // Options object with JWT token
215
+ await testHelper.download('/files/id/abc123', { token: jwtToken });
216
+
217
+ // Options object with cookie-based session auth
218
+ await testHelper.download('/files/id/abc123', { cookies: sessionToken });
219
+
220
+ // Both simultaneously
221
+ await testHelper.download('/files/id/abc123', { cookies: sessionToken, token: jwtToken });
222
+ ```
223
+
177
224
  ## GraphQL Testing (`testHelper.graphQl()`)
178
225
 
179
226
  ```typescript
@@ -154,6 +154,25 @@ export interface TestRestOptions {
154
154
  token?: string;
155
155
  }
156
156
 
157
+ /**
158
+ * Options for download/downloadBuffer requests
159
+ */
160
+ export interface TestDownloadOptions {
161
+ /**
162
+ * Cookie-based authentication. Pass a plain session token string
163
+ * which is converted via buildBetterAuthCookies() to all relevant cookie names
164
+ * (iam.session_token, token).
165
+ */
166
+ cookies?: string;
167
+
168
+ /**
169
+ * Bearer token via Authorization header (JWT authentication).
170
+ * Can be used simultaneously with `cookies` — token sets the Authorization header
171
+ * while cookies sets the Cookie header.
172
+ */
173
+ token?: string;
174
+ }
175
+
157
176
  /**
158
177
  * Test helper
159
178
  */
@@ -176,13 +195,28 @@ export class TestHelper {
176
195
  /**
177
196
  * Download file from URL
178
197
  * To compare content data via string comparison
198
+ * @param url - URL to download from
199
+ * @param tokenOrOptions - Bearer token string, or {@link TestDownloadOptions} with `cookies` and/or `token`
179
200
  * @return Superagent response with additional data field containing the content of the file
180
201
  */
181
- download(url: string, token?: string): Promise<any> {
202
+ download(url: string, tokenOrOptions?: string | TestDownloadOptions): Promise<any> {
182
203
  return new Promise((resolve, reject) => {
183
204
  const request = supertest((this.app as INestApplication).getHttpServer()).get(url);
184
- if (token) {
185
- request.set('Authorization', `bearer ${token}`);
205
+ if (typeof tokenOrOptions === 'string') {
206
+ request.set('Authorization', `bearer ${tokenOrOptions}`);
207
+ } else if (tokenOrOptions) {
208
+ if (tokenOrOptions.cookies) {
209
+ const cookieRecord = TestHelper.buildBetterAuthCookies(tokenOrOptions.cookies);
210
+ request.set(
211
+ 'Cookie',
212
+ Object.entries(cookieRecord)
213
+ .map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
214
+ .join('; '),
215
+ );
216
+ }
217
+ if (tokenOrOptions.token) {
218
+ request.set('Authorization', `bearer ${tokenOrOptions.token}`);
219
+ }
186
220
  }
187
221
  let data = '';
188
222
  request
@@ -207,12 +241,27 @@ export class TestHelper {
207
241
  /**
208
242
  * Download file from URL and get buffer
209
243
  * To compare content data via buffer comparison and with the possibility to save the file
244
+ * @param url - URL to download from
245
+ * @param tokenOrOptions - Bearer token string, or {@link TestDownloadOptions} with `cookies` and/or `token`
210
246
  */
211
- downloadBuffer(url: string, token?: string): Promise<Buffer> {
247
+ downloadBuffer(url: string, tokenOrOptions?: string | TestDownloadOptions): Promise<Buffer> {
212
248
  return new Promise((resolve, reject) => {
213
249
  const request = supertest(this.app.getHttpServer()).get(url);
214
- if (token) {
215
- request.set('Authorization', `bearer ${token}`);
250
+ if (typeof tokenOrOptions === 'string') {
251
+ request.set('Authorization', `bearer ${tokenOrOptions}`);
252
+ } else if (tokenOrOptions) {
253
+ if (tokenOrOptions.cookies) {
254
+ const cookieRecord = TestHelper.buildBetterAuthCookies(tokenOrOptions.cookies);
255
+ request.set(
256
+ 'Cookie',
257
+ Object.entries(cookieRecord)
258
+ .map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
259
+ .join('; '),
260
+ );
261
+ }
262
+ if (tokenOrOptions.token) {
263
+ request.set('Authorization', `bearer ${tokenOrOptions.token}`);
264
+ }
216
265
  }
217
266
 
218
267
  // Array to store the data chunks