@solidxai/core 0.1.15 → 0.1.16-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/CHANGELOG.md +642 -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/seeders/seed-data/solid-core-metadata.json +3 -1
  38. package/dist/services/access-token-denylist.service.d.ts +16 -0
  39. package/dist/services/access-token-denylist.service.d.ts.map +1 -0
  40. package/dist/services/access-token-denylist.service.js +74 -0
  41. package/dist/services/access-token-denylist.service.js.map +1 -0
  42. package/dist/services/authentication.service.d.ts +9 -1
  43. package/dist/services/authentication.service.d.ts.map +1 -1
  44. package/dist/services/authentication.service.js +39 -4
  45. package/dist/services/authentication.service.js.map +1 -1
  46. package/dist/services/import-transaction.service.d.ts +1 -1
  47. package/dist/services/import-transaction.service.d.ts.map +1 -1
  48. package/dist/services/import-transaction.service.js +13 -23
  49. package/dist/services/import-transaction.service.js.map +1 -1
  50. package/dist/services/settings/default-settings-provider.service.d.ts +20 -0
  51. package/dist/services/settings/default-settings-provider.service.d.ts.map +1 -1
  52. package/dist/services/settings/default-settings-provider.service.js +11 -0
  53. package/dist/services/settings/default-settings-provider.service.js.map +1 -1
  54. package/dist/services/workflow-secret.service.d.ts +3 -0
  55. package/dist/services/workflow-secret.service.d.ts.map +1 -1
  56. package/dist/services/workflow-secret.service.js +25 -0
  57. package/dist/services/workflow-secret.service.js.map +1 -1
  58. package/dist/solid-core.module.d.ts.map +1 -1
  59. package/dist/solid-core.module.js +9 -0
  60. package/dist/solid-core.module.js.map +1 -1
  61. package/dist/testing/adapters/ui/browser-provisioner.d.ts +5 -0
  62. package/dist/testing/adapters/ui/browser-provisioner.d.ts.map +1 -0
  63. package/dist/testing/adapters/ui/browser-provisioner.js +102 -0
  64. package/dist/testing/adapters/ui/browser-provisioner.js.map +1 -0
  65. package/dist/testing/adapters/ui/playwright-adapter.d.ts +40 -1
  66. package/dist/testing/adapters/ui/playwright-adapter.d.ts.map +1 -1
  67. package/dist/testing/adapters/ui/playwright-adapter.js +185 -3
  68. package/dist/testing/adapters/ui/playwright-adapter.js.map +1 -1
  69. package/dist/testing/adapters/ui/ui.types.d.ts +6 -0
  70. package/dist/testing/adapters/ui/ui.types.d.ts.map +1 -1
  71. package/dist/testing/adapters/ui/ui.types.js.map +1 -1
  72. package/dist/testing/contracts/runtime-context.types.d.ts +2 -0
  73. package/dist/testing/contracts/runtime-context.types.d.ts.map +1 -1
  74. package/dist/testing/contracts/runtime-context.types.js.map +1 -1
  75. package/dist/testing/core/interpolation.d.ts +1 -0
  76. package/dist/testing/core/interpolation.d.ts.map +1 -1
  77. package/dist/testing/core/interpolation.js +28 -68
  78. package/dist/testing/core/interpolation.js.map +1 -1
  79. package/dist/testing/core/path-resolution.spec.d.ts +2 -0
  80. package/dist/testing/core/path-resolution.spec.d.ts.map +1 -0
  81. package/dist/testing/core/path-resolution.spec.js +106 -0
  82. package/dist/testing/core/path-resolution.spec.js.map +1 -0
  83. package/dist/testing/core/path-segments.d.ts +2 -0
  84. package/dist/testing/core/path-segments.d.ts.map +1 -0
  85. package/dist/testing/core/path-segments.js +70 -0
  86. package/dist/testing/core/path-segments.js.map +1 -0
  87. package/dist/testing/core/resource-store.d.ts.map +1 -1
  88. package/dist/testing/core/resource-store.js +5 -2
  89. package/dist/testing/core/resource-store.js.map +1 -1
  90. package/dist/testing/core/testing-engine.d.ts.map +1 -1
  91. package/dist/testing/core/testing-engine.js +16 -0
  92. package/dist/testing/core/testing-engine.js.map +1 -1
  93. package/dist/testing/reporter/artifact-sink.d.ts +14 -0
  94. package/dist/testing/reporter/artifact-sink.d.ts.map +1 -0
  95. package/dist/testing/reporter/artifact-sink.js +3 -0
  96. package/dist/testing/reporter/artifact-sink.js.map +1 -0
  97. package/dist/testing/reporter/console-reporter.d.ts.map +1 -1
  98. package/dist/testing/reporter/console-reporter.js +7 -0
  99. package/dist/testing/reporter/console-reporter.js.map +1 -1
  100. package/dist/testing/reporter/file-service-artifact-sink.d.ts +22 -0
  101. package/dist/testing/reporter/file-service-artifact-sink.d.ts.map +1 -0
  102. package/dist/testing/reporter/file-service-artifact-sink.js +31 -0
  103. package/dist/testing/reporter/file-service-artifact-sink.js.map +1 -0
  104. package/dist/testing/reporter/lifecycle-events.types.d.ts +67 -0
  105. package/dist/testing/reporter/lifecycle-events.types.d.ts.map +1 -0
  106. package/dist/testing/reporter/lifecycle-events.types.js +3 -0
  107. package/dist/testing/reporter/lifecycle-events.types.js.map +1 -0
  108. package/dist/testing/reporter/lifecycle-webhook-reporter.d.ts +77 -0
  109. package/dist/testing/reporter/lifecycle-webhook-reporter.d.ts.map +1 -0
  110. package/dist/testing/reporter/lifecycle-webhook-reporter.js +275 -0
  111. package/dist/testing/reporter/lifecycle-webhook-reporter.js.map +1 -0
  112. package/dist/testing/reporter/progress-reporter.d.ts +3 -3
  113. package/dist/testing/reporter/progress-reporter.d.ts.map +1 -1
  114. package/dist/testing/reporter/progress-reporter.js.map +1 -1
  115. package/dist/testing/reporter/reporter.types.d.ts +13 -1
  116. package/dist/testing/reporter/reporter.types.d.ts.map +1 -1
  117. package/dist/testing/reporter/reporter.types.js.map +1 -1
  118. package/dist/testing/runner/read-scenarios-file.d.ts +9 -0
  119. package/dist/testing/runner/read-scenarios-file.d.ts.map +1 -0
  120. package/dist/testing/runner/read-scenarios-file.js +56 -0
  121. package/dist/testing/runner/read-scenarios-file.js.map +1 -0
  122. package/dist/testing/runner/run-from-metadata.d.ts +7 -2
  123. package/dist/testing/runner/run-from-metadata.d.ts.map +1 -1
  124. package/dist/testing/runner/run-from-metadata.js +57 -6
  125. package/dist/testing/runner/run-from-metadata.js.map +1 -1
  126. package/dist/testing/steps/ui/navigation.step.d.ts.map +1 -1
  127. package/dist/testing/steps/ui/navigation.step.js +12 -12
  128. package/dist/testing/steps/ui/navigation.step.js.map +1 -1
  129. package/package.json +1 -1
  130. package/postman/instant-logout.postman_collection.json +1493 -0
  131. package/src/commands/run-tests.command.ts +3 -0
  132. package/src/controllers/test-run.controller.ts +44 -0
  133. package/src/dtos/test-run-request.dto.ts +130 -0
  134. package/src/guards/access-token.guard.ts +48 -0
  135. package/src/index.ts +21 -1
  136. package/src/interfaces/active-user-data.interface.ts +8 -0
  137. package/src/jobs/database/test-run-queue-options-database.ts +9 -0
  138. package/src/jobs/database/test-run-queue-publisher-database.service.ts +24 -0
  139. package/src/jobs/database/test-run-queue-subscriber-database.service.ts +122 -0
  140. package/src/seeders/seed-data/solid-core-metadata.json +3 -1
  141. package/src/services/access-token-denylist.service.ts +145 -0
  142. package/src/services/authentication.service.ts +118 -2
  143. package/src/services/import-transaction.service.ts +19 -36
  144. package/src/services/settings/default-settings-provider.service.ts +12 -0
  145. package/src/services/workflow-secret.service.ts +61 -2
  146. package/src/solid-core.module.ts +11 -0
  147. package/src/testing/README.md +44 -1
  148. package/src/testing/adapters/ui/browser-provisioner.ts +115 -0
  149. package/src/testing/adapters/ui/playwright-adapter.ts +255 -5
  150. package/src/testing/adapters/ui/ui.types.ts +14 -0
  151. package/src/testing/contracts/runtime-context.types.ts +6 -0
  152. package/src/testing/core/interpolation.ts +39 -69
  153. package/src/testing/core/path-resolution.spec.ts +133 -0
  154. package/src/testing/core/path-segments.ts +81 -0
  155. package/src/testing/core/resource-store.ts +8 -2
  156. package/src/testing/core/testing-engine.ts +22 -0
  157. package/src/testing/reporter/artifact-sink.ts +16 -0
  158. package/src/testing/reporter/console-reporter.ts +9 -0
  159. package/src/testing/reporter/file-service-artifact-sink.ts +46 -0
  160. package/src/testing/reporter/lifecycle-events.types.ts +103 -0
  161. package/src/testing/reporter/lifecycle-webhook-reporter.ts +385 -0
  162. package/src/testing/reporter/progress-reporter.ts +3 -1
  163. package/src/testing/reporter/reporter.types.ts +10 -1
  164. package/src/testing/runner/read-scenarios-file.ts +44 -0
  165. package/src/testing/runner/run-from-metadata.ts +102 -9
  166. package/src/testing/steps/ui/navigation.step.ts +24 -20
  167. package/dist/services/access-token-storage.service.d.ts +0 -1
  168. package/dist/services/access-token-storage.service.d.ts.map +0 -1
  169. package/dist/services/access-token-storage.service.js +0 -1
  170. package/dist/services/access-token-storage.service.js.map +0 -1
  171. package/src/services/access-token-storage.service.ts +0 -0
