@solidxai/core 0.1.11-beta.7 → 0.1.11-beta.9
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/.claude/settings.local.json +16 -0
- package/CLAUDE.md +26 -0
- package/dist/helpers/environment.helper.d.ts +1 -0
- package/dist/helpers/environment.helper.d.ts.map +1 -1
- package/dist/helpers/environment.helper.js +4 -0
- package/dist/helpers/environment.helper.js.map +1 -1
- package/dist/helpers/model-metadata-helper.service.d.ts +2 -1
- package/dist/helpers/model-metadata-helper.service.d.ts.map +1 -1
- package/dist/helpers/model-metadata-helper.service.js +6 -2
- package/dist/helpers/model-metadata-helper.service.js.map +1 -1
- package/dist/helpers/typeorm-db-helper.d.ts +1 -0
- package/dist/helpers/typeorm-db-helper.d.ts.map +1 -1
- package/dist/helpers/typeorm-db-helper.js +12 -0
- package/dist/helpers/typeorm-db-helper.js.map +1 -1
- package/dist/repository/scheduled-job.repository.d.ts +2 -2
- package/dist/repository/scheduled-job.repository.d.ts.map +1 -1
- package/dist/repository/scheduled-job.repository.js +6 -3
- package/dist/repository/scheduled-job.repository.js.map +1 -1
- package/dist/repository/security-rule.repository.d.ts +2 -2
- package/dist/repository/security-rule.repository.d.ts.map +1 -1
- package/dist/repository/security-rule.repository.js +6 -3
- package/dist/repository/security-rule.repository.js.map +1 -1
- package/dist/services/computed-fields/entity/alpha-num-external-id-computed-field-provider.d.ts.map +1 -1
- package/dist/services/computed-fields/entity/alpha-num-external-id-computed-field-provider.js +2 -1
- package/dist/services/computed-fields/entity/alpha-num-external-id-computed-field-provider.js.map +1 -1
- package/dist/services/crud-helper.service.d.ts.map +1 -1
- package/dist/services/crud-helper.service.js +25 -12
- package/dist/services/crud-helper.service.js.map +1 -1
- package/dist/services/model-metadata.service.d.ts.map +1 -1
- package/dist/services/model-metadata.service.js +2 -1
- package/dist/services/model-metadata.service.js.map +1 -1
- package/dist/services/module-metadata.service.d.ts.map +1 -1
- package/dist/services/module-metadata.service.js +2 -1
- package/dist/services/module-metadata.service.js.map +1 -1
- package/dist/services/settings/default-settings-provider.service.d.ts +32 -0
- package/dist/services/settings/default-settings-provider.service.d.ts.map +1 -1
- package/dist/services/settings/default-settings-provider.service.js +14 -0
- package/dist/services/settings/default-settings-provider.service.js.map +1 -1
- package/dist/subscribers/audit.subscriber.d.ts +0 -1
- package/dist/subscribers/audit.subscriber.d.ts.map +1 -1
- package/dist/subscribers/audit.subscriber.js +5 -8
- package/dist/subscribers/audit.subscriber.js.map +1 -1
- package/dist/subscribers/computed-entity-field.subscriber.d.ts +9 -1
- package/dist/subscribers/computed-entity-field.subscriber.d.ts.map +1 -1
- package/dist/subscribers/computed-entity-field.subscriber.js +43 -12
- package/dist/subscribers/computed-entity-field.subscriber.js.map +1 -1
- package/dist/subscribers/scheduled-job.subscriber.d.ts.map +1 -1
- package/dist/subscribers/scheduled-job.subscriber.js +3 -2
- package/dist/subscribers/scheduled-job.subscriber.js.map +1 -1
- package/dist/subscribers/security-rule.subscriber.d.ts.map +1 -1
- package/dist/subscribers/security-rule.subscriber.js +5 -3
- package/dist/subscribers/security-rule.subscriber.js.map +1 -1
- package/package.json +1 -1
- package/src/helpers/environment.helper.ts +11 -0
- package/src/helpers/model-metadata-helper.service.ts +7 -2
- package/src/helpers/typeorm-db-helper.ts +15 -0
- package/src/repository/scheduled-job.repository.ts +10 -4
- package/src/repository/security-rule.repository.ts +10 -4
- package/src/services/1.js +6 -0
- package/src/services/computed-fields/entity/alpha-num-external-id-computed-field-provider.ts +5 -4
- package/src/services/crud-helper.service.ts +25 -12
- package/src/services/model-metadata.service.ts +2 -1
- package/src/services/module-metadata.service.ts +2 -1
- package/src/services/settings/default-settings-provider.service.ts +14 -0
- package/src/subscribers/audit.subscriber.ts +7 -9
- package/src/subscribers/computed-entity-field.subscriber.ts +79 -28
- package/src/subscribers/scheduled-job.subscriber.ts +3 -2
- package/src/subscribers/security-rule.subscriber.ts +8 -3
- package/dist-tests/api/authenticate.spec.js +0 -119
- package/dist-tests/api/authenticate.spec.js.map +0 -1
- package/dist-tests/api/crud-service.findOne.cityMaster.spec.js +0 -97
- package/dist-tests/api/crud-service.findOne.cityMaster.spec.js.map +0 -1
- package/dist-tests/api/ping.spec.js +0 -21
- package/dist-tests/api/ping.spec.js.map +0 -1
- package/dist-tests/helpers/auth.js +0 -41
- package/dist-tests/helpers/auth.js.map +0 -1
- package/dist-tests/helpers/env.js +0 -11
- package/dist-tests/helpers/env.js.map +0 -1
- package/docs/agent-hub-grooming.md +0 -301
- package/docs/dashboards/AGENTIC_DASHBOARD_IMPLEMENTATION_PLAN.md +0 -438
- package/docs/dashboards/dashboard-curl-smoke-tests.txt +0 -146
- package/docs/dashboards/delete-legacy-dashboard-metadata.sql +0 -172
- package/docs/datasource-introspection-ddl-analysis.md +0 -326
- package/docs/datasource-introspection-implementation-plan.md +0 -306
- package/docs/grouping-enhancements.md +0 -89
- package/docs/java-spring/README.md +0 -3
- package/docs/java-spring/solid-core-module-deep-dive-report.md +0 -1317
- package/docs/module-package-import-handoff.md +0 -691
- package/docs/seed-changes.md +0 -65
- package/docs/test-data-workflow.md +0 -200
- package/docs/type-declaration-import-issue.md +0 -24
|
@@ -5,6 +5,7 @@ import { ActiveUserData } from "src/interfaces/active-user-data.interface";
|
|
|
5
5
|
import { SolidRegistry } from "src/helpers/solid-registry";
|
|
6
6
|
import { BadRequestException, Logger } from "@nestjs/common";
|
|
7
7
|
import { ERROR_MESSAGES } from "src/constants/error-messages";
|
|
8
|
+
import { buildCastToText } from "src/helpers/typeorm-db-helper";
|
|
8
9
|
|
|
9
10
|
export enum FilterCombinator {
|
|
10
11
|
AND = '$and',
|
|
@@ -101,15 +102,19 @@ export class CrudHelperService {
|
|
|
101
102
|
case '$eq':
|
|
102
103
|
qb.andWhere(`${colExpr} = :${uniqueFieldAlias}`, { [uniqueFieldAlias]: normalizedPrimaryOperatorObj.$eq });
|
|
103
104
|
break;
|
|
104
|
-
case '$eqi':
|
|
105
|
-
|
|
105
|
+
case '$eqi': {
|
|
106
|
+
const castExpr = buildCastToText(this.getDriver(qb), colExpr);
|
|
107
|
+
qb.andWhere(`LOWER(${castExpr}) = :${uniqueFieldAlias}`, { [uniqueFieldAlias]: String(normalizedPrimaryOperatorObj.$eqi).toLowerCase() });
|
|
106
108
|
break;
|
|
109
|
+
}
|
|
107
110
|
case '$ne':
|
|
108
111
|
qb.andWhere(`${colExpr} != :${uniqueFieldAlias}`, { [uniqueFieldAlias]: normalizedPrimaryOperatorObj.$ne });
|
|
109
112
|
break;
|
|
110
|
-
case '$nei':
|
|
111
|
-
|
|
113
|
+
case '$nei': {
|
|
114
|
+
const castExpr = buildCastToText(this.getDriver(qb), colExpr);
|
|
115
|
+
qb.andWhere(`LOWER(${castExpr}) != :${uniqueFieldAlias}`, { [uniqueFieldAlias]: String(normalizedPrimaryOperatorObj.$nei).toLowerCase() });
|
|
112
116
|
break;
|
|
117
|
+
}
|
|
113
118
|
case '$gt':
|
|
114
119
|
qb.andWhere(`${colExpr} > :${uniqueFieldAlias}`, { [uniqueFieldAlias]: normalizedPrimaryOperatorObj.$gt });
|
|
115
120
|
break;
|
|
@@ -134,12 +139,16 @@ export class CrudHelperService {
|
|
|
134
139
|
case '$notContains':
|
|
135
140
|
qb.andWhere(`${colExpr} NOT LIKE :${uniqueFieldAlias}`, { [uniqueFieldAlias]: `%${normalizedPrimaryOperatorObj.$notContains}%` });
|
|
136
141
|
break;
|
|
137
|
-
case '$containsi':
|
|
138
|
-
|
|
142
|
+
case '$containsi': {
|
|
143
|
+
const castExpr = buildCastToText(this.getDriver(qb), colExpr);
|
|
144
|
+
qb.andWhere(`LOWER(${castExpr}) LIKE :${uniqueFieldAlias}`, { [uniqueFieldAlias]: `%${String(normalizedPrimaryOperatorObj.$containsi).toLowerCase()}%` });
|
|
139
145
|
break;
|
|
140
|
-
|
|
141
|
-
|
|
146
|
+
}
|
|
147
|
+
case '$notContainsi': {
|
|
148
|
+
const castExpr = buildCastToText(this.getDriver(qb), colExpr);
|
|
149
|
+
qb.andWhere(`LOWER(${castExpr}) NOT LIKE :${uniqueFieldAlias}`, { [uniqueFieldAlias]: `%${String(normalizedPrimaryOperatorObj.$notContainsi).toLowerCase()}%` });
|
|
142
150
|
break;
|
|
151
|
+
}
|
|
143
152
|
case '$null':
|
|
144
153
|
qb.andWhere(`${colExpr} IS NULL`);
|
|
145
154
|
break;
|
|
@@ -152,15 +161,19 @@ export class CrudHelperService {
|
|
|
152
161
|
case '$startsWith':
|
|
153
162
|
qb.andWhere(`${colExpr} LIKE :${uniqueFieldAlias}`, { [uniqueFieldAlias]: `${normalizedPrimaryOperatorObj.$startsWith}%` });
|
|
154
163
|
break;
|
|
155
|
-
case '$startsWithi':
|
|
156
|
-
|
|
164
|
+
case '$startsWithi': {
|
|
165
|
+
const castExpr = buildCastToText(this.getDriver(qb), colExpr);
|
|
166
|
+
qb.andWhere(`LOWER(${castExpr}) LIKE :${uniqueFieldAlias}`, { [uniqueFieldAlias]: `${String(normalizedPrimaryOperatorObj.$startsWithi).toLowerCase()}%` });
|
|
157
167
|
break;
|
|
168
|
+
}
|
|
158
169
|
case '$endsWith':
|
|
159
170
|
qb.andWhere(`${colExpr} LIKE :${uniqueFieldAlias}`, { [uniqueFieldAlias]: `%${normalizedPrimaryOperatorObj.$endsWith}` });
|
|
160
171
|
break;
|
|
161
|
-
case '$endsWithi':
|
|
162
|
-
|
|
172
|
+
case '$endsWithi': {
|
|
173
|
+
const castExpr = buildCastToText(this.getDriver(qb), colExpr);
|
|
174
|
+
qb.andWhere(`LOWER(${castExpr}) LIKE :${uniqueFieldAlias}`, { [uniqueFieldAlias]: `%${String(normalizedPrimaryOperatorObj.$endsWithi).toLowerCase()}` });
|
|
163
175
|
break;
|
|
176
|
+
}
|
|
164
177
|
default:
|
|
165
178
|
throw new Error(`Operator ${operator} is not supported`);
|
|
166
179
|
}
|
|
@@ -27,6 +27,7 @@ import { PermissionMetadata } from '../entities/permission-metadata.entity';
|
|
|
27
27
|
import { RoleMetadata } from '../entities/role-metadata.entity';
|
|
28
28
|
import { ViewMetadata } from '../entities/view-metadata.entity';
|
|
29
29
|
import { CommandService } from '../helpers/command.service';
|
|
30
|
+
import { isEmbeddedDb } from '../helpers/environment.helper';
|
|
30
31
|
import { startNodemonHeartbeat } from '../helpers/nodemon-heartbeat';
|
|
31
32
|
import {
|
|
32
33
|
REFRESH_MODEL_COMMAND,
|
|
@@ -968,7 +969,7 @@ export class ModelMetadataService {
|
|
|
968
969
|
@DisallowInProduction()
|
|
969
970
|
async generateCodeViaCtl(modelId: number): Promise<string> {
|
|
970
971
|
const model = await this.findOne(modelId);
|
|
971
|
-
const isEmbedded =
|
|
972
|
+
const isEmbedded = isEmbeddedDb();
|
|
972
973
|
|
|
973
974
|
// When using an embedded PGlite database, the single-connection limit means
|
|
974
975
|
// a spawned `solid refresh-model` subprocess cannot get its own DB connection
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
} from '../helpers/schematic.service';
|
|
27
27
|
import { CommandService } from '../helpers/command.service';
|
|
28
28
|
import { SolidRegistry } from '../helpers/solid-registry';
|
|
29
|
+
import { isEmbeddedDb } from '../helpers/environment.helper';
|
|
29
30
|
import { startNodemonHeartbeat } from '../helpers/nodemon-heartbeat';
|
|
30
31
|
import { CodeGenerationOptions, ModuleMetadataConfiguration } from '../interfaces';
|
|
31
32
|
import { CrudHelperService } from './crud-helper.service';
|
|
@@ -535,7 +536,7 @@ export class ModuleMetadataService {
|
|
|
535
536
|
@DisallowInProduction()
|
|
536
537
|
async generateCodeViaCtl(moduleId: number): Promise<string> {
|
|
537
538
|
const module = await this.findOne(moduleId);
|
|
538
|
-
const isEmbedded =
|
|
539
|
+
const isEmbedded = isEmbeddedDb();
|
|
539
540
|
|
|
540
541
|
// When using an embedded PGlite database, the single-connection limit means
|
|
541
542
|
// a spawned `solid refresh-module` subprocess cannot get its own DB connection
|
|
@@ -114,6 +114,20 @@ const getSolidCoreSettings = (isProd: boolean) =>
|
|
|
114
114
|
{ label: "In Image View", value: "in_image_view" },
|
|
115
115
|
],
|
|
116
116
|
},
|
|
117
|
+
{
|
|
118
|
+
moduleName: "solid-core",
|
|
119
|
+
key: "rowClickAction",
|
|
120
|
+
value: "edit",
|
|
121
|
+
level: SettingLevel.SystemAdminEditable,
|
|
122
|
+
label: "Row Click Action",
|
|
123
|
+
group: "app-settings",
|
|
124
|
+
sortOrder: 45,
|
|
125
|
+
controlType: "selectionStatic",
|
|
126
|
+
options: [
|
|
127
|
+
{ label: "Edit", value: "edit" },
|
|
128
|
+
{ label: "View", value: "view" },
|
|
129
|
+
],
|
|
130
|
+
},
|
|
117
131
|
{
|
|
118
132
|
moduleName: "solid-core",
|
|
119
133
|
key: "showAuthContent",
|
|
@@ -4,6 +4,7 @@ import { lowerFirst } from 'src/helpers/string.helper';
|
|
|
4
4
|
import { SolidRegistry } from 'src/helpers/solid-registry';
|
|
5
5
|
import { DataSource, EntityMetadata, EntitySubscriberInterface, InsertEvent, RemoveEvent, UpdateEvent } from 'typeorm';
|
|
6
6
|
import { AuditQueuePayload } from 'src/interfaces';
|
|
7
|
+
import { isEmbeddedDb } from 'src/helpers/environment.helper';
|
|
7
8
|
import { RequestContextService } from 'src/services/request-context.service';
|
|
8
9
|
import { PublisherFactory } from 'src/services/queues/publisher-factory.service';
|
|
9
10
|
const AUDIT_BEFORE_SNAPSHOT = '__auditBeforeSnapshot';
|
|
@@ -88,7 +89,10 @@ export class AuditSubscriber implements EntitySubscriberInterface {
|
|
|
88
89
|
*/
|
|
89
90
|
private async prepareManyToManyAuditUpdateSnapshot(event: UpdateEvent<any>, entityId: number | null, updatedColumnNames: string[]): Promise<{ before: any; after: any } | null> {
|
|
90
91
|
|
|
91
|
-
|
|
92
|
+
// On embedded PGlite, pass the transaction's manager so the query runs
|
|
93
|
+
// on the active connection. On regular Postgres, use the default DataSource.
|
|
94
|
+
const mgr = isEmbeddedDb() ? event.manager : this.dataSource;
|
|
95
|
+
const auditRelationFields = (await this.modelMetadataHelperService.loadFieldHierarchy(lowerFirst(event.metadata.name), isEmbeddedDb() ? event.manager : undefined)).filter(field =>
|
|
92
96
|
field.enableAuditTracking &&
|
|
93
97
|
field.type === 'relation' &&
|
|
94
98
|
field.relationType !== 'one-to-many'
|
|
@@ -106,7 +110,7 @@ export class AuditSubscriber implements EntitySubscriberInterface {
|
|
|
106
110
|
|
|
107
111
|
const relationBefore = event.entity?.[AUDIT_BEFORE_SNAPSHOT] ?? null;
|
|
108
112
|
|
|
109
|
-
const relationAfter = await
|
|
113
|
+
const relationAfter = await mgr.getRepository(event.metadata.target as any).findOne({
|
|
110
114
|
where: { id: entityId } as any,
|
|
111
115
|
relations: relations as any,
|
|
112
116
|
});
|
|
@@ -151,7 +155,7 @@ export class AuditSubscriber implements EntitySubscriberInterface {
|
|
|
151
155
|
|
|
152
156
|
if (batch.length === 0) return;
|
|
153
157
|
|
|
154
|
-
if (
|
|
158
|
+
if (isEmbeddedDb()) {
|
|
155
159
|
setImmediate(() => void this.publishAuditBatch(batch));
|
|
156
160
|
return;
|
|
157
161
|
}
|
|
@@ -164,12 +168,6 @@ export class AuditSubscriber implements EntitySubscriberInterface {
|
|
|
164
168
|
this.perTxn.delete(event.queryRunner);
|
|
165
169
|
}
|
|
166
170
|
|
|
167
|
-
private shouldDeferAuditPublish(): boolean {
|
|
168
|
-
const driver = (process.env.DEFAULT_DATABASE_DRIVER ?? '').toLowerCase();
|
|
169
|
-
const poolMax = Number(process.env.DEFAULT_DATABASE_POOL_MAX ?? 20);
|
|
170
|
-
return driver === 'pglite' && Number.isFinite(poolMax) && poolMax <= 1;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
171
|
private async publishAuditBatch(batch: AuditQueuePayload[]) {
|
|
174
172
|
try {
|
|
175
173
|
// allSettled: publish in parallel; a single failure does not block the rest.
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { camelCase } from 'lodash';
|
|
2
|
-
import { Delete } from "@aws-sdk/client-s3";
|
|
3
2
|
import { forwardRef, Inject, Injectable, InternalServerErrorException, Logger, Scope } from "@nestjs/common";
|
|
4
|
-
import { model } from "mongoose";
|
|
5
3
|
import { ComputedFieldTriggerOperation } from "src/dtos/create-field-metadata.dto";
|
|
4
|
+
import { isEmbeddedDb } from "src/helpers/environment.helper";
|
|
6
5
|
import { ComputedFieldMetadata, SolidRegistry, TypeOrmEventContext } from "src/helpers/solid-registry";
|
|
7
6
|
import { IEntityPreComputeFieldProvider } from "src/interfaces";
|
|
8
7
|
import { PublisherFactory } from "src/services/queues/publisher-factory.service";
|
|
@@ -14,19 +13,22 @@ export interface ComputedFieldEvaluationPayload extends ComputedFieldMetadata {
|
|
|
14
13
|
}
|
|
15
14
|
|
|
16
15
|
@Injectable({ scope: Scope.TRANSIENT })
|
|
17
|
-
// @EventSubscriber()
|
|
18
16
|
export class ComputedEntityFieldSubscriber implements EntitySubscriberInterface {
|
|
19
17
|
private readonly logger = new Logger(this.constructor.name);
|
|
20
18
|
private dataSource: DataSource;
|
|
19
|
+
|
|
20
|
+
// Per-transaction buffer for post-event evaluation jobs. Entries are
|
|
21
|
+
// flushed in afterTransactionCommit so the publish (which uses the
|
|
22
|
+
// default DataSource) never races the in-flight transaction's connection.
|
|
23
|
+
// On PGlite (pool size 1), the flush is deferred via setImmediate to
|
|
24
|
+
// ensure the transaction's connection has been released first.
|
|
25
|
+
private perTxn = new WeakMap<any, { computedField: ComputedFieldMetadata<any>; databaseEntity: any }[]>();
|
|
26
|
+
|
|
21
27
|
constructor(
|
|
22
|
-
// @InjectDataSource()
|
|
23
|
-
// private readonly dataSource: DataSource,
|
|
24
28
|
private readonly solidRegistry: SolidRegistry,
|
|
25
29
|
@Inject(forwardRef(() => PublisherFactory))
|
|
26
30
|
private readonly publisherFactory: PublisherFactory<ComputedFieldEvaluationPayload>
|
|
27
|
-
// private readonly computedFieldPublisher: ComputedFieldEvaluationPublisherDatabase,
|
|
28
31
|
) {
|
|
29
|
-
// this.dataSource.subscribers.push(this);
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
bindToDataSource(dataSource: DataSource) {
|
|
@@ -43,47 +45,97 @@ export class ComputedEntityFieldSubscriber implements EntitySubscriberInterface
|
|
|
43
45
|
async beforeUpdate(event: UpdateEvent<any>): Promise<any> {
|
|
44
46
|
const modelName = camelCase(event.metadata?.name ?? event.entity?.constructor?.name ?? '');
|
|
45
47
|
const eventContext = this.sanitizeEventContext(event, 'beforeUpdate');
|
|
46
|
-
// await this.handleComputedFieldEvaluation(event.databaseEntity, ComputedFieldTriggerOperation.beforeUpdate, modelName, eventContext);
|
|
47
48
|
await this.handleComputedFieldEvaluation(event.entity, ComputedFieldTriggerOperation.beforeUpdate, modelName, eventContext);
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
afterInsert(event: InsertEvent<any>) {
|
|
51
52
|
const modelName = camelCase(event.metadata?.name ?? event.entity?.constructor?.name ?? '');
|
|
52
53
|
const eventContext = this.sanitizeEventContext(event, 'afterInsert');
|
|
53
|
-
this.
|
|
54
|
+
this.handlePostEventJobs(event, event.entity, ComputedFieldTriggerOperation.afterInsert, modelName, eventContext);
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
afterUpdate(event: UpdateEvent<any>) {
|
|
57
58
|
const modelName = camelCase(event.metadata?.name ?? event.entity?.constructor?.name ?? event.databaseEntity?.constructor?.name ?? '');
|
|
58
59
|
const eventContext = this.sanitizeEventContext(event, 'afterUpdate');
|
|
59
|
-
|
|
60
|
-
this.handleComputedFieldEvaluationJob(event.entity, ComputedFieldTriggerOperation.afterUpdate, modelName, eventContext);
|
|
60
|
+
this.handlePostEventJobs(event, event.entity, ComputedFieldTriggerOperation.afterUpdate, modelName, eventContext);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
afterRemove(event: RemoveEvent<any>) {
|
|
64
64
|
const modelName = camelCase(event.metadata?.name ?? event.entity?.constructor?.name ?? event.databaseEntity?.constructor?.name ?? '');
|
|
65
65
|
const eventContext = this.sanitizeEventContext(event, 'afterRemove');
|
|
66
|
-
this.
|
|
66
|
+
this.handlePostEventJobs(event, event.databaseEntity, ComputedFieldTriggerOperation.afterRemove, modelName, eventContext);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
//FIXME: Need to add support for beforeRemove, beforeSoftRemove, afterSoftRemove, beforeRecover, afterRecover
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
// --------- post-event dispatch (pglite vs non-pglite) ----------
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* On embedded PGlite (pool size 1): buffer payloads on the queryRunner and
|
|
75
|
+
* flush in afterTransactionCommit (deferred via setImmediate) so the publish
|
|
76
|
+
* never tries to get a second connection while the transaction is active.
|
|
77
|
+
*
|
|
78
|
+
* On regular Postgres: fire-and-forget publish immediately (original behaviour),
|
|
79
|
+
* since the pool has spare connections.
|
|
80
|
+
*/
|
|
81
|
+
private handlePostEventJobs(
|
|
82
|
+
event: InsertEvent<any> | UpdateEvent<any> | RemoveEvent<any>,
|
|
83
|
+
entity: any,
|
|
84
|
+
currentOperation: ComputedFieldTriggerOperation,
|
|
85
|
+
modelName: string,
|
|
86
|
+
eventContext?: TypeOrmEventContext,
|
|
87
|
+
) {
|
|
88
|
+
if (!entity) return;
|
|
89
|
+
|
|
75
90
|
const computedFieldsTobeEvaluated = this.getComputedFieldsForEvaluation(
|
|
76
91
|
this.solidRegistry.getComputedFieldMetadata(),
|
|
77
92
|
currentOperation,
|
|
78
93
|
modelName
|
|
79
94
|
);
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
95
|
+
if (computedFieldsTobeEvaluated.length === 0) return;
|
|
96
|
+
|
|
97
|
+
if (isEmbeddedDb()) {
|
|
98
|
+
const qr = event.queryRunner;
|
|
99
|
+
const arr = this.perTxn.get(qr) ?? [];
|
|
100
|
+
for (const computedField of computedFieldsTobeEvaluated) {
|
|
101
|
+
arr.push({
|
|
102
|
+
computedField: this.attachContext(computedField, eventContext),
|
|
103
|
+
databaseEntity: entity,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
this.perTxn.set(qr, arr);
|
|
107
|
+
} else {
|
|
108
|
+
for (const computedField of computedFieldsTobeEvaluated) {
|
|
109
|
+
this.enqueueComputedFieldEvaluationJob(
|
|
110
|
+
this.attachContext(computedField, eventContext),
|
|
111
|
+
entity,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
83
114
|
}
|
|
84
115
|
}
|
|
85
116
|
|
|
86
|
-
|
|
117
|
+
// --------- transaction lifecycle (pglite only) ----------
|
|
118
|
+
async afterTransactionCommit(event: { queryRunner: any }) {
|
|
119
|
+
const batch = this.perTxn.get(event.queryRunner) ?? [];
|
|
120
|
+
this.perTxn.delete(event.queryRunner);
|
|
121
|
+
if (batch.length === 0) return;
|
|
122
|
+
|
|
123
|
+
// Deferred via setImmediate so the transaction's connection is released
|
|
124
|
+
// back to the (size-1) pool before the publish tries to acquire it.
|
|
125
|
+
setImmediate(() => void this.flushBatch(batch));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
afterTransactionRollback(event: { queryRunner: any }) {
|
|
129
|
+
this.perTxn.delete(event.queryRunner);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private async flushBatch(batch: { computedField: ComputedFieldMetadata<any>; databaseEntity: any }[]) {
|
|
133
|
+
for (const { computedField, databaseEntity } of batch) {
|
|
134
|
+
this.enqueueComputedFieldEvaluationJob(computedField, databaseEntity);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private async handleComputedFieldEvaluation(entity: any, currentOperation: ComputedFieldTriggerOperation, modelName: string, eventContext?: TypeOrmEventContext): Promise<void> {
|
|
87
139
|
if (!entity) {
|
|
88
140
|
return;
|
|
89
141
|
}
|
|
@@ -92,9 +144,8 @@ export class ComputedEntityFieldSubscriber implements EntitySubscriberInterface
|
|
|
92
144
|
currentOperation,
|
|
93
145
|
modelName
|
|
94
146
|
);
|
|
95
|
-
//TODO: We can add a feature i.e dependsOn, where we can check if the computed field depends on other computed fields and evaluate them first
|
|
96
147
|
for (const computedField of computedFieldsTobeEvaluated) {
|
|
97
|
-
this.
|
|
148
|
+
await this.evaluateComputedField(this.attachContext(computedField, eventContext), entity, currentOperation);
|
|
98
149
|
}
|
|
99
150
|
}
|
|
100
151
|
|
|
@@ -147,17 +198,14 @@ export class ComputedEntityFieldSubscriber implements EntitySubscriberInterface
|
|
|
147
198
|
}
|
|
148
199
|
}
|
|
149
200
|
|
|
150
|
-
private enqueueComputedFieldEvaluationJob(computedField: ComputedFieldMetadata<any>, databaseEntity: any
|
|
201
|
+
private enqueueComputedFieldEvaluationJob(computedField: ComputedFieldMetadata<any>, databaseEntity: any) {
|
|
151
202
|
const { manager: _manager, ...serializableEventContext } = computedField.eventContext ?? {};
|
|
152
203
|
const payload = {
|
|
153
204
|
...computedField,
|
|
154
205
|
eventContext: serializableEventContext,
|
|
155
206
|
databaseEntity,
|
|
156
207
|
};
|
|
157
|
-
this.publisherFactory.publish({ payload }, 'ComputedFieldEvaluationPublisher')
|
|
158
|
-
// this.computedFieldPublisher.publish({
|
|
159
|
-
// payload
|
|
160
|
-
// });
|
|
208
|
+
this.publisherFactory.publish({ payload }, 'ComputedFieldEvaluationPublisher');
|
|
161
209
|
}
|
|
162
210
|
|
|
163
211
|
private attachContext<T extends ComputedFieldMetadata<any>>(computedField: T, eventContext?: any): T {
|
|
@@ -176,7 +224,10 @@ export class ComputedEntityFieldSubscriber implements EntitySubscriberInterface
|
|
|
176
224
|
const base: TypeOrmEventContext = {
|
|
177
225
|
metadataName: event.metadata?.name,
|
|
178
226
|
eventType: eventType,
|
|
179
|
-
|
|
227
|
+
// Only pass the transaction's EntityManager for embedded DB (PGlite),
|
|
228
|
+
// where a second pooled connection mid-transaction would deadlock.
|
|
229
|
+
// On regular Postgres, providers use their injected EntityManager.
|
|
230
|
+
...(isEmbeddedDb() ? { manager: event.manager } : {}),
|
|
180
231
|
};
|
|
181
232
|
if ("entityId" in event && event.entityId) {
|
|
182
233
|
base.entityId = event.entityId;
|
|
@@ -4,6 +4,7 @@ import * as fs from "fs/promises";
|
|
|
4
4
|
import { CreateScheduledJobDto } from "src/dtos/create-scheduled-job.dto";
|
|
5
5
|
import { ModuleMetadata } from "src/entities/module-metadata.entity";
|
|
6
6
|
import { ScheduledJob } from "src/entities/scheduled-job.entity";
|
|
7
|
+
import { isEmbeddedDb } from "src/helpers/environment.helper";
|
|
7
8
|
import { ModuleMetadataHelperService } from "src/helpers/module-metadata-helper.service";
|
|
8
9
|
import { ScheduledJobRepository } from "src/repository/scheduled-job.repository";
|
|
9
10
|
import {
|
|
@@ -83,7 +84,7 @@ export class ScheduledJobSubscriber
|
|
|
83
84
|
return;
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
const moduleMetadataRepo = event.queryRunner.manager.getRepository(ModuleMetadata);
|
|
87
|
+
const moduleMetadataRepo = (isEmbeddedDb() ? event.queryRunner.manager : this.dataSource).getRepository(ModuleMetadata);
|
|
87
88
|
const populatedModuleMetadata = await moduleMetadataRepo.findOne({
|
|
88
89
|
where: { id: moduleMetadata.id },
|
|
89
90
|
});
|
|
@@ -160,7 +161,7 @@ export class ScheduledJobSubscriber
|
|
|
160
161
|
(job) => job.scheduleName === jobName
|
|
161
162
|
);
|
|
162
163
|
// Insert or update job in metadata
|
|
163
|
-
const jobDto: CreateScheduledJobDto = await this.scheduledJobRepo.toDto(populatedScheduleJob as ScheduledJob);
|
|
164
|
+
const jobDto: CreateScheduledJobDto = await this.scheduledJobRepo.toDto(populatedScheduleJob as ScheduledJob, isEmbeddedDb() ? entityManager : undefined);
|
|
164
165
|
// Strip out bookkeeping/runtime fields before persisting to metadata JSON.
|
|
165
166
|
const {
|
|
166
167
|
moduleId,
|
|
@@ -4,6 +4,7 @@ import * as fs from 'fs/promises'; // Use the Promise-based version of fs for as
|
|
|
4
4
|
import { SOLID_CORE_MODULE_NAME } from 'src/constants';
|
|
5
5
|
import { ModelMetadata } from 'src/entities/model-metadata.entity';
|
|
6
6
|
import { SecurityRule } from 'src/entities/security-rule.entity';
|
|
7
|
+
import { isEmbeddedDb } from "src/helpers/environment.helper";
|
|
7
8
|
import { ModuleMetadataHelperService } from "src/helpers/module-metadata-helper.service";
|
|
8
9
|
import { SecurityRuleRepository } from 'src/repository/security-rule.repository';
|
|
9
10
|
import { DataSource, EntitySubscriberInterface, InsertEvent, UpdateEvent } from "typeorm";
|
|
@@ -40,7 +41,11 @@ export class SecurityRuleSubscriber implements EntitySubscriberInterface<Securit
|
|
|
40
41
|
return;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
// On embedded PGlite, use the transaction's connection to avoid a
|
|
45
|
+
// second pooled connection mid-transaction. On regular Postgres, use
|
|
46
|
+
// the default DataSource (original behaviour).
|
|
47
|
+
const mgr = isEmbeddedDb() ? event.manager : this.dataSource;
|
|
48
|
+
const modelMetadataRepo = mgr.getRepository(ModelMetadata);
|
|
44
49
|
const populatedModelMetadata = await modelMetadataRepo.findOne({
|
|
45
50
|
where: {
|
|
46
51
|
id: modelMetadata.id
|
|
@@ -67,12 +72,12 @@ export class SecurityRuleSubscriber implements EntitySubscriberInterface<Securit
|
|
|
67
72
|
|
|
68
73
|
if (metaData.securityRules) {
|
|
69
74
|
const securityRuleIndex = metaData.securityRules?.findIndex((ruleFromFile: { name: string }) => ruleFromFile.name === securityRule.name);
|
|
70
|
-
const { id, roleId, modelMetadataId, ...requiredDto } = await this.securityRuleRepo.toDto(securityRule)
|
|
75
|
+
const { id, roleId, modelMetadataId, ...requiredDto } = await this.securityRuleRepo.toDto(securityRule, isEmbeddedDb() ? event.manager : undefined)
|
|
71
76
|
metaData.securityRules[securityRuleIndex] = { ...requiredDto, securityRuleConfig: JSON.parse(securityRule.securityRuleConfig) }
|
|
72
77
|
}
|
|
73
78
|
else {
|
|
74
79
|
const securityRules = []
|
|
75
|
-
const { id, roleId, modelMetadataId, ...requiredDto } = await this.securityRuleRepo.toDto(securityRule)
|
|
80
|
+
const { id, roleId, modelMetadataId, ...requiredDto } = await this.securityRuleRepo.toDto(securityRule, isEmbeddedDb() ? event.manager : undefined)
|
|
76
81
|
securityRules.push({ ...requiredDto, securityRuleConfig: JSON.parse(securityRule.securityRuleConfig) })
|
|
77
82
|
metaData.securityRules = securityRules
|
|
78
83
|
}
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
const test_1 = require("@playwright/test");
|
|
4
|
-
const env_1 = require("../helpers/env");
|
|
5
|
-
const baseURL = process.env.API_BASE_URL ?? "http://localhost:3000";
|
|
6
|
-
const TEST_USER_EMAIL = (0, env_1.getRequiredEnv)("TEST_USER_EMAIL");
|
|
7
|
-
const TEST_USER_PASSWORD = (0, env_1.getRequiredEnv)("TEST_USER_PASSWORD");
|
|
8
|
-
function base64UrlDecode(input) {
|
|
9
|
-
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
10
|
-
const padded = normalized.length % 4 === 0
|
|
11
|
-
? normalized
|
|
12
|
-
: normalized.padEnd(normalized.length + (4 - (normalized.length % 4)), "=");
|
|
13
|
-
return Buffer.from(padded, "base64").toString("utf-8");
|
|
14
|
-
}
|
|
15
|
-
function validateJwt(token) {
|
|
16
|
-
const parts = token.split(".");
|
|
17
|
-
if (parts.length !== 3) {
|
|
18
|
-
throw new Error("JWT must have three dot-separated parts.");
|
|
19
|
-
}
|
|
20
|
-
const headerJson = JSON.parse(base64UrlDecode(parts[0]));
|
|
21
|
-
const payloadJson = JSON.parse(base64UrlDecode(parts[1]));
|
|
22
|
-
if (!headerJson || typeof headerJson !== "object") {
|
|
23
|
-
throw new Error("JWT header must be a JSON object.");
|
|
24
|
-
}
|
|
25
|
-
if (!payloadJson || typeof payloadJson !== "object") {
|
|
26
|
-
throw new Error("JWT payload must be a JSON object.");
|
|
27
|
-
}
|
|
28
|
-
if (typeof payloadJson.exp !== "number") {
|
|
29
|
-
throw new Error("JWT payload.exp must be a number.");
|
|
30
|
-
}
|
|
31
|
-
return payloadJson;
|
|
32
|
-
}
|
|
33
|
-
(0, test_1.test)("API: authenticate succeeds with valid credentials", async () => {
|
|
34
|
-
const api = await test_1.request.newContext({
|
|
35
|
-
baseURL,
|
|
36
|
-
extraHTTPHeaders: {
|
|
37
|
-
accept: "*/*",
|
|
38
|
-
"content-type": "application/json",
|
|
39
|
-
},
|
|
40
|
-
});
|
|
41
|
-
try {
|
|
42
|
-
const res = await api.post("/api/iam/authenticate", {
|
|
43
|
-
data: {
|
|
44
|
-
email: TEST_USER_EMAIL,
|
|
45
|
-
username: "",
|
|
46
|
-
password: TEST_USER_PASSWORD,
|
|
47
|
-
},
|
|
48
|
-
});
|
|
49
|
-
(0, test_1.expect)(res.status()).toBe(200);
|
|
50
|
-
const json = await res.json();
|
|
51
|
-
(0, test_1.expect)(json.statusCode).toBe(200);
|
|
52
|
-
(0, test_1.expect)(Array.isArray(json.message)).toBe(true);
|
|
53
|
-
(0, test_1.expect)(json.message.length).toBe(0);
|
|
54
|
-
(0, test_1.expect)(json.error).toBe("");
|
|
55
|
-
const user = json.data?.user;
|
|
56
|
-
(0, test_1.expect)(user, "Expected data.user to be an object.").toBeTruthy();
|
|
57
|
-
(0, test_1.expect)(typeof user).toBe("object");
|
|
58
|
-
const email = user?.email;
|
|
59
|
-
(0, test_1.expect)(typeof email).toBe("string");
|
|
60
|
-
if (email === TEST_USER_EMAIL) {
|
|
61
|
-
(0, test_1.expect)(email).toBe(TEST_USER_EMAIL);
|
|
62
|
-
}
|
|
63
|
-
else {
|
|
64
|
-
(0, test_1.expect)(email.length).toBeGreaterThan(0);
|
|
65
|
-
}
|
|
66
|
-
(0, test_1.expect)(typeof user?.mobile).toBe("string");
|
|
67
|
-
(0, test_1.expect)(typeof user?.username).toBe("string");
|
|
68
|
-
(0, test_1.expect)(typeof user?.forcePasswordChange).toBe("boolean");
|
|
69
|
-
(0, test_1.expect)(typeof user?.id).toBe("number");
|
|
70
|
-
const roles = user?.roles;
|
|
71
|
-
(0, test_1.expect)(Array.isArray(roles)).toBe(true);
|
|
72
|
-
if (Array.isArray(roles)) {
|
|
73
|
-
(0, test_1.expect)(roles.every((role) => typeof role === "string")).toBe(true);
|
|
74
|
-
(0, test_1.expect)(roles).toContain("Admin");
|
|
75
|
-
}
|
|
76
|
-
const accessToken = json.data?.accessToken;
|
|
77
|
-
const refreshToken = json.data?.refreshToken;
|
|
78
|
-
(0, test_1.expect)(typeof accessToken).toBe("string");
|
|
79
|
-
(0, test_1.expect)(typeof refreshToken).toBe("string");
|
|
80
|
-
const accessPayload = validateJwt(accessToken);
|
|
81
|
-
const refreshPayload = validateJwt(refreshToken);
|
|
82
|
-
(0, test_1.expect)(typeof accessPayload.exp).toBe("number");
|
|
83
|
-
(0, test_1.expect)(typeof refreshPayload.exp).toBe("number");
|
|
84
|
-
}
|
|
85
|
-
finally {
|
|
86
|
-
await api.dispose();
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
(0, test_1.test)("API: authenticate fails with wrong password", async () => {
|
|
90
|
-
const api = await test_1.request.newContext({
|
|
91
|
-
baseURL,
|
|
92
|
-
extraHTTPHeaders: {
|
|
93
|
-
accept: "*/*",
|
|
94
|
-
"content-type": "application/json",
|
|
95
|
-
},
|
|
96
|
-
});
|
|
97
|
-
try {
|
|
98
|
-
const res = await api.post("/api/iam/authenticate", {
|
|
99
|
-
data: {
|
|
100
|
-
email: TEST_USER_EMAIL,
|
|
101
|
-
username: "",
|
|
102
|
-
password: `${TEST_USER_PASSWORD}__wrong`,
|
|
103
|
-
},
|
|
104
|
-
});
|
|
105
|
-
(0, test_1.expect)(res.status()).toBe(401);
|
|
106
|
-
const json = await res.json();
|
|
107
|
-
(0, test_1.expect)(json.statusCode).toBe(401);
|
|
108
|
-
(0, test_1.expect)(json.statusCodeMessage).toBe("Unauthorized");
|
|
109
|
-
(0, test_1.expect)(json.message).toBe("Invalid credentials");
|
|
110
|
-
(0, test_1.expect)(json.error).toBe("Invalid credentials");
|
|
111
|
-
(0, test_1.expect)(json.data?.statusCode).toBe(401);
|
|
112
|
-
(0, test_1.expect)(json.data?.error).toBe("Unauthorized");
|
|
113
|
-
(0, test_1.expect)(json.data?.message).toBe("Invalid credentials");
|
|
114
|
-
}
|
|
115
|
-
finally {
|
|
116
|
-
await api.dispose();
|
|
117
|
-
}
|
|
118
|
-
});
|
|
119
|
-
//# sourceMappingURL=authenticate.spec.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"authenticate.spec.js","sourceRoot":"","sources":["../../tests/api/authenticate.spec.ts"],"names":[],"mappings":";;AAAA,2CAAyD;AACzD,wCAAgD;AAOhD,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,YAAY,IAAI,uBAAuB,CAAC;AACpE,MAAM,eAAe,GAAG,IAAA,oBAAc,EAAC,iBAAiB,CAAC,CAAC;AAC1D,MAAM,kBAAkB,GAAG,IAAA,oBAAc,EAAC,oBAAoB,CAAC,CAAC;AAEhE,SAAS,eAAe,CAAC,KAAa;IACpC,MAAM,UAAU,GAAG,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IAC/D,MAAM,MAAM,GACV,UAAU,CAAC,MAAM,GAAG,CAAC,KAAK,CAAC;QACzB,CAAC,CAAC,UAAU;QACZ,CAAC,CAAC,UAAU,CAAC,MAAM,CACjB,UAAU,CAAC,MAAM,GAAG,CAAC,CAAC,GAAG,CAAC,UAAU,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,EACjD,GAAG,CACJ,CAAC;IACN,OAAO,MAAM,CAAC,IAAI,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;AACzD,CAAC;AAED,SAAS,WAAW,CAAC,KAAa;IAChC,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC/B,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACvB,MAAM,IAAI,KAAK,CAAC,0CAA0C,CAAC,CAAC;IAC9D,CAAC;IAED,MAAM,UAAU,GAAG,IAAI,CAAC,KAAK,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IACzD,MAAM,WAAW,GAAG,IAAI,CAAC,KAAK,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAE1D,IAAI,CAAC,UAAU,IAAI,OAAO,UAAU,KAAK,QAAQ,EAAE,CAAC;QAClD,MAAM,IAAI,KAAK,CAAC,mCAAmC,CAAC,CAAC;IACvD,CAAC;IAED,IAAI,CAAC,WAAW,IAAI,OAAO,WAAW,KAAK,QAAQ,EAAE,CAAC;QACpD,MAAM,IAAI,KAAK,CAAC,oCAAoC,CAAC,CAAC;IACxD,CAAC;IAED,IAAI,OAAO,WAAW,CAAC,GAAG,KAAK,QAAQ,EAAE,CAAC;QACxC,MAAM,IAAI,KAAK,CAAC,mCAAmC,CAAC,CAAC;IACvD,CAAC;IAED,OAAO,WAAyB,CAAC;AACnC,CAAC;AAED,IAAA,WAAI,EAAC,mDAAmD,EAAE,KAAK,IAAI,EAAE;IACnE,MAAM,GAAG,GAAG,MAAM,cAAO,CAAC,UAAU,CAAC;QACnC,OAAO;QACP,gBAAgB,EAAE;YAChB,MAAM,EAAE,KAAK;YACb,cAAc,EAAE,kBAAkB;SACnC;KACF,CAAC,CAAC;IAEH,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,GAAG,CAAC,IAAI,CAAC,uBAAuB,EAAE;YAClD,IAAI,EAAE;gBACJ,KAAK,EAAE,eAAe;gBACtB,QAAQ,EAAE,EAAE;gBACZ,QAAQ,EAAE,kBAAkB;aAC7B;SACF,CAAC,CAAC;QAEH,IAAA,aAAM,EAAC,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAC/B,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;QAE9B,IAAA,aAAM,EAAC,IAAI,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAClC,IAAA,aAAM,EAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC/C,IAAA,aAAM,EAAC,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACpC,IAAA,aAAM,EAAC,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAE5B,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,EAAE,IAA2C,CAAC;QACpE,IAAA,aAAM,EAAC,IAAI,EAAE,qCAAqC,CAAC,CAAC,UAAU,EAAE,CAAC;QACjE,IAAA,aAAM,EAAC,OAAO,IAAI,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QAEnC,MAAM,KAAK,GAAG,IAAI,EAAE,KAAK,CAAC;QAC1B,IAAA,aAAM,EAAC,OAAO,KAAK,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACpC,IAAI,KAAK,KAAK,eAAe,EAAE,CAAC;YAC9B,IAAA,aAAM,EAAC,KAAK,CAAC,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC;QACtC,CAAC;aAAM,CAAC;YAEN,IAAA,aAAM,EAAE,KAAgB,CAAC,MAAM,CAAC,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC;QACtD,CAAC;QAED,IAAA,aAAM,EAAC,OAAO,IAAI,EAAE,MAAM,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QAC3C,IAAA,aAAM,EAAC,OAAO,IAAI,EAAE,QAAQ,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QAC7C,IAAA,aAAM,EAAC,OAAO,IAAI,EAAE,mBAAmB,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QACzD,IAAA,aAAM,EAAC,OAAO,IAAI,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QAEvC,MAAM,KAAK,GAAG,IAAI,EAAE,KAAK,CAAC;QAC1B,IAAA,aAAM,EAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACxC,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;YACzB,IAAA,aAAM,EAAC,KAAK,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACnE,IAAA,aAAM,EAAC,KAAK,CAAC,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC;QACnC,CAAC;QAED,MAAM,WAAW,GAAG,IAAI,CAAC,IAAI,EAAE,WAAW,CAAC;QAC3C,MAAM,YAAY,GAAG,IAAI,CAAC,IAAI,EAAE,YAAY,CAAC;QAC7C,IAAA,aAAM,EAAC,OAAO,WAAW,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QAC1C,IAAA,aAAM,EAAC,OAAO,YAAY,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QAE3C,MAAM,aAAa,GAAG,WAAW,CAAC,WAAqB,CAAC,CAAC;QACzD,MAAM,cAAc,GAAG,WAAW,CAAC,YAAsB,CAAC,CAAC;QAE3D,IAAA,aAAM,EAAC,OAAO,aAAa,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QAChD,IAAA,aAAM,EAAC,OAAO,cAAc,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IACnD,CAAC;YAAS,CAAC;QACT,MAAM,GAAG,CAAC,OAAO,EAAE,CAAC;IACtB,CAAC;AACH,CAAC,CAAC,CAAC;AAEH,IAAA,WAAI,EAAC,6CAA6C,EAAE,KAAK,IAAI,EAAE;IAC7D,MAAM,GAAG,GAAG,MAAM,cAAO,CAAC,UAAU,CAAC;QACnC,OAAO;QACP,gBAAgB,EAAE;YAChB,MAAM,EAAE,KAAK;YACb,cAAc,EAAE,kBAAkB;SACnC;KACF,CAAC,CAAC;IAEH,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,GAAG,CAAC,IAAI,CAAC,uBAAuB,EAAE;YAClD,IAAI,EAAE;gBACJ,KAAK,EAAE,eAAe;gBACtB,QAAQ,EAAE,EAAE;gBACZ,QAAQ,EAAE,GAAG,kBAAkB,SAAS;aACzC;SACF,CAAC,CAAC;QAEH,IAAA,aAAM,EAAC,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAC/B,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;QAE9B,IAAA,aAAM,EAAC,IAAI,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAClC,IAAA,aAAM,EAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;QACpD,IAAA,aAAM,EAAC,IAAI,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,qBAAqB,CAAC,CAAC;QACjD,IAAA,aAAM,EAAC,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,qBAAqB,CAAC,CAAC;QAC/C,IAAA,aAAM,EAAC,IAAI,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACxC,IAAA,aAAM,EAAC,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;QAC9C,IAAA,aAAM,EAAC,IAAI,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,qBAAqB,CAAC,CAAC;IACzD,CAAC;YAAS,CAAC;QACT,MAAM,GAAG,CAAC,OAAO,EAAE,CAAC;IACtB,CAAC;AACH,CAAC,CAAC,CAAC","sourcesContent":["import { expect, request, test } from \"@playwright/test\";\nimport { getRequiredEnv } from \"../helpers/env\";\n\ntype JwtPayload = {\n exp?: number;\n [key: string]: unknown;\n};\n\nconst baseURL = process.env.API_BASE_URL ?? \"http://localhost:3000\";\nconst TEST_USER_EMAIL = getRequiredEnv(\"TEST_USER_EMAIL\");\nconst TEST_USER_PASSWORD = getRequiredEnv(\"TEST_USER_PASSWORD\");\n\nfunction base64UrlDecode(input: string): string {\n const normalized = input.replace(/-/g, \"+\").replace(/_/g, \"/\");\n const padded =\n normalized.length % 4 === 0\n ? normalized\n : normalized.padEnd(\n normalized.length + (4 - (normalized.length % 4)),\n \"=\"\n );\n return Buffer.from(padded, \"base64\").toString(\"utf-8\");\n}\n\nfunction validateJwt(token: string): JwtPayload {\n const parts = token.split(\".\");\n if (parts.length !== 3) {\n throw new Error(\"JWT must have three dot-separated parts.\");\n }\n\n const headerJson = JSON.parse(base64UrlDecode(parts[0]));\n const payloadJson = JSON.parse(base64UrlDecode(parts[1]));\n\n if (!headerJson || typeof headerJson !== \"object\") {\n throw new Error(\"JWT header must be a JSON object.\");\n }\n\n if (!payloadJson || typeof payloadJson !== \"object\") {\n throw new Error(\"JWT payload must be a JSON object.\");\n }\n\n if (typeof payloadJson.exp !== \"number\") {\n throw new Error(\"JWT payload.exp must be a number.\");\n }\n\n return payloadJson as JwtPayload;\n}\n\ntest(\"API: authenticate succeeds with valid credentials\", async () => {\n const api = await request.newContext({\n baseURL,\n extraHTTPHeaders: {\n accept: \"*/*\",\n \"content-type\": \"application/json\",\n },\n });\n\n try {\n const res = await api.post(\"/api/iam/authenticate\", {\n data: {\n email: TEST_USER_EMAIL,\n username: \"\",\n password: TEST_USER_PASSWORD,\n },\n });\n\n expect(res.status()).toBe(200);\n const json = await res.json();\n\n expect(json.statusCode).toBe(200);\n expect(Array.isArray(json.message)).toBe(true);\n expect(json.message.length).toBe(0);\n expect(json.error).toBe(\"\");\n\n const user = json.data?.user as Record<string, unknown> | undefined;\n expect(user, \"Expected data.user to be an object.\").toBeTruthy();\n expect(typeof user).toBe(\"object\");\n\n const email = user?.email;\n expect(typeof email).toBe(\"string\");\n if (email === TEST_USER_EMAIL) {\n expect(email).toBe(TEST_USER_EMAIL);\n } else {\n // If your API returns a fixed system email, replace this with an exact match.\n expect((email as string).length).toBeGreaterThan(0);\n }\n\n expect(typeof user?.mobile).toBe(\"string\");\n expect(typeof user?.username).toBe(\"string\");\n expect(typeof user?.forcePasswordChange).toBe(\"boolean\");\n expect(typeof user?.id).toBe(\"number\");\n\n const roles = user?.roles;\n expect(Array.isArray(roles)).toBe(true);\n if (Array.isArray(roles)) {\n expect(roles.every((role) => typeof role === \"string\")).toBe(true);\n expect(roles).toContain(\"Admin\");\n }\n\n const accessToken = json.data?.accessToken;\n const refreshToken = json.data?.refreshToken;\n expect(typeof accessToken).toBe(\"string\");\n expect(typeof refreshToken).toBe(\"string\");\n\n const accessPayload = validateJwt(accessToken as string);\n const refreshPayload = validateJwt(refreshToken as string);\n\n expect(typeof accessPayload.exp).toBe(\"number\");\n expect(typeof refreshPayload.exp).toBe(\"number\");\n } finally {\n await api.dispose();\n }\n});\n\ntest(\"API: authenticate fails with wrong password\", async () => {\n const api = await request.newContext({\n baseURL,\n extraHTTPHeaders: {\n accept: \"*/*\",\n \"content-type\": \"application/json\",\n },\n });\n\n try {\n const res = await api.post(\"/api/iam/authenticate\", {\n data: {\n email: TEST_USER_EMAIL,\n username: \"\",\n password: `${TEST_USER_PASSWORD}__wrong`,\n },\n });\n\n expect(res.status()).toBe(401);\n const json = await res.json();\n\n expect(json.statusCode).toBe(401);\n expect(json.statusCodeMessage).toBe(\"Unauthorized\");\n expect(json.message).toBe(\"Invalid credentials\");\n expect(json.error).toBe(\"Invalid credentials\");\n expect(json.data?.statusCode).toBe(401);\n expect(json.data?.error).toBe(\"Unauthorized\");\n expect(json.data?.message).toBe(\"Invalid credentials\");\n } finally {\n await api.dispose();\n }\n});\n"]}
|