@solidxai/core 0.1.15 → 0.1.16-beta.1

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.
Files changed (176) hide show
  1. package/CHANGELOG.md +1293 -0
  2. package/dist/commands/run-tests.command.d.ts +3 -1
  3. package/dist/commands/run-tests.command.d.ts.map +1 -1
  4. package/dist/commands/run-tests.command.js +6 -2
  5. package/dist/commands/run-tests.command.js.map +1 -1
  6. package/dist/controllers/test-run.controller.d.ts +12 -0
  7. package/dist/controllers/test-run.controller.d.ts.map +1 -0
  8. package/dist/controllers/test-run.controller.js +54 -0
  9. package/dist/controllers/test-run.controller.js.map +1 -0
  10. package/dist/dtos/test-run-request.dto.d.ts +35 -0
  11. package/dist/dtos/test-run-request.dto.d.ts.map +1 -0
  12. package/dist/dtos/test-run-request.dto.js +120 -0
  13. package/dist/dtos/test-run-request.dto.js.map +1 -0
  14. package/dist/guards/access-token.guard.d.ts +5 -1
  15. package/dist/guards/access-token.guard.d.ts.map +1 -1
  16. package/dist/guards/access-token.guard.js +20 -1
  17. package/dist/guards/access-token.guard.js.map +1 -1
  18. package/dist/index.d.ts +12 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +17 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/interfaces/active-user-data.interface.d.ts +1 -0
  23. package/dist/interfaces/active-user-data.interface.d.ts.map +1 -1
  24. package/dist/interfaces/active-user-data.interface.js.map +1 -1
  25. package/dist/jobs/database/test-run-queue-options-database.d.ts +8 -0
  26. package/dist/jobs/database/test-run-queue-options-database.d.ts.map +1 -0
  27. package/dist/jobs/database/test-run-queue-options-database.js +10 -0
  28. package/dist/jobs/database/test-run-queue-options-database.js.map +1 -0
  29. package/dist/jobs/database/test-run-queue-publisher-database.service.d.ts +12 -0
  30. package/dist/jobs/database/test-run-queue-publisher-database.service.d.ts.map +1 -0
  31. package/dist/jobs/database/test-run-queue-publisher-database.service.js +39 -0
  32. package/dist/jobs/database/test-run-queue-publisher-database.service.js.map +1 -0
  33. package/dist/jobs/database/test-run-queue-subscriber-database.service.d.ts +26 -0
  34. package/dist/jobs/database/test-run-queue-subscriber-database.service.d.ts.map +1 -0
  35. package/dist/jobs/database/test-run-queue-subscriber-database.service.js +120 -0
  36. package/dist/jobs/database/test-run-queue-subscriber-database.service.js.map +1 -0
  37. package/dist/resources/images/solid-core-menu-icon.png +0 -0
  38. package/dist/seeders/seed-data/solid-core-metadata.json +4 -2
  39. package/dist/services/access-token-denylist.service.d.ts +16 -0
  40. package/dist/services/access-token-denylist.service.d.ts.map +1 -0
  41. package/dist/services/access-token-denylist.service.js +74 -0
  42. package/dist/services/access-token-denylist.service.js.map +1 -0
  43. package/dist/services/authentication.service.d.ts +9 -1
  44. package/dist/services/authentication.service.d.ts.map +1 -1
  45. package/dist/services/authentication.service.js +39 -4
  46. package/dist/services/authentication.service.js.map +1 -1
  47. package/dist/services/import-transaction.service.d.ts +1 -1
  48. package/dist/services/import-transaction.service.d.ts.map +1 -1
  49. package/dist/services/import-transaction.service.js +13 -23
  50. package/dist/services/import-transaction.service.js.map +1 -1
  51. package/dist/services/module-metadata.service.js +4 -4
  52. package/dist/services/module-metadata.service.js.map +1 -1
  53. package/dist/services/settings/default-settings-provider.service.d.ts +20 -0
  54. package/dist/services/settings/default-settings-provider.service.d.ts.map +1 -1
  55. package/dist/services/settings/default-settings-provider.service.js +11 -0
  56. package/dist/services/settings/default-settings-provider.service.js.map +1 -1
  57. package/dist/services/workflow-secret.service.d.ts +3 -0
  58. package/dist/services/workflow-secret.service.d.ts.map +1 -1
  59. package/dist/services/workflow-secret.service.js +25 -0
  60. package/dist/services/workflow-secret.service.js.map +1 -1
  61. package/dist/solid-core.module.d.ts.map +1 -1
  62. package/dist/solid-core.module.js +9 -0
  63. package/dist/solid-core.module.js.map +1 -1
  64. package/dist/testing/adapters/ui/browser-provisioner.d.ts +5 -0
  65. package/dist/testing/adapters/ui/browser-provisioner.d.ts.map +1 -0
  66. package/dist/testing/adapters/ui/browser-provisioner.js +102 -0
  67. package/dist/testing/adapters/ui/browser-provisioner.js.map +1 -0
  68. package/dist/testing/adapters/ui/playwright-adapter.d.ts +40 -1
  69. package/dist/testing/adapters/ui/playwright-adapter.d.ts.map +1 -1
  70. package/dist/testing/adapters/ui/playwright-adapter.js +185 -3
  71. package/dist/testing/adapters/ui/playwright-adapter.js.map +1 -1
  72. package/dist/testing/adapters/ui/ui.types.d.ts +6 -0
  73. package/dist/testing/adapters/ui/ui.types.d.ts.map +1 -1
  74. package/dist/testing/adapters/ui/ui.types.js.map +1 -1
  75. package/dist/testing/contracts/runtime-context.types.d.ts +2 -0
  76. package/dist/testing/contracts/runtime-context.types.d.ts.map +1 -1
  77. package/dist/testing/contracts/runtime-context.types.js.map +1 -1
  78. package/dist/testing/core/interpolation.d.ts +1 -0
  79. package/dist/testing/core/interpolation.d.ts.map +1 -1
  80. package/dist/testing/core/interpolation.js +28 -68
  81. package/dist/testing/core/interpolation.js.map +1 -1
  82. package/dist/testing/core/path-resolution.spec.d.ts +2 -0
  83. package/dist/testing/core/path-resolution.spec.d.ts.map +1 -0
  84. package/dist/testing/core/path-resolution.spec.js +106 -0
  85. package/dist/testing/core/path-resolution.spec.js.map +1 -0
  86. package/dist/testing/core/path-segments.d.ts +2 -0
  87. package/dist/testing/core/path-segments.d.ts.map +1 -0
  88. package/dist/testing/core/path-segments.js +70 -0
  89. package/dist/testing/core/path-segments.js.map +1 -0
  90. package/dist/testing/core/resource-store.d.ts.map +1 -1
  91. package/dist/testing/core/resource-store.js +5 -2
  92. package/dist/testing/core/resource-store.js.map +1 -1
  93. package/dist/testing/core/testing-engine.d.ts.map +1 -1
  94. package/dist/testing/core/testing-engine.js +16 -0
  95. package/dist/testing/core/testing-engine.js.map +1 -1
  96. package/dist/testing/reporter/artifact-sink.d.ts +14 -0
  97. package/dist/testing/reporter/artifact-sink.d.ts.map +1 -0
  98. package/dist/testing/reporter/artifact-sink.js +3 -0
  99. package/dist/testing/reporter/artifact-sink.js.map +1 -0
  100. package/dist/testing/reporter/console-reporter.d.ts.map +1 -1
  101. package/dist/testing/reporter/console-reporter.js +7 -0
  102. package/dist/testing/reporter/console-reporter.js.map +1 -1
  103. package/dist/testing/reporter/file-service-artifact-sink.d.ts +22 -0
  104. package/dist/testing/reporter/file-service-artifact-sink.d.ts.map +1 -0
  105. package/dist/testing/reporter/file-service-artifact-sink.js +31 -0
  106. package/dist/testing/reporter/file-service-artifact-sink.js.map +1 -0
  107. package/dist/testing/reporter/lifecycle-events.types.d.ts +67 -0
  108. package/dist/testing/reporter/lifecycle-events.types.d.ts.map +1 -0
  109. package/dist/testing/reporter/lifecycle-events.types.js +3 -0
  110. package/dist/testing/reporter/lifecycle-events.types.js.map +1 -0
  111. package/dist/testing/reporter/lifecycle-webhook-reporter.d.ts +77 -0
  112. package/dist/testing/reporter/lifecycle-webhook-reporter.d.ts.map +1 -0
  113. package/dist/testing/reporter/lifecycle-webhook-reporter.js +275 -0
  114. package/dist/testing/reporter/lifecycle-webhook-reporter.js.map +1 -0
  115. package/dist/testing/reporter/progress-reporter.d.ts +3 -3
  116. package/dist/testing/reporter/progress-reporter.d.ts.map +1 -1
  117. package/dist/testing/reporter/progress-reporter.js.map +1 -1
  118. package/dist/testing/reporter/reporter.types.d.ts +13 -1
  119. package/dist/testing/reporter/reporter.types.d.ts.map +1 -1
  120. package/dist/testing/reporter/reporter.types.js.map +1 -1
  121. package/dist/testing/runner/read-scenarios-file.d.ts +9 -0
  122. package/dist/testing/runner/read-scenarios-file.d.ts.map +1 -0
  123. package/dist/testing/runner/read-scenarios-file.js +56 -0
  124. package/dist/testing/runner/read-scenarios-file.js.map +1 -0
  125. package/dist/testing/runner/run-from-metadata.d.ts +7 -2
  126. package/dist/testing/runner/run-from-metadata.d.ts.map +1 -1
  127. package/dist/testing/runner/run-from-metadata.js +57 -6
  128. package/dist/testing/runner/run-from-metadata.js.map +1 -1
  129. package/dist/testing/steps/ui/navigation.step.d.ts.map +1 -1
  130. package/dist/testing/steps/ui/navigation.step.js +12 -12
  131. package/dist/testing/steps/ui/navigation.step.js.map +1 -1
  132. package/package.json +4 -2
  133. package/postman/instant-logout.postman_collection.json +1493 -0
  134. package/resources/images/solid-core-menu-icon.png +0 -0
  135. package/src/commands/run-tests.command.ts +3 -0
  136. package/src/controllers/test-run.controller.ts +44 -0
  137. package/src/dtos/test-run-request.dto.ts +130 -0
  138. package/src/guards/access-token.guard.ts +48 -0
  139. package/src/index.ts +21 -1
  140. package/src/interfaces/active-user-data.interface.ts +8 -0
  141. package/src/jobs/database/test-run-queue-options-database.ts +9 -0
  142. package/src/jobs/database/test-run-queue-publisher-database.service.ts +24 -0
  143. package/src/jobs/database/test-run-queue-subscriber-database.service.ts +122 -0
  144. package/src/seeders/seed-data/solid-core-metadata.json +4 -2
  145. package/src/services/access-token-denylist.service.ts +145 -0
  146. package/src/services/authentication.service.ts +118 -2
  147. package/src/services/import-transaction.service.ts +19 -36
  148. package/src/services/module-metadata.service.ts +4 -4
  149. package/src/services/settings/default-settings-provider.service.ts +12 -0
  150. package/src/services/workflow-secret.service.ts +61 -2
  151. package/src/solid-core.module.ts +11 -0
  152. package/src/testing/README.md +44 -1
  153. package/src/testing/adapters/ui/browser-provisioner.ts +115 -0
  154. package/src/testing/adapters/ui/playwright-adapter.ts +255 -5
  155. package/src/testing/adapters/ui/ui.types.ts +14 -0
  156. package/src/testing/contracts/runtime-context.types.ts +6 -0
  157. package/src/testing/core/interpolation.ts +39 -69
  158. package/src/testing/core/path-resolution.spec.ts +133 -0
  159. package/src/testing/core/path-segments.ts +81 -0
  160. package/src/testing/core/resource-store.ts +8 -2
  161. package/src/testing/core/testing-engine.ts +22 -0
  162. package/src/testing/reporter/artifact-sink.ts +16 -0
  163. package/src/testing/reporter/console-reporter.ts +9 -0
  164. package/src/testing/reporter/file-service-artifact-sink.ts +46 -0
  165. package/src/testing/reporter/lifecycle-events.types.ts +103 -0
  166. package/src/testing/reporter/lifecycle-webhook-reporter.ts +385 -0
  167. package/src/testing/reporter/progress-reporter.ts +3 -1
  168. package/src/testing/reporter/reporter.types.ts +10 -1
  169. package/src/testing/runner/read-scenarios-file.ts +44 -0
  170. package/src/testing/runner/run-from-metadata.ts +102 -9
  171. package/src/testing/steps/ui/navigation.step.ts +24 -20
  172. package/dist/services/access-token-storage.service.d.ts +0 -1
  173. package/dist/services/access-token-storage.service.d.ts.map +0 -1
  174. package/dist/services/access-token-storage.service.js +0 -1
  175. package/dist/services/access-token-storage.service.js.map +0 -1
  176. package/src/services/access-token-storage.service.ts +0 -0