@@ -9,6 +9,7 @@ import { runFromMetadata } from 'src/testing/runner/run-from-metadata';
9
9
  import type { TestingMetadata } from 'src/testing/contracts/testing-metadata.types';
10
10
  import { SpecRegistry } from 'src/testing/core/spec-registry';
11
11
  import { SettingService } from 'src/services/setting.service';
12
+ import { WorkflowSecretService } from 'src/services/workflow-secret.service';
12
13
  import type { SolidCoreSetting } from 'src/services/settings/default-settings-provider.service';
13
14
 
14
15
  interface TestRunCommandOptions {
@@ -41,6 +42,7 @@ export class TestRunCommand extends CommandRunner {
41
42
  constructor(
42
43
  private readonly moduleMetadataHelperService: ModuleMetadataHelperService,
43
44
  private readonly settingService: SettingService,
45
+ private readonly workflowSecretService: WorkflowSecretService,
44
46
  ) {
45
47
  super();
46
48
  }
@@ -134,6 +136,7 @@ export class TestRunCommand extends CommandRunner {
134
136
  retries: options?.retries,
135
137
  },
136
138
  options: { printApiLogs },
139
+ resolveSecrets: (keys) => this.workflowSecretService.resolveAvailable(keys),
137
140
  specs: specEntries.length
138
141
  ? (registry) => loadSpecRegistrations(specEntries, metadataPath, registry)
139
142
  : undefined,
@@ -0,0 +1,44 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
3
+ import { ApiTags } from '@nestjs/swagger';
4
+
5
+ import { Auth } from 'src/decorators/auth.decorator';
6
+ import { Public } from 'src/decorators/public.decorator';
7
+ import { AuthType } from 'src/enums/auth-type.enum';
8
+ import { PublisherFactory } from 'src/services/queues/publisher-factory.service';
9
+ import {
10
+ TestRunJobPayload,
11
+ TestRunRequestDto,
12
+ } from 'src/dtos/test-run-request.dto';
13
+
14
+ /**
15
+ * Triggers a BDD test run from CUSTOM INPUT (inline scenarios). Enqueues the run and
16
+ * returns immediately; a worker (QUEUES_SERVICE_ROLE=subscriber) executes it and
17
+ * streams lifecycle webhooks to `dto.webhookUrl`. Reusable by any Solid app
18
+ * (testingHub calls this from its StartRunController).
19
+ */
20
+ @Auth(AuthType.None)
21
+ @Controller('test-runs')
22
+ @ApiTags('Solid Core')
23
+ export class TestRunController {
24
+ constructor(private readonly publisherFactory: PublisherFactory<TestRunJobPayload>) {}
25
+
26
+ @Public()
27
+ @Post()
28
+ @HttpCode(HttpStatus.ACCEPTED)
29
+ // Accept the raw body (not the typed DTO) so the global ValidationPipe's
30
+ // implicit-conversion does not coerce inline scenario objects into empty arrays.
31
+ async trigger(@Body() dto: TestRunRequestDto & Record<string, any>) {
32
+ const runId = randomUUID();
33
+ const jobId = await this.publisherFactory.publish(
34
+ {
35
+ payload: { ...dto, runId },
36
+ parentEntity: 'testRun',
37
+ },
38
+ 'TestRunQueuePublisher',
39
+ dto.broker,
40
+ );
41
+
42
+ return { runId, jobId, status: 'queued' };
43
+ }
44
+ }
@@ -0,0 +1,130 @@
1
+ import {
2
+ IsArray,
3
+ IsBoolean,
4
+ IsObject,
5
+ IsOptional,
6
+ IsString,
7
+ } from 'class-validator';
8
+
9
+ import type {
10
+ ScenarioSpec,
11
+ ScenarioType,
12
+ TestingDataRecord,
13
+ TestingRoleSpec,
14
+ TestingUserSpec,
15
+ } from '../testing/contracts/testing-metadata.types';
16
+
17
+ /**
18
+ * Per-scenario capture toggles. Structurally compatible with the typed
19
+ * `CaptureOptions` introduced in Phase 2 (A3, in adapters/ui/ui.types.ts).
20
+ */
21
+ export interface CaptureOptionsInput {
22
+ console?: boolean;
23
+ network?: boolean;
24
+ screenshotOnFailure?: boolean;
25
+ screenshotAtEnd?: boolean;
26
+ video?: boolean;
27
+ har?: boolean;
28
+ }
29
+
30
+ /**
31
+ * Body of `POST /test-runs`. Carries the FULL test definition inline (scenarios +
32
+ * optional data/roles/users — the `testing` block minus `specs`), so the runner is
33
+ * location-agnostic and never reads a fixed module/path. `specs` (custom step code)
34
+ * cannot travel inline and must be deployed on the worker.
35
+ */
36
+ export class TestRunRequestDto {
37
+ @IsOptional()
38
+ @IsArray()
39
+ scenarios?: ScenarioSpec[];
40
+
41
+ @IsOptional()
42
+ @IsString()
43
+ scenariosPath?: string;
44
+
45
+ @IsOptional()
46
+ @IsArray()
47
+ data?: TestingDataRecord[];
48
+
49
+ @IsOptional()
50
+ @IsArray()
51
+ roles?: TestingRoleSpec[];
52
+
53
+ @IsOptional()
54
+ @IsArray()
55
+ users?: TestingUserSpec[];
56
+
57
+ /** API base URL for relative request URLs. */
58
+ @IsOptional()
59
+ @IsString()
60
+ baseUrl?: string;
61
+
62
+ /** UI base URL for relative navigation (Playwright). */
63
+ @IsOptional()
64
+ @IsString()
65
+ uiBaseUrl?: string;
66
+
67
+ @IsOptional()
68
+ @IsString()
69
+ type?: ScenarioType;
70
+
71
+ @IsOptional()
72
+ @IsString()
73
+ env?: string;
74
+
75
+ @IsOptional()
76
+ @IsBoolean()
77
+ headless?: boolean;
78
+
79
+ @IsOptional()
80
+ @IsBoolean()
81
+ recordVideo?: boolean;
82
+
83
+ @IsOptional()
84
+ @IsArray()
85
+ includeTags?: string[];
86
+
87
+ @IsOptional()
88
+ @IsArray()
89
+ scenarioIds?: string[];
90
+
91
+ @IsOptional()
92
+ @IsArray()
93
+ skipScenarioIds?: string[];
94
+
95
+ /** Where lifecycle events are POSTed (e.g. testingHub ingest endpoint). */
96
+ @IsString()
97
+ webhookUrl!: string;
98
+
99
+ /** Correlation id supplied by the caller (e.g. testingHub TestRun id). */
100
+ @IsOptional()
101
+ @IsString()
102
+ externalRunId?: string;
103
+
104
+ /**
105
+ * Per-run env variables for `${env:X}` interpolation — e.g. { BASE_URL: app.url }
106
+ * so scenarios target the application under test rather than the runner itself.
107
+ * (Distinct from `env` above, which is the dev/uat/prod environment label.)
108
+ */
109
+ @IsOptional()
110
+ @IsObject()
111
+ variables?: Record<string, string>;
112
+
113
+ @IsOptional()
114
+ @IsObject()
115
+ capture?: CaptureOptionsInput;
116
+
117
+ @IsOptional()
118
+ @IsBoolean()
119
+ printApiLogs?: boolean;
120
+
121
+ /** Queue broker override: database | redis | rabbitmq (default: database). */
122
+ @IsOptional()
123
+ @IsString()
124
+ broker?: string;
125
+ }
126
+
127
+ /** Queue payload = the request plus the runner-generated run id. */
128
+ export interface TestRunJobPayload extends TestRunRequestDto {
129
+ runId: string;
130
+ }
@@ -13,6 +13,7 @@ import { REQUEST_USER_KEY } from "../constants";
13
13
  import { PermissionMetadataService } from '../services/permission-metadata.service';
14
14
  import { ClsService } from 'nestjs-cls';
15
15
  import { ActiveSessionStorageService } from "../services/active-session-storage.service";
16
+ import { AccessTokenDenylistService } from "../services/access-token-denylist.service";
16
17
  import { SettingService } from '../services/setting.service';
17
18
  import { createHash } from "crypto";
18
19
 
@@ -22,6 +23,7 @@ export class AccessTokenGuard implements CanActivate {
22
23
  private readonly jwtService: JwtService,
23
24
  private readonly permissionsService: PermissionMetadataService,
24
25
  private readonly activeSessionStorage: ActiveSessionStorageService,
26
+ private readonly accessTokenDenylist: AccessTokenDenylistService,
25
27
  private readonly settingService: SettingService,
26
28
  private readonly cls: ClsService
27
29
  ) { }
@@ -47,6 +49,13 @@ export class AccessTokenGuard implements CanActivate {
47
49
  jwtConfiguration
48
50
  );
49
51
 
52
+ // Immediate access-token revocation. Runs BEFORE the concurrent-login
53
+ // check on purpose: rejecting a revoked token first stops it reaching the
54
+ // adopt-on-empty branch below and reclaiming the active session slot.
55
+ // No setting check here - isRevoked short-circuits when the feature is
56
+ // off, without touching the cache.
57
+ await this.validateNotRevoked(payload, token);
58
+
50
59
  // Prevent Concurrent Login Feature
51
60
  await this.validateConcurrentLoginSession(payload, token);
52
61
 
@@ -83,6 +92,45 @@ export class AccessTokenGuard implements CanActivate {
83
92
  return createHash('sha256').update(token).digest('hex');
84
93
  }
85
94
 
95
+ /**
96
+ * The key this token's session is denylisted under.
97
+ *
98
+ * Deliberately NOT merged with resolveSessionId above, which differs only by
99
+ * the deviceKey step. Merging them would change validateConcurrentLoginSession
100
+ * for tokens issued while preventConcurrentLogins was off that are still live
101
+ * when it is switched on: those resolve to sha256(token) today and would
102
+ * resolve to deviceKey instead. The divergence is invisible in normal
103
+ * operation - that check only runs while the setting is on, and tokens issued
104
+ * in that state never carry a deviceKey - so the two formulas already agree
105
+ * for every token it sees. Only toggle-crossover tokens tell them apart.
106
+ */
107
+ private resolveRevocationKey(payload: ActiveUserData, token: string): string {
108
+ return (
109
+ payload.sessionId ??
110
+ payload.deviceKey ??
111
+ createHash('sha256').update(token).digest('hex')
112
+ );
113
+ }
114
+
115
+ private async validateNotRevoked(payload: ActiveUserData, token: string): Promise<void> {
116
+ // isRevoked would short-circuit on its own, but resolving the key first can
117
+ // cost a sha256 for a token carrying neither claim. Checking here keeps the
118
+ // opted-out path genuinely free.
119
+ if (!this.accessTokenDenylist.isEnabled()) {
120
+ return;
121
+ }
122
+
123
+ const revocationKey = this.resolveRevocationKey(payload, token);
124
+ const revoked = await this.accessTokenDenylist.isRevoked(
125
+ payload.sub,
126
+ revocationKey,
127
+ payload.iat,
128
+ );
129
+ if (revoked) {
130
+ throw new UnauthorizedException(ERROR_MESSAGES.SESSION_INVALID);
131
+ }
132
+ }
133
+
86
134
  private async validateConcurrentLoginSession(payload: ActiveUserData, token: string,): Promise<void> {
87
135
  if (!this.settingService.getConfigValue<SolidCoreSetting>("preventConcurrentLogins",)) {
88
136
  return;
package/src/index.ts CHANGED
@@ -319,6 +319,15 @@ export * from './jobs/redis/smtp-email-subscriber-redis.service'
319
319
  export * from './jobs/redis/test-queue-publisher-redis.service'
320
320
  export * from './jobs/redis/test-queue-options-redis'
321
321
  export * from './jobs/redis/test-queue-subscriber-redis.service'
322
+ export * from './jobs/database/test-run-queue-publisher-database.service'
323
+ export * from './jobs/database/test-run-queue-subscriber-database.service'
324
+ export * from './controllers/test-run.controller'
325
+ export * from './dtos/test-run-request.dto'
326
+ export * from './testing/reporter/lifecycle-events.types'
327
+ export * from './testing/reporter/lifecycle-webhook-reporter'
328
+ export * from './testing/reporter/artifact-sink'
329
+ export * from './testing/reporter/file-service-artifact-sink'
330
+ export * from './services/file/file-service.interface'
322
331
  export * from './jobs/redis/three60-whatsapp-publisher-redis.service'
323
332
  export * from './jobs/redis/three60-whatsapp-queue-options-redis'
324
333
  export * from './jobs/redis/three60-whatsapp-subscriber-redis.service'
@@ -339,7 +348,7 @@ export * from './services/selection-providers/list-of-values-selection-providers
339
348
  export * from './seeders/permission-metadata-seeder.service'
340
349
  export * from './seeders/module-metadata-seeder.service'
341
350
 
342
- // export * from './services/access-token-storage.service'
351
+ export * from './services/access-token-denylist.service'
343
352
  export * from './services/action-metadata.service'
344
353
  export * from './services/authentication.service'
345
354
  export * from './services/bcrypt.service'
@@ -471,3 +480,14 @@ export * from './solid-core.module'
471
480
  export * from './winston.logger'
472
481
 
473
482
  export { ERROR_MESSAGES } from './constants/error-messages'
483
+ // Needed by consuming projects composing their own logout, so they can return
484
+ // the same response body as the built-in one.
485
+ export { SUCCESS_MESSAGES } from './constants/success-messages'
486
+
487
+ // Named rather than `export *` so the testing internals stay private: consumers only need
488
+ // to provision the browser, which Test Hub does on module init.
489
+ export {
490
+ ensureChromiumInstalled,
491
+ isChromiumInstalled,
492
+ cancelChromiumInstall,
493
+ } from './testing/adapters/ui/browser-provisioner'
@@ -37,6 +37,14 @@ export interface ActiveUserData {
37
37
  */
38
38
  roles: string[];
39
39
 
40
+ /**
41
+ * Standard JWT "issued at", in whole seconds. Stamped automatically by
42
+ * signAsync on every token - declared here only because it was never typed.
43
+ * Compared against a revocation instant to decide whether this token predates
44
+ * a logout.
45
+ */
46
+ iat?: number;
47
+
40
48
  /**
41
49
  * The subject's (user) permissions.
42
50
  * These are not part of the JWT token, we query them from the database each time the access-token guard is run.
@@ -0,0 +1,9 @@
1
+ import { BrokerType } from "../../interfaces";
2
+
3
+ const QUEUE_NAME = 'solid_test_run_queue_db';
4
+
5
+ export default {
6
+ name: QUEUE_NAME,
7
+ type: BrokerType.Database,
8
+ queueName: QUEUE_NAME,
9
+ };
@@ -0,0 +1,24 @@
1
+ import { Injectable } from '@nestjs/common';
2
+
3
+ import testRunQueueConfig from './test-run-queue-options-database';
4
+ import { MqMessageQueueService } from '../../services/mq-message-queue.service';
5
+ import { MqMessageService } from '../../services/mq-message.service';
6
+ import { QueuesModuleOptions } from "../../interfaces";
7
+ import { DatabasePublisher } from 'src/services/queues/database-publisher.service';
8
+ import { TestRunJobPayload } from '../../dtos/test-run-request.dto';
9
+
10
+ @Injectable()
11
+ export class TestRunQueuePublisherDatabase extends DatabasePublisher<TestRunJobPayload> {
12
+ constructor(
13
+ protected readonly mqMessageService: MqMessageService,
14
+ protected readonly mqMessageQueueService: MqMessageQueueService,
15
+ ) {
16
+ super(mqMessageService, mqMessageQueueService);
17
+ }
18
+
19
+ options(): QueuesModuleOptions {
20
+ return {
21
+ ...testRunQueueConfig
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,122 @@
1
+ import { Inject, Injectable, Logger } from '@nestjs/common';
2
+ import { HttpService } from '@nestjs/axios';
3
+
4
+ import { QueueMessage } from 'src/interfaces/mq';
5
+ import testRunQueueConfig from './test-run-queue-options-database';
6
+ import { MqMessageService } from '../../services/mq-message.service';
7
+ import { MqMessageQueueService } from '../../services/mq-message-queue.service';
8
+ import { QueuesModuleOptions } from "../../interfaces";
9
+ import { DatabaseSubscriber } from 'src/services/queues/database-subscriber.service';
10
+ import { PollerService } from 'src/services/poller.service';
11
+ import { runFromMetadata } from '../../testing/runner/run-from-metadata';
12
+ import { readScenariosFile } from '../../testing/runner/read-scenarios-file';
13
+ import {
14
+ LifecycleWebhookReporter,
15
+ WebhookPostFn,
16
+ } from '../../testing/reporter/lifecycle-webhook-reporter';
17
+ import { FileServiceArtifactSink } from '../../testing/reporter/file-service-artifact-sink';
18
+ import { FILE_SERVICE, IFileService } from '../../services/file/file-service.interface';
19
+ import { TestRunJobPayload } from '../../dtos/test-run-request.dto';
20
+ import { WorkflowSecretService } from '../../services/workflow-secret.service';
21
+
22
+ /**
23
+ * Executes a queued test run on the worker tier (QUEUES_SERVICE_ROLE=subscriber).
24
+ * Resolves scenarios from `scenariosPath` (read from disk here, at execution time —
25
+ * the worker shares the filesystem with the caller) or from inline `scenarios`, then
26
+ * runs them via {@link runFromMetadata} with a {@link LifecycleWebhookReporter} that
27
+ * streams run.start → scenario.* → run.end to the caller's webhookUrl. A failing TEST
28
+ * is NOT a failing JOB: failures (including an unreadable scenario file) are reported
29
+ * over the webhook (run.end ok:false) and the job still completes, so the queue does
30
+ * not retry a deterministically-failing test run.
31
+ */
32
+ @Injectable()
33
+ export class TestRunQueueSubscriberDatabase extends DatabaseSubscriber<TestRunJobPayload> {
34
+ private readonly testRunLogger = new Logger(TestRunQueueSubscriberDatabase.name);
35
+
36
+ constructor(
37
+ readonly mqMessageService: MqMessageService,
38
+ readonly mqMessageQueueService: MqMessageQueueService,
39
+ readonly poller: PollerService,
40
+ private readonly httpService: HttpService,
41
+ @Inject(FILE_SERVICE) private readonly fileService: IFileService,
42
+ private readonly workflowSecretService: WorkflowSecretService,
43
+ ) {
44
+ super(mqMessageService, mqMessageQueueService, poller);
45
+ }
46
+
47
+ options(): QueuesModuleOptions {
48
+ return {
49
+ ...testRunQueueConfig
50
+ }
51
+ }
52
+
53
+ async subscribe(message: QueueMessage<TestRunJobPayload>) {
54
+ const p = message.payload;
55
+ this.testRunLogger.debug(`Starting test run ${p.runId} (externalRunId=${p.externalRunId})`);
56
+
57
+ const post: WebhookPostFn = async (url, body, opts) => {
58
+ const res = await this.httpService.axiosRef.post(url, body, {
59
+ headers: { 'Content-Type': 'application/json', ...(opts.headers ?? {}) },
60
+ timeout: opts.timeoutMs ?? 10_000,
61
+ validateStatus: () => true,
62
+ });
63
+ return { ok: res.status >= 200 && res.status < 300, status: res.status };
64
+ };
65
+
66
+ // Persist binary artifacts (screenshots, whole-run video) to shared storage via the
67
+ // core FILE_SERVICE (disk by default, S3 when DEFAULT_FILE_SERVICE=s3) instead of
68
+ // shipping them inline / storing blobs in the DB.
69
+ const artifactSink = new FileServiceArtifactSink(this.fileService, { prefix: 'testing-hub' });
70
+
71
+ const reporter = new LifecycleWebhookReporter({
72
+ webhookUrl: p.webhookUrl,
73
+ runName: p.externalRunId ?? p.runId,
74
+ externalRunId: p.externalRunId,
75
+ runId: p.runId,
76
+ post,
77
+ artifactSink,
78
+ });
79
+
80
+ let scenarios = p.scenarios;
81
+ let data = p.data;
82
+ if (p.scenariosPath) {
83
+ const read = readScenariosFile(p.scenariosPath);
84
+ if (!read.ok) {
85
+ const reason = `Scenario file unreadable: ${p.scenariosPath} (${read.error})`;
86
+ this.testRunLogger.warn(`Test run ${p.runId} — ${reason}`);
87
+ reporter.onRunStart({ total: 0, startedAt: new Date().toISOString(), scenarioIds: [] });
88
+ await reporter.flushPending(1, reason);
89
+ return { runId: p.runId, exitCode: 1 };
90
+ }
91
+ scenarios = read.scenarios;
92
+ data = read.data ?? data;
93
+ this.testRunLogger.debug(`Test run ${p.runId} loaded ${scenarios.length} scenario(s) from ${p.scenariosPath}`);
94
+ }
95
+
96
+ let exitCode = 0;
97
+ try {
98
+ await runFromMetadata({
99
+ scenarios,
100
+ data,
101
+ includeTags: p.includeTags,
102
+ scenarioIds: p.scenarioIds,
103
+ skipScenarioIds: p.skipScenarioIds,
104
+ reporter,
105
+ env: p.variables,
106
+ api: { baseUrl: p.baseUrl },
107
+ ui: { baseUrl: p.uiBaseUrl, headless: p.headless ?? true, capture: p.capture, recordVideo: p.recordVideo ?? true },
108
+ options: { printApiLogs: p.printApiLogs ?? true },
109
+ externalRunId: p.externalRunId,
110
+ resolveSecrets: (keys) => this.workflowSecretService.resolveAvailable(keys),
111
+ });
112
+ } catch (err: any) {
113
+ // A failed scenario is surfaced via the run.end webhook, not as a job failure.
114
+ exitCode = 1;
115
+ this.testRunLogger.warn(`Test run ${p.runId} completed with failures: ${err?.message ?? err}`);
116
+ } finally {
117
+ await reporter.flushPending(exitCode);
118
+ }
119
+
120
+ return { runId: p.runId, exitCode };
121
+ }
122
+ }
@@ -14958,6 +14958,7 @@
14958
14958
  "enableGlobalSearch": true,
14959
14959
  "create": false,
14960
14960
  "edit": false,
14961
+ "delete": false,
14961
14962
  "import": false,
14962
14963
  "export": false,
14963
14964
  "allowedViews": [
@@ -15089,7 +15090,8 @@
15089
15090
  "label": "User Activity History",
15090
15091
  "className": "grid",
15091
15092
  "showAddFormButton": false,
15092
- "showEditFormButton": false
15093
+ "showEditFormButton": false,
15094
+ "showDeleteFormButton": false
15093
15095
  },
15094
15096
  "children": [
15095
15097
  {
@@ -0,0 +1,145 @@
1
+ import { CACHE_MANAGER } from "@nestjs/cache-manager";
2
+ import { Inject, Injectable } from "@nestjs/common";
3
+ import { Cache } from "cache-manager";
4
+ import type { SolidCoreSetting } from "src/services/settings/default-settings-provider.service";
5
+ import { SettingService } from "./setting.service";
6
+
7
+ // The denylist entry must outlive the longest-lived access token it could
8
+ // match. Any token an entry rejects was issued before the revocation, so it
9
+ // dies by revokedAt + accessTokenTtl at the latest; after that `exp` rejects it
10
+ // and the entry is dead weight. Mirrors STATE_TTL_BUFFER_SECONDS in
11
+ // refresh-token-ids-storage.service.ts.
12
+ const ENTRY_TTL_BUFFER_SECONDS = 60;
13
+
14
+ /**
15
+ * Immediate access-token revocation, opt-in via `invalidateAccessTokenOnLogout`.
16
+ *
17
+ * Entries record WHEN a session was revoked rather than merely that it was, and
18
+ * the guard compares that instant against the token's own `iat`. The timestamp
19
+ * is what makes a session-scoped key safe: some keys are reused across logins -
20
+ * API-key auth passes a stable `apikey-<id>` deviceKey, and the per-user key
21
+ * below is shared by every login that user will ever make - so a boolean entry
22
+ * would have to either reject the newly minted token too, or be cleared on
23
+ * re-issue and thereby revive the still-unexpired token the logout killed.
24
+ * Comparing against `iat` needs neither: tokens minted after the revocation
25
+ * pass, tokens minted before it fail, and nothing is ever cleared.
26
+ *
27
+ * This service owns the setting check - callers never branch on it. One source
28
+ * of truth instead of three call sites that can drift, no `if (enabled)` nesting
29
+ * in logout(), and a consuming project using the public seam cannot write
30
+ * entries that nothing will ever read. `getConfigValue` is a synchronous
31
+ * in-memory lookup, so a deployment that has not opted in pays nothing and
32
+ * issues no I/O.
33
+ */
34
+ @Injectable()
35
+ export class AccessTokenDenylistService {
36
+ constructor(
37
+ @Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
38
+ private readonly settingService: SettingService,
39
+ ) {}
40
+
41
+ isEnabled(): boolean {
42
+ return (
43
+ this.settingService.getConfigValue<SolidCoreSetting>(
44
+ "invalidateAccessTokenOnLogout",
45
+ ) === true
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Revoke every access token belonging to one session. A no-op when the
51
+ * feature is off, and when no session key could be resolved - which spares
52
+ * the caller a null check.
53
+ */
54
+ async revokeSession(userId: number, sessionKey?: string): Promise<void> {
55
+ if (!this.isEnabled() || !sessionKey) {
56
+ return;
57
+ }
58
+ await this.cacheManager.set(
59
+ this.getSessionKey(userId, sessionKey),
60
+ this.revokedAt(),
61
+ this.getEntryTtlMs(),
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Revoke every access token for a user, across all sessions - `allDevices`.
67
+ *
68
+ * Needs no claim on the token and no enumeration of buckets: every
69
+ * outstanding token was necessarily issued before the logout that writes
70
+ * this, so one entry sweeps them all.
71
+ */
72
+ async revokeAllForUser(userId: number): Promise<void> {
73
+ if (!this.isEnabled()) {
74
+ return;
75
+ }
76
+ await this.cacheManager.set(
77
+ this.getUserKey(userId),
78
+ this.revokedAt(),
79
+ this.getEntryTtlMs(),
80
+ );
81
+ }
82
+
83
+ /**
84
+ * True when the token was issued before either revocation instant that could
85
+ * apply to it - its own session's, or its user's.
86
+ *
87
+ * Returns false without touching the cache when the feature is off, which is
88
+ * why the guard needs no setting check of its own.
89
+ */
90
+ async isRevoked(
91
+ userId: number,
92
+ sessionKey: string,
93
+ issuedAtSeconds?: number,
94
+ ): Promise<boolean> {
95
+ if (!this.isEnabled()) {
96
+ return false;
97
+ }
98
+
99
+ // A token with no `iat` cannot be placed relative to a revocation. Tokens
100
+ // signed by @nestjs/jwt always carry one, so this is a malformed-payload
101
+ // guard rather than a real case; refuse rather than fail open.
102
+ if (typeof issuedAtSeconds !== "number") {
103
+ return true;
104
+ }
105
+
106
+ const [sessionRevokedAt, userRevokedAt] = await Promise.all([
107
+ this.cacheManager.get<number>(this.getSessionKey(userId, sessionKey)),
108
+ this.cacheManager.get<number>(this.getUserKey(userId)),
109
+ ]);
110
+
111
+ const revokedAt = Math.max(sessionRevokedAt ?? 0, userRevokedAt ?? 0);
112
+ return issuedAtSeconds * 1000 < revokedAt;
113
+ }
114
+
115
+ /**
116
+ * `iat` has whole-second resolution, so a token minted in the same second as
117
+ * the revocation is indistinguishable from one minted just before it.
118
+ * Rounding up resolves that ambiguity toward revoking: an automated client
119
+ * re-authenticating inside the window gets one 401 and succeeds on retry,
120
+ * whereas rounding down would leave a genuinely revoked token valid for up to
121
+ * a second. No human path can reach the window at all - logout invalidates
122
+ * the refresh token, so the next access token only exists after a fresh login.
123
+ */
124
+ private revokedAt(): number {
125
+ return (Math.floor(Date.now() / 1000) + 1) * 1000;
126
+ }
127
+
128
+ private getSessionKey(userId: number, sessionKey: string): string {
129
+ return `revoked-session-${userId}-${sessionKey}`;
130
+ }
131
+
132
+ private getUserKey(userId: number): string {
133
+ return `revoked-user-${userId}`;
134
+ }
135
+
136
+ // cache-manager v5 expects milliseconds; accessTokenTtl is configured in
137
+ // seconds. Read from the setting rather than hardcoded so that raising
138
+ // accessTokenTtl keeps entries long enough to outlive the tokens they match.
139
+ private getEntryTtlMs(): number {
140
+ const accessTokenTtlSeconds = Number(
141
+ this.settingService.getConfigValue<SolidCoreSetting>("accessTokenTtl"),
142
+ );
143
+ return (accessTokenTtlSeconds + ENTRY_TTL_BUFFER_SECONDS) * 1000;
144
+ }
145
+ }