@@ -40,6 +40,7 @@ import { User } from "../entities/user.entity";
40
40
  import { EventDetails, EventType } from "../interfaces";
41
41
  import { ActiveUserData } from "../interfaces/active-user-data.interface";
42
42
  import { ActiveSessionStorageService } from "./active-session-storage.service";
43
+ import { AccessTokenDenylistService } from "./access-token-denylist.service";
43
44
  import { HashingService } from "./hashing.service";
44
45
  import {
45
46
  InvalidatedRefreshTokenError,
@@ -77,6 +78,7 @@ export class AuthenticationService {
77
78
  private readonly hashingService: HashingService,
78
79
  private readonly jwtService: JwtService,
79
80
  private readonly activeSessionStorage: ActiveSessionStorageService,
81
+ private readonly accessTokenDenylist: AccessTokenDenylistService,
80
82
  private readonly refreshTokenIdsStorage: RefreshTokenIdsStorageService,
81
83
  private readonly httpService: HttpService,
82
84
  // private readonly mailService: SMTPEMailService,
@@ -2356,15 +2358,34 @@ export class AuthenticationService {
2356
2358
  }
2357
2359
 
2358
2360
  const userId = payload.sub;
2361
+
2359
2362
  if (allDevices) {
2363
+ // Deliberately not gated on session ownership: "log out everywhere"
2364
+ // treats any verifiable refresh token as sufficient authority, which is
2365
+ // what invalidateAll already accepts.
2360
2366
  await this.refreshTokenIdsStorage.invalidateAll(userId);
2361
- } else {
2367
+ await this.accessTokenDenylist.revokeAllForUser(userId);
2368
+ } else if (await this.ownsLiveSession(userId, payload, refreshToken)) {
2362
2369
  // Scoped to the bucket this token belongs to, so signing out on one
2363
2370
  // device leaves the others alive. A token with no deviceKey names the
2364
2371
  // single slot, which is the same key as before.
2365
2372
  await this.refreshTokenIdsStorage.invalidate(userId, payload.deviceKey);
2373
+ await this.accessTokenDenylist.revokeSession(
2374
+ userId,
2375
+ await this.resolveRevocationKey(userId, payload),
2376
+ );
2377
+ await this.activeSessionStorage.clearActiveSession(userId);
2378
+ } else {
2379
+ // The token names a session that is already gone - superseded by a
2380
+ // newer login into the same bucket. Acting on it would destroy the
2381
+ // session that replaced it. Nothing to do, but log it: the failure is
2382
+ // otherwise invisible, and a cluster of these means clients are losing
2383
+ // token state and their logouts are no-ops.
2384
+ this.logger.warn(
2385
+ `logout: refresh token does not match the live session for user ${userId}; nothing invalidated`,
2386
+ );
2366
2387
  }
2367
- await this.activeSessionStorage.clearActiveSession(userId);
2388
+
2368
2389
  const user = await this.userRepository.findOne({
2369
2390
  where: {
2370
2391
  id: userId,
@@ -2387,6 +2408,101 @@ export class AuthenticationService {
2387
2408
  }
2388
2409
  }
2389
2410
 
2411
+ /**
2412
+ * Whether this refresh token belongs to the session that currently occupies
2413
+ * its bucket.
2414
+ *
2415
+ * A per-device bucket holds exactly one session - deviceKey is a fresh
2416
+ * randomUUID per login - so a token carrying one is that session by
2417
+ * construction and needs no further proof. Only the bare `user-<id>` bucket
2418
+ * is shared across logins (preventConcurrentLogins on, or a pre-migration
2419
+ * token), and that is the sole case where a stale token can name a session
2420
+ * that has since been replaced. Without this, a laptop whose session was
2421
+ * superseded by a phone login would, on logging out, destroy the phone's
2422
+ * session instead of its own.
2423
+ *
2424
+ * Matches current OR previous, and deliberately does not consult
2425
+ * isWithinGraceWindow: that window governs whether a rotated-out token may
2426
+ * still be exchanged, whereas the question here is only which session the
2427
+ * token belongs to - which stays true however long ago it rotated.
2428
+ *
2429
+ * Missing state means it owns nothing. Safe, because the refresh state and
2430
+ * active-session entry share one cache: if the state is gone the session
2431
+ * entry is too, so the actions being skipped were no-ops anyway.
2432
+ */
2433
+ private async ownsLiveSession(
2434
+ userId: number,
2435
+ payload: { deviceKey?: string },
2436
+ refreshToken: string,
2437
+ ): Promise<boolean> {
2438
+ if (payload.deviceKey) {
2439
+ return true;
2440
+ }
2441
+
2442
+ const state =
2443
+ await this.refreshTokenIdsStorage.getCurrentRefreshTokenState(userId);
2444
+ return (
2445
+ state?.currentRefreshToken === refreshToken ||
2446
+ (!!state?.previousRefreshToken &&
2447
+ state.previousRefreshToken === refreshToken)
2448
+ );
2449
+ }
2450
+
2451
+ /**
2452
+ * The key a session's access tokens are denylisted under. Mirrors how the
2453
+ * access token itself is stamped: deviceKey while concurrent logins are
2454
+ * allowed, sessionId while they are not - and in the latter case only one
2455
+ * session exists, so the recorded active session IS this session's id.
2456
+ *
2457
+ * Must be read before clearActiveSession, which deletes the value it reads.
2458
+ */
2459
+ private async resolveRevocationKey(
2460
+ userId: number,
2461
+ payload: { deviceKey?: string },
2462
+ ): Promise<string | undefined> {
2463
+ return (
2464
+ payload.deviceKey ??
2465
+ (await this.activeSessionStorage.getActiveSession(userId))
2466
+ );
2467
+ }
2468
+
2469
+ /**
2470
+ * Public seam for consuming projects that need a logout of their own shape -
2471
+ * for instance revoking the access token while deliberately leaving the
2472
+ * refresh token valid.
2473
+ *
2474
+ * Verifies the refresh token (signature/audience/issuer enforced, expiry
2475
+ * tolerated), confirms it owns the live session, and returns the key its
2476
+ * access tokens are denylisted under.
2477
+ *
2478
+ * null - unverifiable: forged, malformed, or an access
2479
+ * token presented here
2480
+ * revocationKey undefined - verified, but nothing safe to revoke: either a
2481
+ * superseded session, or no session key could be
2482
+ * resolved. userId is still returned so the caller
2483
+ * can log the event or call revokeAllForUser.
2484
+ *
2485
+ * logout() uses this same method, so the seam cannot silently rot.
2486
+ */
2487
+ async resolveSessionRevocationKey(
2488
+ refreshToken: string,
2489
+ ): Promise<{ userId: number; revocationKey?: string } | null> {
2490
+ const payload = await this.verifyRefreshTokenForLogout(refreshToken);
2491
+ if (!payload?.sub) {
2492
+ return null;
2493
+ }
2494
+
2495
+ const userId = payload.sub;
2496
+ if (!(await this.ownsLiveSession(userId, payload, refreshToken))) {
2497
+ return { userId };
2498
+ }
2499
+
2500
+ return {
2501
+ userId,
2502
+ revocationKey: await this.resolveRevocationKey(userId, payload),
2503
+ };
2504
+ }
2505
+
2390
2506
  private async verifyRefreshTokenForLogout(
2391
2507
  refreshToken: string,
2392
2508
  ): Promise<{ sub: number; deviceKey?: string } | null> {
@@ -607,12 +607,28 @@ export class ImportTransactionService extends CRUDService<ImportTransaction> {
607
607
  //FIXME Currently below method fails if any field in the record is not valid or if the record is not valid. It does not collect the errors for all fields in a record
608
608
  private async insertRecord(record: Record<string, any>, mapping: ImportMapping[], modelMetadataWithFields: ModelMetadata, modelService: CRUDService<any>): Promise<any> {
609
609
  // Convert the imported record to a DTO
610
- const dto = await this.convertImportedRecordToDto(record, mapping, modelMetadataWithFields);
610
+ const dto = await this.convertImportedRecordToDto(record, mapping, modelMetadataWithFields, modelService);
611
611
  // Use the model service to create the record in the database
612
612
  const createdRecord = await modelService.create(dto, [], {}); //FIXME: Need to handle this part alongwith the refactoring of the CRUDService for permissions
613
613
  return createdRecord; // Return the created record
614
614
  }
615
615
 
616
+ // Import files do not contain hidden internal fields. Add the entity's
617
+ // defaults before CRUD validation so those fields do not fail as missing.
618
+ private applyImportDefaults(dto: Record<string, any>, modelService: CRUDService<any>): void {
619
+ for (const column of modelService.repo.metadata.columns) {
620
+ if (dto[column.propertyName] !== undefined || column.default === undefined) {
621
+ continue;
622
+ }
623
+
624
+ // Leave SQL expression defaults (such as CURRENT_TIMESTAMP) for the
625
+ // database instead of putting the expression text into the DTO.
626
+ if (typeof column.default !== 'function') {
627
+ dto[column.propertyName] = column.default;
628
+ }
629
+ }
630
+ }
631
+
616
632
  private getModelService(modelSingularName: string): CRUDService<any> {
617
633
  // Get the model service for the model metadata name
618
634
  const modelServiceWrapper = this.introspectService.getProvider(`${classify(modelSingularName)}Service`);
@@ -623,7 +639,7 @@ export class ImportTransactionService extends CRUDService<ImportTransaction> {
623
639
  return modelService;
624
640
  }
625
641
 
626
- private async convertImportedRecordToDto(record: Record<string, any>, mapping: ImportMapping[], modelMetadataWithFields: ModelMetadata) {
642
+ private async convertImportedRecordToDto(record: Record<string, any>, mapping: ImportMapping[], modelMetadataWithFields: ModelMetadata, modelService: CRUDService<any>) {
627
643
  // Create a new record object
628
644
  const dtoRecord: Record<string, any> = {};
629
645
 
@@ -644,43 +660,10 @@ export class ImportTransactionService extends CRUDService<ImportTransaction> {
644
660
  }
645
661
  }
646
662
 
647
- // Some fields are hidden from the import file because users should not
648
- // have to enter internal values such as passwordSchemeVersion. The
649
- // backend still checks those fields as required, so fill in their default
650
- // values here before validation. This lets users import normal details
651
- // without exposing or requiring internal fields in the template.
652
- for (const field of modelMetadataWithFields.fields) {
653
- if (
654
- field.required &&
655
- field.defaultValue !== null &&
656
- field.defaultValue !== undefined &&
657
- !(field.name in dtoRecord)
658
- ) {
659
- dtoRecord[field.name] = this.parseImportDefaultValue(field);
660
- }
661
- }
662
-
663
+ this.applyImportDefaults(dtoRecord, modelService);
663
664
  return dtoRecord;
664
665
  }
665
666
 
666
- // Defaults come from metadata as text. Convert them to the type expected by
667
- // validation, so values like "1" and "true" are handled as a number and a
668
- // boolean instead of being treated as plain text.
669
- private parseImportDefaultValue(field: FieldMetadata): any {
670
- const defaultValue = field.defaultValue;
671
-
672
- switch (field.type) {
673
- case SolidFieldType.boolean:
674
- return ['true', '1', 'yes'].includes(String(defaultValue).toLowerCase());
675
- case SolidFieldType.int:
676
- case SolidFieldType.bigint:
677
- case SolidFieldType.decimal:
678
- return Number(defaultValue);
679
- default:
680
- return defaultValue;
681
- }
682
- }
683
-
684
667
  private async populateDtoForACell(dtoRecord: Record<string, any>, fieldMetadata: FieldMetadata, record: Record<string, any>, key: string): Promise<Record<string, any>> {
685
668
  const fieldType = fieldMetadata.type;
686
669
  // const userKeyFieldName = userKeyField?.name || 'id'; // Default to 'id' if not found
@@ -142,8 +142,8 @@ export class ModuleMetadataService {
142
142
  throw new BadRequestException('Dangerous file types are not allowed for the menu icon.');
143
143
  }
144
144
  const fileStoragePath = this.getFullFilePathForDisk(this.getFileName(files[0]));
145
- this.fileService.copy(files[0].path, fileStoragePath);
146
- this.fileService.delete(files[0].path);
145
+ await this.fileService.copy(files[0].path, fileStoragePath);
146
+ await this.fileService.delete(files[0].path);
147
147
  createDto.menuIconUrl = fileStoragePath;
148
148
  }
149
149
  const moduleMetadata = this.moduleMetadataRepo.create(createDto);
@@ -250,8 +250,8 @@ export class ModuleMetadataService {
250
250
  }
251
251
 
252
252
  const fileStoragePath = this.getFullFilePathForDisk(this.getFileName(files[0]));
253
- this.fileService.copy(files[0].path, fileStoragePath);
254
- this.fileService.delete(files[0].path);
253
+ await this.fileService.copy(files[0].path, fileStoragePath);
254
+ await this.fileService.delete(files[0].path);
255
255
  module.menuIconUrl = fileStoragePath;
256
256
  }
257
257
  return manager.save(ModuleMetadata, module);
@@ -1263,6 +1263,18 @@ const getSolidCoreSettings = (isProd: boolean) =>
1263
1263
  sortOrder: 200,
1264
1264
  controlType: "boolean",
1265
1265
  },
1266
+ {
1267
+ moduleName: "solid-core",
1268
+ key: "invalidateAccessTokenOnLogout",
1269
+ value: false,
1270
+ level: SettingLevel.SystemAdminEditable,
1271
+ label: "Invalidate Access Token On Logout",
1272
+ group: "authentication-settings",
1273
+ sortOrder: 205,
1274
+ controlType: "boolean",
1275
+ helpText:
1276
+ "Rejects a session's access token on the next request after logout, instead of letting it live out its remaining lifetime. Adds a cache lookup to every authenticated request, and needs Redis configured to take effect across multiple replicas.",
1277
+ },
1266
1278
  {
1267
1279
  moduleName: "solid-core",
1268
1280
  key: "mpinEnabled",
@@ -1,7 +1,7 @@
1
- import { BadRequestException, Injectable, Logger } from '@nestjs/common';
1
+ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
2
2
  import { InjectEntityManager } from '@nestjs/typeorm';
3
3
  import { ModuleRef } from "@nestjs/core";
4
- import { EntityManager } from 'typeorm';
4
+ import { EntityManager, In } from 'typeorm';
5
5
  import { CRUDService } from 'src/services/crud.service';
6
6
  import { WorkflowSecret } from '../entities/workflow-secret.entity';
7
7
  import { WorkflowSecretRepository } from '../repository/workflow-secret.repository';
@@ -88,6 +88,65 @@ export class WorkflowSecretService extends CRUDService<WorkflowSecret> {
88
88
  return context;
89
89
  }
90
90
 
91
+ /**
92
+ * Resolves one secret to its decrypted, type-coerced value.
93
+ *
94
+ * `key` is the literal key column value — "smtp.password" is one row, not a path.
95
+ * Callers must resolve at point of use and must not cache the result.
96
+ */
97
+ async resolve(key: string): Promise<any> {
98
+ const resolved = await this.resolveMany([key]);
99
+ return resolved[key];
100
+ }
101
+
102
+ /**
103
+ * Resolves whatever of `keys` exists and is active, in a single query, omitting the
104
+ * rest. Returns a flat map keyed by the literal keys requested.
105
+ *
106
+ * For callers that can report a missing key better than this service can — the test
107
+ * runner lets an unresolved key surface at the step that references it, which names
108
+ * the token and locates the failure. Prefer resolveMany elsewhere.
109
+ *
110
+ * Still fail-closed on infrastructure: an absent APP_ENCRYPTION_KEY or a failed
111
+ * decrypt throws rather than yielding a partial map.
112
+ */
113
+ async resolveAvailable(keys: string[]): Promise<Record<string, any>> {
114
+ const uniqueKeys = Array.from(new Set((keys ?? []).filter(Boolean)));
115
+ if (uniqueKeys.length === 0) {
116
+ return {};
117
+ }
118
+
119
+ const secrets = await this.repo.find({
120
+ where: { key: In(uniqueKeys), status: "active" } as any,
121
+ });
122
+
123
+ return secrets.reduce((resolved, secret) => {
124
+ resolved[secret.key] = this.decryptAndCoerce(secret);
125
+ return resolved;
126
+ }, {} as Record<string, any>);
127
+ }
128
+
129
+ /**
130
+ * Resolves several secrets in a single query, returning a flat map keyed by the
131
+ * literal keys requested.
132
+ *
133
+ * Fail-closed: throws for a key that is missing, inactive or undecryptable rather
134
+ * than omitting it from the result. A credential that silently resolves to undefined
135
+ * becomes an empty password on an outbound request.
136
+ */
137
+ async resolveMany(keys: string[]): Promise<Record<string, any>> {
138
+ const resolved = await this.resolveAvailable(keys);
139
+
140
+ const missing = Array.from(new Set((keys ?? []).filter(Boolean))).filter(
141
+ (key) => !(key in resolved),
142
+ );
143
+ if (missing.length > 0) {
144
+ throw new NotFoundException(`No active secret found for: ${missing.join(', ')}`);
145
+ }
146
+
147
+ return resolved;
148
+ }
149
+
91
150
  private prepareSecretForSave(dto: any, requireValue: boolean): any {
92
151
  const preparedDto = { ...dto };
93
152
  const rawValue = preparedDto.value;
@@ -100,6 +100,7 @@ import { OTPAuthenticationController } from "./controllers/otp-authentication.co
100
100
  import { ServiceController } from "./controllers/service.controller";
101
101
  import { SmsTemplateController } from "./controllers/sms-template.controller";
102
102
  import { TestQueueController } from "./controllers/test-queue.controller";
103
+ import { TestRunController } from "./controllers/test-run.controller";
103
104
  import { EmailAttachment } from "./entities/email-attachment.entity";
104
105
  import { EmailTemplate } from "./entities/email-template.entity";
105
106
  import { MenuItemMetadata } from "./entities/menu-item-metadata.entity";
@@ -117,6 +118,8 @@ import { ApiEmailQueuePublisher } from "./jobs/rabbitmq/api-email-publisher.serv
117
118
  import { ApiEmailQueueSubscriber } from "./jobs/rabbitmq/api-email-subscriber.service";
118
119
  import { TestQueuePublisherDatabase } from "./jobs/database/test-queue-publisher-database.service";
119
120
  import { TestQueueSubscriberDatabase } from "./jobs/database/test-queue-subscriber-database.service";
121
+ import { TestRunQueuePublisherDatabase } from "./jobs/database/test-run-queue-publisher-database.service";
122
+ import { TestRunQueueSubscriberDatabase } from "./jobs/database/test-run-queue-subscriber-database.service";
120
123
  import { TestQueuePublisherRedis } from "./jobs/redis/test-queue-publisher-redis.service";
121
124
  import { TestQueueSubscriberRedis } from "./jobs/redis/test-queue-subscriber-redis.service";
122
125
  import { Msg91WhatsappQueuePublisher } from "./jobs/rabbitmq/msg91-whatsapp-publisher.service";
@@ -167,6 +170,7 @@ import { GoogleOauthStrategy } from "./passport-strategies/google-oauth.strategy
167
170
  import { ApiKeyService } from "./services/api-key.service";
168
171
  import { MpinService } from "./services/mpin.service";
169
172
  import { ActiveSessionStorageService } from "./services/active-session-storage.service";
173
+ import { AccessTokenDenylistService } from "./services/access-token-denylist.service";
170
174
  import { AuthenticationService } from "./services/authentication.service";
171
175
  import { MetadataValidationService } from "./services/metadata-validation.service";
172
176
  import { BcryptService } from "./services/bcrypt.service";
@@ -626,6 +630,7 @@ import { SwitchNode } from './services/workflow/nodes/switch.node';
626
630
  SmsTemplateController,
627
631
  TestController,
628
632
  TestQueueController,
633
+ TestRunController,
629
634
  UserActivityHistoryController,
630
635
  UserController,
631
636
  UserViewMetadataController,
@@ -778,6 +783,7 @@ import { SwitchNode } from './services/workflow/nodes/switch.node';
778
783
  ApiKeyService,
779
784
  MpinService,
780
785
  ActiveSessionStorageService,
786
+ AccessTokenDenylistService,
781
787
  AuthenticationService,
782
788
  GoogleAuthenticationController,
783
789
  RefreshTokenIdsStorageService,
@@ -802,6 +808,8 @@ import { SwitchNode } from './services/workflow/nodes/switch.node';
802
808
 
803
809
  TestQueuePublisherDatabase,
804
810
  TestQueueSubscriberDatabase,
811
+ TestRunQueuePublisherDatabase,
812
+ TestRunQueueSubscriberDatabase,
805
813
  TestQueuePublisherRedis,
806
814
  TestQueueSubscriberRedis,
807
815
  ApiEmailQueuePublisherRedis,
@@ -996,6 +1004,9 @@ import { SwitchNode } from './services/workflow/nodes/switch.node';
996
1004
  SolidXSendSmsNode,
997
1005
  ],
998
1006
  exports: [
1007
+ // Exported for DI, not merely re-exported from index.ts: a consuming
1008
+ // project composing its own logout has to be able to inject this.
1009
+ AccessTokenDenylistService,
999
1010
  AuthenticationService,
1000
1011
  ChatterMessageDetailsRepository,
1001
1012
  ChatterMessageDetailsService,
@@ -8,6 +8,21 @@
8
8
  - `reporter/`: reporting interfaces + console reporter
9
9
  - `runner/`: lifecycle helpers + metadata runner
10
10
 
11
+ ## Browser Setup
12
+ UI scenarios need a Chromium build. The npm `playwright` package ships no install script, so
13
+ the binary is fetched separately.
14
+
15
+ - **By default it is lazy.** The first test run that selects a UI scenario downloads Chromium
16
+ (~150MB) before the run starts, then reuses it. Runs filtered to API-only scenarios never
17
+ download anything, and neither do projects that have no UI scenarios.
18
+ - **To fetch it at install time instead**, set `COMMON_UI_TEST_BROWSERS_EAGER_INSTALL=true`
19
+ either in the API project's `.env` or as a real environment variable. Useful for CI images.
20
+ A failed download never fails the install. Note that `npm install --ignore-scripts` skips
21
+ this entirely, in which case the lazy path still applies.
22
+ - **In Docker**, browsers land in `~/.cache/ms-playwright` (Linux). A multi-stage build that
23
+ installs as `root` but runs as another user must set `PLAYWRIGHT_BROWSERS_PATH` to a shared
24
+ location, or the runtime user will not find the binary.
25
+
11
26
  ## Metadata Shape
12
27
  `TestingMetadata` lives under `testing` in module metadata JSON files.
13
28
 
@@ -252,11 +267,14 @@ Options in `with`:
252
267
  - `timeoutMs` (optional, overrides the run-wide UI navigation timeout)
253
268
 
254
269
  ### **Op: `ui.expectUrl`**
255
- Description: Asserts the current page URL.
270
+ Description: Waits for the page URL to match, then asserts it. Backed by `page.waitForURL`, so an
271
+ assertion placed straight after a navigating click does not race the navigation. Returns
272
+ immediately when the URL already matches.
256
273
 
257
274
  Options in `with`:
258
275
  - `equals` (optional)
259
276
  - `contains` (optional)
277
+ - `timeoutMs` (optional, overrides the run-wide UI navigation timeout)
260
278
 
261
279
  ### **Op: `ui.fill`**
262
280
  Description: Fills an input or editable element.
@@ -289,6 +307,19 @@ Options in `with`:
289
307
  - `key` (required)
290
308
  - `timeoutMs` (optional, overrides the run-wide UI timeout)
291
309
 
310
+ ### **Op: `ui.uploadFile`**
311
+ Description: Sets one or more files on a file input.
312
+
313
+ Options in `with`:
314
+ - `selector` (required, the `<input type="file">`)
315
+ - `filePath` (required, a path or an array of paths)
316
+ - `timeoutMs` (optional, overrides the run-wide UI timeout)
317
+
318
+ Notes:
319
+ - Paths are resolved against the run's working directory, so a relative path is relative to where
320
+ `solid test run` was invoked.
321
+ - Pass an array to populate a `multiple` input; a single string is the common case.
322
+
292
323
  ### **Op: `ui.waitForManual`**
293
324
  Description: Pauses a headed Playwright run so a human can interact with the live browser, then resumes when Enter is pressed in the terminal.
294
325
 
@@ -327,6 +358,18 @@ Options in `with`:
327
358
  - `selector` (required)
328
359
  - `timeoutMs` (optional, overrides the run-wide UI timeout)
329
360
 
361
+ ### **Op: `ui.expectHidden`**
362
+ Description: Waits for an element to be hidden — i.e. detached or no longer visible.
363
+
364
+ Options in `with`:
365
+ - `selector` (required)
366
+ - `timeoutMs` (optional, overrides the run-wide UI timeout)
367
+
368
+ Notes:
369
+ - The counterpart to `ui.expectVisible`: use it to wait for a modal, spinner, toast or overlay to
370
+ **go away** before the next step acts. Prefer it over `util.sleep` for that — it returns the moment
371
+ the element disappears instead of always spending the full duration.
372
+
330
373
  ### **Op: `ui.expectText`**
331
374
  Description: Asserts the text content of an element.
332
375
 
@@ -0,0 +1,115 @@
1
+ import { ChildProcess, spawn } from "child_process";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+
5
+ /**
6
+ * The npm `playwright` package ships no install script, so `npm install` places the
7
+ * JS module but never downloads the browser binary. These helpers fetch it on demand.
8
+ */
9
+
10
+ let inFlight: Promise<void> | null = null;
11
+ let activeInstall: ChildProcess | null = null;
12
+
13
+ /**
14
+ * Resolves Playwright's own CLI entry point.
15
+ *
16
+ * `require.resolve('playwright/cli.js')` fails with ERR_PACKAGE_PATH_NOT_EXPORTED because
17
+ * `cli.js` is absent from Playwright's `exports` map, so we resolve `package.json` (which
18
+ * is exported) and walk to its sibling.
19
+ */
20
+ export function resolvePlaywrightCli(): string | null {
21
+ try {
22
+ const packageJsonPath = require.resolve("playwright/package.json");
23
+ const cliPath = path.join(path.dirname(packageJsonPath), "cli.js");
24
+ return fs.existsSync(cliPath) ? cliPath : null;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Asking Playwright for the executable path keeps us in step with PLAYWRIGHT_BROWSERS_PATH
32
+ * and its per-version cache layout instead of second-guessing either.
33
+ */
34
+ export async function isChromiumInstalled(): Promise<boolean> {
35
+ try {
36
+ const { chromium } = await import("playwright");
37
+ return fs.existsSync(chromium.executablePath());
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Downloads Chromium unless it is already present. Only Chromium is fetched: it is the
45
+ * sole browser PlaywrightAdapter launches.
46
+ *
47
+ * Concurrent callers share one download. This matters once a server warms the browser up
48
+ * at boot while a test run may start before that finishes.
49
+ */
50
+ export function ensureChromiumInstalled(): Promise<void> {
51
+ if (!inFlight) {
52
+ // Clearing on settle rather than caching the result lets a failed download be retried
53
+ // by the next caller instead of poisoning the whole process lifetime.
54
+ inFlight = installChromium().finally(() => {
55
+ inFlight = null;
56
+ activeInstall = null;
57
+ });
58
+ }
59
+ return inFlight;
60
+ }
61
+
62
+ /**
63
+ * Stops a download that is still running, so shutting down mid-install does not orphan the
64
+ * child process against a half-written browser cache.
65
+ */
66
+ export function cancelChromiumInstall(): void {
67
+ activeInstall?.kill("SIGTERM");
68
+ }
69
+
70
+ async function installChromium(): Promise<void> {
71
+ if (await isChromiumInstalled()) return;
72
+
73
+ const cliPath = resolvePlaywrightCli();
74
+ if (!cliPath) {
75
+ throw new Error(
76
+ "Chromium is required for UI scenarios but the playwright package could not be resolved. " +
77
+ "Install it in the API project with: npm install playwright",
78
+ );
79
+ }
80
+
81
+ console.log(
82
+ "Chromium is required for UI scenarios and is not installed yet. Downloading it now (~150MB, one time)...",
83
+ );
84
+
85
+ await new Promise<void>((resolve, reject) => {
86
+ // Async spawn, never spawnSync: this also runs inside a live server during module init,
87
+ // where a synchronous child would block the event loop for the whole download.
88
+ const child = spawn(process.execPath, [cliPath, "install", "chromium"], {
89
+ stdio: "inherit",
90
+ });
91
+ activeInstall = child;
92
+
93
+ child.on("error", (error) =>
94
+ reject(
95
+ new Error(
96
+ `Failed to download Chromium: ${error.message}. ` +
97
+ "Install it manually with: npx playwright install chromium",
98
+ ),
99
+ ),
100
+ );
101
+
102
+ child.on("close", (code, signal) => {
103
+ if (code === 0) {
104
+ resolve();
105
+ return;
106
+ }
107
+ reject(
108
+ new Error(
109
+ `Chromium download ${signal ? `was stopped by ${signal}` : `exited with code ${code}`}. ` +
110
+ "Install it manually with: npx playwright install chromium",
111
+ ),
112
+ );
113
+ });
114
+ });
115
+ }