@mshima/generator-jhipster-nodejs 0.0.1 → 0.0.2

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 (24) hide show
  1. package/generators/node-server/entity-files.js +0 -15
  2. package/generators/node-server/generator.js +12 -9
  3. package/generators/node-server/generators/bootstrap/generator.js +2 -1
  4. package/generators/node-server/generators/migrations/command.js +5 -0
  5. package/generators/node-server/generators/migrations/files.js +42 -0
  6. package/generators/node-server/generators/migrations/generator.js +108 -0
  7. package/generators/node-server/generators/migrations/index.js +2 -0
  8. package/generators/node-server/generators/migrations/templates/server/src/migrations/_entityRemovedMigration_.ts.ejs +15 -0
  9. package/generators/node-server/generators/migrations/templates/server/src/migrations/_entityUpdatedConstraintsMigration_.ts.ejs +60 -0
  10. package/generators/node-server/generators/migrations/templates/server/src/migrations/_entityUpdatedMigration_.ts.ejs +28 -0
  11. package/generators/node-server/resources/package.json +2 -2
  12. package/generators/node-server/support/migrations.js +196 -17
  13. package/generators/node-server/templates/package.json.ejs +0 -2
  14. package/generators/node-server/templates/server/README.md.ejs +13 -5
  15. package/generators/node-server/templates/server/e2e/session.e2e-spec.ts.ejs +17 -2
  16. package/generators/node-server/templates/server/src/app.config.ts.ejs +4 -4
  17. package/generators/node-server/templates/server/src/auth/csrf.middleware.spec.ts.ejs +15 -4
  18. package/generators/node-server/templates/server/src/auth/csrf.middleware.ts.ejs +18 -9
  19. package/generators/node-server/templates/server/src/auth/session.store.ts.ejs +2 -2
  20. package/generators/node-server/templates/server/src/auth/user.session.controller.ts.ejs +2 -2
  21. package/generators/node-server/templates/server/src/migrations/support/schema.ts.ejs +76 -24
  22. package/package.json +2 -2
  23. /package/generators/node-server/{templates → generators/migrations/templates}/server/src/migrations/_entityConstraintsMigration_.ts.ejs +0 -0
  24. /package/generators/node-server/{templates → generators/migrations/templates}/server/src/migrations/_entityMigration_.ts.ejs +0 -0
@@ -23,19 +23,4 @@ export const entityFiles = {
23
23
  ],
24
24
  },
25
25
  ],
26
- // The migrations of the entity, like its Liquibase changelogs: its table, then its constraints when it has some.
27
- migrations: [
28
- {
29
- path: SERVER_NODEJS_DIR,
30
- condition: ctx => ctx.databaseTypeSql,
31
- renameTo: data => `${SERVER_NODEJS_DIR}src/migrations/${data.nodejsMigration.fileName}.ts`,
32
- templates: ['src/migrations/_entityMigration_.ts'],
33
- },
34
- {
35
- path: SERVER_NODEJS_DIR,
36
- condition: ctx => ctx.databaseTypeSql && ctx.nodejsConstraintsMigration,
37
- renameTo: data => `${SERVER_NODEJS_DIR}src/migrations/${data.nodejsConstraintsMigration.fileName}.ts`,
38
- templates: ['src/migrations/_entityConstraintsMigration_.ts'],
39
- },
40
- ],
41
26
  };
@@ -16,9 +16,7 @@ import {
16
16
  apiFakeEntity,
17
17
  apiFakeValue,
18
18
  columnTransformer,
19
- constraintsMigration,
20
19
  contentTypeColumnOptions,
21
- entityMigration,
22
20
  fieldColumnOptions,
23
21
  fieldColumnTypeImports,
24
22
  fieldTypeValidators,
@@ -225,6 +223,12 @@ export default class extends BaseApplicationGenerator {
225
223
  async composingTemplateTask() {
226
224
  await this.composeWithJHipster('docker');
227
225
  },
226
+ async composeMigrations() {
227
+ // The migrations of the entities of a SQL database, from their changelogs, like the incremental Liquibase changelogs.
228
+ if (this.jhipsterConfigWithDefaults.databaseType === 'sql') {
229
+ await this.composeWithJHipster('jhipster-nodejs:node-server:migrations');
230
+ }
231
+ },
228
232
  async composeLanguages() {
229
233
  // Like the spring-boot generator: the languages of the application to generate (languagesToGenerate), the server
230
234
  // messages of server:i18n among them, without a client composing it.
@@ -434,9 +438,8 @@ export default class extends BaseApplicationGenerator {
434
438
  application.nodejsUserPrimaryKeyColumn = primaryKeyColumn(application.user);
435
439
  application.nodejsUserAuthorityJoinTable = userAuthorityJoinTable(application);
436
440
  }
441
+ // The migrations of the entities are the ones of their changelogs (the migrations generator).
437
442
  for (const entity of entities.filter(entity => !entity.skipServer && !entity.builtIn)) {
438
- entity.nodejsMigration = entityMigration(entity);
439
- entity.nodejsConstraintsMigration = constraintsMigration(entity);
440
443
  // The join tables of the entity, its @JoinTable the same as its constraints migration.
441
444
  for (const relationship of entity.relationships.filter(relationship => relationship.shouldWriteJoinTable)) {
442
445
  relationship.nodejsJoinTable = joinTable(entity, relationship);
@@ -476,7 +479,7 @@ export default class extends BaseApplicationGenerator {
476
479
  'server/src/migrations/1570200490072-SeedUsersRoles.ts',
477
480
  ],
478
481
  ],
479
- '4.3.1': [
482
+ '4.2.1': [
480
483
  // The HTTP helpers of the server moved from src/client (a name taken by the client workspace) to src/common.
481
484
  'server/src/client/header-util.ts',
482
485
  'server/src/client/request.ts',
@@ -487,6 +490,9 @@ export default class extends BaseApplicationGenerator {
487
490
  'server/webpack.server.prod.config.js',
488
491
  // The Nest CLI builds and starts the server, and copies the configuration files (assets of nest-cli.json).
489
492
  'server/scripts/copy-resources.ts',
493
+ // The development profile of 4.2.0 (BACKEND_ENV=dev) is in .env.dev: the .env file, loaded by every start of the
494
+ // server, would start the built server with the development profile.
495
+ 'server/.env',
490
496
  // The files of 4.2.0 moved to the feature modules (auth, users, management, common), their specs next to them.
491
497
  'server/src/domain/authority.entity.ts',
492
498
  'server/src/domain/user.entity.ts',
@@ -574,7 +580,7 @@ export default class extends BaseApplicationGenerator {
574
580
  await control.cleanupFiles(this.oldNodejsVersion, {
575
581
  '3.0.1': entities.filter(e => !e.skipServer).map(e => `server/src/repository/${e.entityFileName}.repository.ts`),
576
582
  // The files of the entities, of 4.2.0, moved to their feature module (src/entities/<entity>).
577
- '4.3.1': entities
583
+ '4.2.1': entities
578
584
  .filter(entity => !entity.skipServer && !entity.builtIn)
579
585
  .flatMap(({ entityFileName, fields }) => [
580
586
  `server/src/domain/${entityFileName}.entity.ts`,
@@ -692,9 +698,6 @@ export default class extends BaseApplicationGenerator {
692
698
  if (!entity.builtIn) {
693
699
  source.addEntityToAppModule({ nodejsModulePath, entityClass });
694
700
  }
695
- for (const migration of [entity.nodejsMigration, entity.nodejsConstraintsMigration].filter(Boolean)) {
696
- source.addMigrationToNodeConfig(migration);
697
- }
698
701
  }
699
702
  },
700
703
  });
@@ -75,7 +75,8 @@ export default class extends BaseApplicationGenerator {
75
75
  return this.asDefaultTaskGroup({
76
76
  postPreparing({ application }) {
77
77
  if (application.authority) {
78
- application.authority.skipClient = !application.clientFrameworkAngular;
78
+ // The clients of JHipster with the screens of the built-in Authority, which their user management uses.
79
+ application.authority.skipClient = !application.clientFrameworkAngular && !application.clientFrameworkVue;
79
80
  }
80
81
  },
81
82
  });
@@ -0,0 +1,5 @@
1
+ import { asCommand } from 'generator-jhipster';
2
+
3
+ export default asCommand({
4
+ configs: {},
5
+ });
@@ -0,0 +1,42 @@
1
+ import { SERVER_NODEJS_SRC_DIR } from '../../../generator-nodejs-constants.js';
2
+
3
+ const SERVER_NODEJS_DIR = `${SERVER_NODEJS_SRC_DIR}/`;
4
+
5
+ /**
6
+ * The migrations of a changelog of this generation, like the Liquibase changelogs of its entity: for its creation its table,
7
+ * then its constraints when it has some; for an update its fields, then its relationships; for its removal its tables.
8
+ */
9
+ export const migrationFiles = {
10
+ migrations: [
11
+ {
12
+ path: SERVER_NODEJS_DIR,
13
+ condition: ctx => ctx.nodejsMigration,
14
+ renameTo: data => `${SERVER_NODEJS_DIR}src/migrations/${data.nodejsMigration.fileName}.ts`,
15
+ templates: ['src/migrations/_entityMigration_.ts'],
16
+ },
17
+ {
18
+ path: SERVER_NODEJS_DIR,
19
+ condition: ctx => ctx.nodejsConstraintsMigration,
20
+ renameTo: data => `${SERVER_NODEJS_DIR}src/migrations/${data.nodejsConstraintsMigration.fileName}.ts`,
21
+ templates: ['src/migrations/_entityConstraintsMigration_.ts'],
22
+ },
23
+ {
24
+ path: SERVER_NODEJS_DIR,
25
+ condition: ctx => ctx.nodejsUpdatedMigration,
26
+ renameTo: data => `${SERVER_NODEJS_DIR}src/migrations/${data.nodejsUpdatedMigration.fileName}.ts`,
27
+ templates: ['src/migrations/_entityUpdatedMigration_.ts'],
28
+ },
29
+ {
30
+ path: SERVER_NODEJS_DIR,
31
+ condition: ctx => ctx.nodejsUpdatedConstraintsMigration,
32
+ renameTo: data => `${SERVER_NODEJS_DIR}src/migrations/${data.nodejsUpdatedConstraintsMigration.fileName}.ts`,
33
+ templates: ['src/migrations/_entityUpdatedConstraintsMigration_.ts'],
34
+ },
35
+ {
36
+ path: SERVER_NODEJS_DIR,
37
+ condition: ctx => ctx.nodejsRemovedMigration,
38
+ renameTo: data => `${SERVER_NODEJS_DIR}src/migrations/${data.nodejsRemovedMigration.fileName}.ts`,
39
+ templates: ['src/migrations/_entityRemovedMigration_.ts'],
40
+ },
41
+ ],
42
+ };
@@ -0,0 +1,108 @@
1
+ import BaseApplicationGenerator from 'generator-jhipster/generators/base-application';
2
+ import {
3
+ CHANGELOGS_CUSTOM_OBJECT,
4
+ changelogsCustomObject,
5
+ getEntityChangelogs,
6
+ isNewEntityChangelog,
7
+ } from 'generator-jhipster/generators/base-application/generators/database-changelogs/support';
8
+ import { CONTEXT_DATA_APPLICATION_ENTITIES_KEY } from 'generator-jhipster/generators/base-application/support';
9
+
10
+ import { changelogMigrations, migrationOfFile } from '../../support/index.js';
11
+
12
+ import { migrationFiles } from './files.js';
13
+
14
+ /**
15
+ * The migrations of the entities of a SQL server, like the incremental Liquibase changelogs of JHipster Spring Boot: the
16
+ * migrations of the changelogs of the entities JHipster records (jhipster:base-application:database-changelogs, the
17
+ * changelogs custom object), a creation AddedEntity and AddedEntityConstraints, an update UpdatedEntity and
18
+ * UpdatedEntityConstraints (see support/migrations.js). A generation writes the migrations of its own changelogs, recorded
19
+ * in them, and registers every one: the ones of the former changelogs as they record them, written once.
20
+ */
21
+ export default class extends BaseApplicationGenerator {
22
+ /** The migrations of the changelogs of this generation, with their entity. */
23
+ #migrations = [];
24
+
25
+ constructor(args, options, features) {
26
+ // The changelogs once the entities are prepared: their migrations are written from the entities and their fields.
27
+ super(args, options, { ...features, customObjects: [changelogsCustomObject] });
28
+ }
29
+
30
+ async beforeQueue() {
31
+ await this.dependsOnJHipster('jhipster-nodejs:node-server:bootstrap');
32
+ }
33
+
34
+ /** The changelogs of the entities, the ones of this generation recorded (see getEntityChangelogs of JHipster). */
35
+ get changelogs() {
36
+ return getEntityChangelogs(this);
37
+ }
38
+
39
+ /** The entities of the application with a table of the server, as JHipster prepares them. */
40
+ get #entities() {
41
+ return [...this.getContextData(CONTEXT_DATA_APPLICATION_ENTITIES_KEY, { factory: () => new Map() }).values()];
42
+ }
43
+
44
+ get [BaseApplicationGenerator.COMPOSING]() {
45
+ return this.asComposingTaskGroup({
46
+ async composeChangelogs() {
47
+ await this.composeWithJHipster('jhipster:base-application:database-changelogs');
48
+ },
49
+ });
50
+ }
51
+
52
+ get [BaseApplicationGenerator.objectPriority(CHANGELOGS_CUSTOM_OBJECT, 'postPreparing')]() {
53
+ return {
54
+ prepareMigrations: ({ changelogs }) => {
55
+ const entities = this.#entities;
56
+ for (const changelog of changelogs.filter(changelog => isNewEntityChangelog(this, changelog))) {
57
+ // The entity of a creation or an update; a removal has none, its migration written from the entity before it.
58
+ const entity = entities.find(entity => entity.name === changelog.entityName);
59
+ if (!(entity ?? changelog.previousEntity).skipServer) {
60
+ this.#prepareMigrations(changelog, entity, entities);
61
+ }
62
+ }
63
+ },
64
+ };
65
+ }
66
+
67
+ get [BaseApplicationGenerator.WRITING_OBJECTS]() {
68
+ return this.asWritingObjectsTaskGroup({
69
+ async writeMigrations({ application }) {
70
+ for (const { entity, migrations } of this.#migrations) {
71
+ await this.writeFiles({ sections: migrationFiles, context: { ...application, ...entity, ...migrations } });
72
+ }
73
+ },
74
+ });
75
+ }
76
+
77
+ get [BaseApplicationGenerator.POST_WRITING_OBJECTS]() {
78
+ return this.asPostWritingObjectsTaskGroup({
79
+ registerMigrations({ source }) {
80
+ // The migrations every changelog records, the ones of this generation included.
81
+ for (const changelog of this.changelogs) {
82
+ for (const fileName of this.getObjectConfig(changelogName(changelog))?.nodejsMigrations ?? []) {
83
+ source.addMigrationToNodeConfig(migrationOfFile(fileName));
84
+ }
85
+ }
86
+ },
87
+ });
88
+ }
89
+
90
+ /**
91
+ * The migrations of a changelog of this generation (support/migrations.js), written with its entity, and recorded in its
92
+ * object (nodejsMigrations), which the next generations register.
93
+ */
94
+ #prepareMigrations(changelog, entity, entities) {
95
+ const migrations = changelogMigrations(changelog, entity, entities);
96
+ this.#migrations.push({ entity, migrations });
97
+ const name = changelogName(changelog);
98
+ this.setObjectConfig(name, {
99
+ ...this.getObjectConfig(name),
100
+ nodejsMigrations: Object.values(migrations)
101
+ .filter(Boolean)
102
+ .map(migration => migration.fileName),
103
+ });
104
+ }
105
+ }
106
+
107
+ /** The name of the object of a changelog, like JHipster names it. */
108
+ const changelogName = ({ changelogDate, entityName }) => `${changelogDate}_${entityName}`;
@@ -0,0 +1,2 @@
1
+ export { default } from './generator.js';
2
+ export { default as command } from './command.js';
@@ -0,0 +1,15 @@
1
+ import type { MigrationInterface, QueryRunner } from 'typeorm';
2
+
3
+ /** Removed the entity <%= nodejsRemovedMigration.entityName %>: its join tables and its table dropped. */
4
+ export class <%= nodejsRemovedMigration.className %> implements MigrationInterface {
5
+ public async up(queryRunner: QueryRunner): Promise<void> {
6
+ <%_ for (const name of nodejsRemovedMigration.joinTables) { _%>
7
+ await queryRunner.dropTable('<%= name %>', true);
8
+ <%_ } _%>
9
+ await queryRunner.dropTable('<%= nodejsRemovedMigration.tableName %>', true);
10
+ }
11
+
12
+ public async down(_queryRunner: QueryRunner): Promise<void> {
13
+ // The table of the removed entity and its join tables are not restored, nor their values.
14
+ }
15
+ }
@@ -0,0 +1,60 @@
1
+ import type { MigrationInterface, QueryRunner } from 'typeorm';
2
+ <%_ if (nodejsUpdatedConstraintsMigration.schemaImports) { _%>
3
+
4
+ import { <%= nodejsUpdatedConstraintsMigration.schemaImports %> } from './support/schema.ts';
5
+ <%_ } _%>
6
+
7
+ /**
8
+ * Updated the relationships of the entity <%= entityClass %>: the join columns, their foreign keys and the join tables of its
9
+ * removed relationships dropped, the ones of its added relationships.
10
+ */
11
+ export class <%= nodejsUpdatedConstraintsMigration.className %> implements MigrationInterface {
12
+ public async up(queryRunner: QueryRunner): Promise<void> {
13
+ <%_ for (const name of nodejsUpdatedConstraintsMigration.removedJoinTables) { _%>
14
+ await queryRunner.dropTable('<%= name %>', true);
15
+ <%_ } _%>
16
+ <%_ if (nodejsUpdatedConstraintsMigration.removedJoinColumnNames) { _%>
17
+ await dropColumns(queryRunner, '<%= nodejsUpdatedConstraintsMigration.tableName %>', <%- nodejsUpdatedConstraintsMigration.removedJoinColumnNames %>);
18
+ <%_ } _%>
19
+ <%_ if (nodejsUpdatedConstraintsMigration.addedJoinColumnNames) { _%>
20
+ await addColumns(
21
+ queryRunner,
22
+ '<%= nodejsUpdatedConstraintsMigration.tableName %>',
23
+ [
24
+ <%_ for (const column of nodejsUpdatedConstraintsMigration.addedJoinColumns) { _%>
25
+ { name: '<%= column.name %>', type: <%- column.type %>, nullable: true },
26
+ <%_ } _%>
27
+ ],
28
+ <%_ if (nodejsUpdatedConstraintsMigration.relationUniques) { _%>
29
+ { relationUniques: <%- nodejsUpdatedConstraintsMigration.relationUniques %> },
30
+ <%_ } _%>
31
+ );
32
+ await createForeignKeys(queryRunner, '<%= nodejsUpdatedConstraintsMigration.tableName %>', [
33
+ <%_ for (const foreignKey of nodejsUpdatedConstraintsMigration.addedForeignKeys) { _%>
34
+ { columnName: '<%= foreignKey.columnName %>', referencedTableName: '<%= foreignKey.referencedTableName %>', referencedColumnName: '<%= foreignKey.referencedColumnName %>' },
35
+ <%_ } _%>
36
+ ]);
37
+ <%_ } _%>
38
+ <%_ for (const joinTable of nodejsUpdatedConstraintsMigration.addedJoinTables) { _%>
39
+ await createJoinTable(
40
+ queryRunner,
41
+ '<%= joinTable.name %>',
42
+ <%_ for (const column of [joinTable.joinColumn, joinTable.inverseJoinColumn]) { _%>
43
+ { name: '<%= column.name %>', type: <%- column.type %>, referencedTableName: '<%= column.referencedTableName %>', referencedColumnName: '<%= column.referencedColumnName %>' },
44
+ <%_ } _%>
45
+ );
46
+ <%_ } _%>
47
+ }
48
+
49
+ public async down(<%= nodejsUpdatedConstraintsMigration.addedJoinColumnNames || nodejsUpdatedConstraintsMigration.addedJoinTables.length > 0 ? '' : '_' %>queryRunner: QueryRunner): Promise<void> {
50
+ <%_ if (nodejsUpdatedConstraintsMigration.removedJoinColumnNames || nodejsUpdatedConstraintsMigration.removedJoinTables.length > 0) { _%>
51
+ // The join columns and the join tables of the removed relationships are not restored, nor their values.
52
+ <%_ } _%>
53
+ <%_ for (const joinTable of nodejsUpdatedConstraintsMigration.addedJoinTables) { _%>
54
+ await queryRunner.dropTable('<%= joinTable.name %>', true);
55
+ <%_ } _%>
56
+ <%_ if (nodejsUpdatedConstraintsMigration.addedJoinColumnNames) { _%>
57
+ await dropColumns(queryRunner, '<%= nodejsUpdatedConstraintsMigration.tableName %>', <%- nodejsUpdatedConstraintsMigration.addedJoinColumnNames %>);
58
+ <%_ } _%>
59
+ }
60
+ }
@@ -0,0 +1,28 @@
1
+ import type { MigrationInterface, QueryRunner } from 'typeorm';
2
+
3
+ import { <%= nodejsUpdatedMigration.schemaImports %> } from './support/schema.ts';
4
+
5
+ /** Updated the fields of the entity <%= entityClass %>: the columns of its added fields, the ones of its removed fields dropped. */
6
+ export class <%= nodejsUpdatedMigration.className %> implements MigrationInterface {
7
+ public async up(queryRunner: QueryRunner): Promise<void> {
8
+ <%_ if (nodejsUpdatedMigration.addedColumnNames) { _%>
9
+ await addColumns(queryRunner, '<%= nodejsUpdatedMigration.tableName %>', [
10
+ <%_ for (const column of nodejsUpdatedMigration.addedColumns) { _%>
11
+ { name: '<%= column.name %>', type: <%- column.type %><% if (column.enum) { %>, enum: <%- column.enum %><% } %><% if (column.length) { %>, length: <%= column.length %><% } %><% if (column.precision) { %>, precision: <%= column.precision %>, scale: <%= column.scale %><% } %><% if (column.nullable) { %>, nullable: true<% } %><% if (column.unique) { %>, unique: true<% } %> },
12
+ <%_ } _%>
13
+ ]);
14
+ <%_ } _%>
15
+ <%_ if (nodejsUpdatedMigration.removedColumnNames) { _%>
16
+ await dropColumns(queryRunner, '<%= nodejsUpdatedMigration.tableName %>', <%- nodejsUpdatedMigration.removedColumnNames %>);
17
+ <%_ } _%>
18
+ }
19
+
20
+ public async down(<%= nodejsUpdatedMigration.addedColumnNames ? '' : '_' %>queryRunner: QueryRunner): Promise<void> {
21
+ <%_ if (nodejsUpdatedMigration.removedColumnNames) { _%>
22
+ // The columns of the removed fields are not restored, nor their values.
23
+ <%_ } _%>
24
+ <%_ if (nodejsUpdatedMigration.addedColumnNames) { _%>
25
+ await dropColumns(queryRunner, '<%= nodejsUpdatedMigration.tableName %>', <%- nodejsUpdatedMigration.addedColumnNames %>);
26
+ <%_ } _%>
27
+ }
28
+ }
@@ -29,7 +29,7 @@
29
29
  "jwt-decode": "4.0.0",
30
30
  "mongodb": "7.7.0",
31
31
  "mongodb-memory-server": "11.3.0",
32
- "mssql": "12.7.11",
32
+ "mssql": "12.7.13",
33
33
  "mysql2": "3.24.6",
34
34
  "oracledb": "7.0.1",
35
35
  "passport": "0.7.0",
@@ -68,7 +68,7 @@
68
68
  "sonarqube-scanner": "5.0.2",
69
69
  "supertest": "7.3.1",
70
70
  "typescript": "6.0.3",
71
- "typescript-eslint": "8.72.0",
71
+ "typescript-eslint": "8.72.1",
72
72
  "vite": "8.3.5",
73
73
  "vitest": "5.0.5"
74
74
  },
@@ -13,6 +13,14 @@ import { serverEnumInfo } from './enums.js';
13
13
  * the timestamp of the changelogDate of their entity, the changelogs of the constraints the ones of the constraints at
14
14
  * that timestamp plus CONSTRAINTS_TIMESTAMP_OFFSET, and the changelogs of the developers, at the end of master.xml, the
15
15
  * migrations the typeorm:migration scripts create at their time plus MIGRATION_TIMESTAMP_OFFSET.
16
+ *
17
+ * Like the incremental changelogs of Liquibase, the migrations of an entity are the ones of its changelogs JHipster records
18
+ * (jhipster:base-application:database-changelogs, the changelog objects of the .yo-rc.json): its creation, AddedEntity
19
+ * and AddedEntityConstraints, then each update at the date of its changelog plus MIGRATION_TIMESTAMP_OFFSET (after the
20
+ * constraints, like the incremental changelogs at the end of master.xml): UpdatedEntity adds the columns of the added
21
+ * fields and drops the ones of the removed fields, then UpdatedEntityConstraints, 1 ms after, adds and drops the join
22
+ * columns, their foreign keys and the join tables of the relationships. A migration is written once, by the generation
23
+ * recording its changelog.
16
24
  */
17
25
 
18
26
  /**
@@ -33,6 +41,22 @@ export const MIGRATION_TIMESTAMP_OFFSET = 6 * 10 ** 12;
33
41
  /** The JavaScript timestamp (milliseconds, UTC) of the changelogDate of an entity, the date JHipster parses it to. */
34
42
  const entityTimestamp = entity => entity.changelogDateForRecent.getTime();
35
43
 
44
+ /** The JavaScript timestamp (milliseconds, UTC) of a changelogDate of JHipster (yyyyMMddHHmmss). */
45
+ export const changelogDateToTimestamp = changelogDate => {
46
+ const [, year, month, day, hours, minutes, seconds] = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/.exec(changelogDate) ?? [];
47
+ if (!year) {
48
+ throw new Error(`The changelogDate ${changelogDate} is not a date of the yyyyMMddHHmmss format`);
49
+ }
50
+ return Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hours), Number(minutes), Number(seconds));
51
+ };
52
+
53
+ /** The class and the file of a migration of an entity, by the kind of the migration and its timestamp. */
54
+ const migrationName = (kind, entityClass, timestamp) => ({
55
+ timestamp,
56
+ className: `${kind}${entityClass}${timestamp}`,
57
+ fileName: `${timestamp}-${kind}${entityClass}`,
58
+ });
59
+
36
60
  /** The TypeScript of the type of a column whose @Column decorator has none: the TypeScript type of its property. */
37
61
  const typeOfFieldType = {
38
62
  string: 'String',
@@ -75,12 +99,15 @@ export const fieldColumns = field => {
75
99
  column.length = field.fieldValidateRulesMaxlength;
76
100
  }
77
101
  const columns = [column];
78
- if (['byte[]', 'ByteBuffer'].includes(field.fieldType) && field.fieldTypeBlobContent !== 'text') {
102
+ if (hasContentTypeColumn(field)) {
79
103
  columns.push({ name: `${field.fieldNameAsDatabaseColumn}_content_type`, type: 'String', nullable });
80
104
  }
81
105
  return columns;
82
106
  };
83
107
 
108
+ /** Whether a field is a binary blob, stored with the column of its content type. */
109
+ const hasContentTypeColumn = field => ['byte[]', 'ByteBuffer'].includes(field.fieldType) && field.fieldTypeBlobContent !== 'text';
110
+
84
111
  /**
85
112
  * The camelCase of the default naming strategy of TypeORM, the names it gives the join columns and the columns of the join
86
113
  * tables: unlike the one of lodash, it keeps the case of the letters it does not join (mainURL_id is mainURLId).
@@ -131,14 +158,11 @@ export const joinTable = (entity, relationship) => {
131
158
  };
132
159
  };
133
160
 
134
- /** The migration adding the table of an entity, its name, its class and its columns. */
135
- export const entityMigration = entity => {
136
- const timestamp = entityTimestamp(entity);
161
+ /** The migration adding the table of an entity, its name, its class and its columns, at the timestamp of its creation. */
162
+ export const entityMigration = (entity, timestamp = entityTimestamp(entity)) => {
137
163
  const relationships = joinColumnRelationships(entity);
138
164
  return {
139
- timestamp,
140
- className: `AddedEntity${entity.entityClass}${timestamp}`,
141
- fileName: `${timestamp}-AddedEntity${entity.entityClass}`,
165
+ ...migrationName('AddedEntity', entity.entityClass, timestamp),
142
166
  tableName: entity.entityTableName,
143
167
  columns: [
144
168
  primaryKeyColumn(entity),
@@ -158,25 +182,180 @@ export const entityMigration = entity => {
158
182
  * The migration adding the constraints of an entity, its foreign keys and its join tables, when it has some: run after the
159
183
  * migrations of every table, like the constraints changelogs of JHipster run after every table.
160
184
  */
161
- export const constraintsMigration = entity => {
185
+ export const constraintsMigration = (entity, timestamp = entityTimestamp(entity)) => {
162
186
  const joinColumns = joinColumnRelationships(entity);
163
187
  const joinTables = joinTableRelationships(entity);
164
188
  if (joinColumns.length === 0 && joinTables.length === 0) return undefined;
165
- const timestamp = entityTimestamp(entity) + CONSTRAINTS_TIMESTAMP_OFFSET;
166
189
  return {
167
- timestamp,
168
- className: `AddedEntityConstraints${entity.entityClass}${timestamp}`,
169
- fileName: `${timestamp}-AddedEntityConstraints${entity.entityClass}`,
190
+ ...migrationName('AddedEntityConstraints', entity.entityClass, timestamp + CONSTRAINTS_TIMESTAMP_OFFSET),
170
191
  tableName: entity.entityTableName,
171
- foreignKeys: joinColumns.map(relationship => ({
172
- columnName: joinColumnName(relationship),
173
- referencedTableName: relationship.otherEntity.entityTableName,
174
- referencedColumnName: relationship.otherEntity.primaryKey.name,
175
- })),
192
+ foreignKeys: joinColumns.map(foreignKey),
176
193
  joinTables: joinTables.map(relationship => joinTable(entity, relationship)),
177
194
  };
178
195
  };
179
196
 
197
+ /** The foreign key of the join column of a relationship. */
198
+ const foreignKey = relationship => ({
199
+ columnName: joinColumnName(relationship),
200
+ referencedTableName: relationship.otherEntity.entityTableName,
201
+ referencedColumnName: relationship.otherEntity.primaryKey.name,
202
+ });
203
+
204
+ /**
205
+ * The migrations of a changelog of an entity, of this generation: for its creation the table of the entity at its date
206
+ * (nodejsMigration), then its constraints (nodejsConstraintsMigration); for an update its fields (nodejsUpdatedMigration),
207
+ * then its relationships (nodejsUpdatedConstraintsMigration), after the constraints of every entity; for its removal its
208
+ * join tables and its table (nodejsRemovedMigration), after the updates of the entities whose relationships referenced it.
209
+ * @param {any} changelog the changelog
210
+ * @param {any} entity the entity, prepared: none for a removal
211
+ * @param {any[]} entities the entities of the application, prepared
212
+ */
213
+ export const changelogMigrations = (changelog, entity, entities) => {
214
+ const timestamp = changelogDateToTimestamp(changelog.changelogDate);
215
+ if (changelog.operation === 'create') {
216
+ return { nodejsMigration: entityMigration(entity, timestamp), nodejsConstraintsMigration: constraintsMigration(entity, timestamp) };
217
+ }
218
+ if (changelog.operation === 'update') {
219
+ const changes = { entity, ...changelogChanges(changelog, entity, entities) };
220
+ return {
221
+ nodejsUpdatedMigration: updatedEntityMigration(changes, timestamp + MIGRATION_TIMESTAMP_OFFSET),
222
+ // The relationships after the fields, whose join columns may reference them.
223
+ nodejsUpdatedConstraintsMigration: updatedConstraintsMigration(changes, timestamp + MIGRATION_TIMESTAMP_OFFSET + 1),
224
+ };
225
+ }
226
+ return { nodejsRemovedMigration: removedEntityMigration(changelog, timestamp + MIGRATION_TIMESTAMP_OFFSET) };
227
+ };
228
+
229
+ /**
230
+ * The migration of the removal of an entity, like the dropTable of Liquibase: the join tables it owned, then its table, from
231
+ * the entity before the changelog (its previousEntity). The relationships of the other entities referencing it are removed
232
+ * with it, their join columns and their join tables dropped by their update migrations, dated before.
233
+ * @param {any} changelog the removal changelog of this generation
234
+ * @param {number} timestamp the timestamp of the migration
235
+ */
236
+ export const removedEntityMigration = ({ entityName, previousEntity }, timestamp) => ({
237
+ ...migrationName('RemovedEntity', `${entityName.charAt(0).toUpperCase()}${entityName.slice(1)}`, timestamp),
238
+ entityName,
239
+ tableName: previousEntity.entityTableName,
240
+ joinTables: previousEntity.relationships.filter(hasJoinTable).map(relationship => relationship.joinTable.name),
241
+ });
242
+
243
+ /** The class of a migration, by the name of its file (`<timestamp>-<kind><Entity>`, its class `<kind><Entity><timestamp>`). */
244
+ export const migrationOfFile = fileName => ({ fileName, className: fileName.replace(/^(\d+)-(\w+)$/, '$2$1') });
245
+
246
+ /**
247
+ * The fields and the relationships an update changelog of an entity adds and removes: the added ones the entity has,
248
+ * prepared; the removed ones the entity before the changelog has (its previousEntity, of this generation only), by the
249
+ * names of their columns. A relationship changed in its type, its other entity, its side or its join column
250
+ * (recreatedRelationships) is removed and added again; one changed in its options only (onDelete, onUpdate), which the
251
+ * foreign keys of the server do not declare, is left alone. A field changed (its type, its validations) is not migrated.
252
+ * @param {any} changelog the update changelog of this generation
253
+ * @param {any} entity the entity, prepared
254
+ * @param {any[]} entities the entities of the application, prepared
255
+ */
256
+ export const changelogChanges = (changelog, entity, entities) => {
257
+ const { previousEntity, recreatedRelationships } = changelog;
258
+ const named = (items, names, key) => items.filter(item => names.includes(item[key]));
259
+ return {
260
+ addedFields: named(entity.fields, changelog.addedFields, 'fieldName'),
261
+ removedFields: named(previousEntity.fields, changelog.removedFields, 'fieldName'),
262
+ addedRelationships: named(entity.relationships, [...changelog.addedRelationships, ...recreatedRelationships], 'relationshipName'),
263
+ removedRelationships: named(
264
+ previousEntity.relationships,
265
+ [...changelog.removedRelationships, ...recreatedRelationships],
266
+ 'relationshipName',
267
+ ).map(relationship => removedRelationship(relationship, entities)),
268
+ };
269
+ };
270
+
271
+ /**
272
+ * A relationship of the entity before a changelog, as the changelog records it (its type, its side, its other entity and its
273
+ * join table), with the properties of a relationship the migrations name its join column by: its field, and the primary key
274
+ * of its other entity (id when the other entity is no longer).
275
+ */
276
+ const removedRelationship = (relationship, entities) => {
277
+ const otherEntity = entities.find(entity => entity.name.toLowerCase() === relationship.otherEntityName.toLowerCase());
278
+ return {
279
+ ...relationship,
280
+ relationshipFieldName: `${relationship.relationshipName.charAt(0).toLowerCase()}${relationship.relationshipName.slice(1)}`,
281
+ otherEntity: otherEntity ?? { primaryKey: { name: 'id' } },
282
+ };
283
+ };
284
+
285
+ /** The names of the columns of a field of the entity before a changelog, by the column name the changelog records. */
286
+ const removedFieldColumnNames = field => [field.columnName, ...(hasContentTypeColumn(field) ? [`${field.columnName}_content_type`] : [])];
287
+
288
+ /** The TypeScript of an array of names, none without a name. */
289
+ const namesLiteral = names => (names.length > 0 ? `[${names.map(literal).join(', ')}]` : undefined);
290
+
291
+ /**
292
+ * The migration of the fields of an entity an update changelog changes, like the updated_entity changelog of Liquibase: the
293
+ * columns of the added fields, and the names of the columns of the removed fields. None without an added or a removed field.
294
+ * @param {{ entity: any, addedFields: any[], removedFields: any[] }} changes the changes of the changelog (changelogChanges)
295
+ * @param {number} timestamp the timestamp of the migration
296
+ */
297
+ export const updatedEntityMigration = ({ entity, addedFields, removedFields }, timestamp) => {
298
+ if (addedFields.length === 0 && removedFields.length === 0) return undefined;
299
+ const addedColumns = addedFields.flatMap(fieldColumns);
300
+ return {
301
+ ...migrationName('UpdatedEntity', entity.entityClass, timestamp),
302
+ tableName: entity.entityTableName,
303
+ addedColumns,
304
+ // The TypeScript of the names of the columns the migration adds and drops, and of the helpers it imports.
305
+ addedColumnNames: namesLiteral(addedColumns.map(column => column.name)),
306
+ removedColumnNames: namesLiteral(removedFields.flatMap(removedFieldColumnNames)),
307
+ schemaImports: addedColumns.length > 0 ? 'addColumns, dropColumns' : 'dropColumns',
308
+ };
309
+ };
310
+
311
+ /** Whether a relationship has a join column: a many-to-one one, or a one-to-one one on its owner side. */
312
+ const hasJoinColumn = ({ relationshipType, ownerSide }) =>
313
+ relationshipType === 'many-to-one' || (relationshipType === 'one-to-one' && ownerSide);
314
+
315
+ /** Whether a relationship has a join table: a many-to-many one on its owner side. */
316
+ const hasJoinTable = ({ relationshipType, ownerSide }) => relationshipType === 'many-to-many' && ownerSide;
317
+
318
+ /**
319
+ * The migration of the relationships of an entity an update changelog changes, like the updated_entity_constraints changelog
320
+ * of Liquibase: the join columns with their foreign keys and the join tables of the added relationships, and the ones of
321
+ * the removed relationships dropped. None without a join column or a join table added or removed.
322
+ * @param {{ entity: any, addedRelationships: any[], removedRelationships: any[] }} changes the changes of the changelog (changelogChanges)
323
+ * @param {number} timestamp the timestamp of the migration
324
+ */
325
+ export const updatedConstraintsMigration = ({ entity, addedRelationships, removedRelationships }, timestamp) => {
326
+ const addedJoinColumns = addedRelationships.filter(hasJoinColumn);
327
+ const changes = {
328
+ addedJoinColumns: addedJoinColumns.map(relationship => ({
329
+ ...referenceColumn(joinColumnName(relationship), relationship.otherEntity),
330
+ nullable: true,
331
+ })),
332
+ addedForeignKeys: addedJoinColumns.map(foreignKey),
333
+ addedJoinTables: addedRelationships.filter(hasJoinTable).map(relationship => joinTable(entity, relationship)),
334
+ removedJoinColumns: removedRelationships.filter(hasJoinColumn).map(joinColumnName),
335
+ removedJoinTables: removedRelationships.filter(hasJoinTable).map(relationship => relationship.joinTable.name),
336
+ };
337
+ if (Object.values(changes).every(list => list.length === 0)) return undefined;
338
+ return {
339
+ ...migrationName('UpdatedEntityConstraints', entity.entityClass, timestamp),
340
+ tableName: entity.entityTableName,
341
+ ...changes,
342
+ // The TypeScript of the names of the join columns, of the unique ones of the one-to-one relationships, and of the helpers
343
+ // the migration imports.
344
+ addedJoinColumnNames: namesLiteral(changes.addedJoinColumns.map(column => column.name)),
345
+ relationUniques: namesLiteral(
346
+ addedJoinColumns.filter(relationship => relationship.relationshipType === 'one-to-one').map(joinColumnName),
347
+ ),
348
+ removedJoinColumnNames: namesLiteral(changes.removedJoinColumns),
349
+ schemaImports: [
350
+ addedJoinColumns.length > 0 && 'addColumns, createForeignKeys',
351
+ changes.addedJoinTables.length > 0 && 'createJoinTable',
352
+ (addedJoinColumns.length > 0 || changes.removedJoinColumns.length > 0) && 'dropColumns',
353
+ ]
354
+ .filter(Boolean)
355
+ .join(', '),
356
+ };
357
+ };
358
+
180
359
  /**
181
360
  * The join table of the authorities of the built-in users, named like TypeORM names the junction table of the @JoinTable()
182
361
  * of User.authorities: the tables and the property, its columns the tables and their primary keys.
@@ -65,8 +65,6 @@
65
65
  "docker:build": "docker build -t <%= baseName.toLowerCase() %> .",
66
66
  "lint": "<%= clientPackageManager %> run lint --workspaces",
67
67
  "lint:fix": "<%= clientPackageManager %> run lint:fix --workspaces",
68
- "prettier:check": "prettier --check \"{,src/**/}*.{<%= prettierExtensions %>}\"",
69
- "prettier:format": "prettier --write \"{,src/**/}*.{<%= prettierExtensions %>}\"",
70
68
  <%_ if (clientFrameworkAny) { _%>
71
69
  "start": "concurrently -k -s first -n application,client -c red,green npm:start:server npm:start:client",
72
70
  <%_ } else { _%>
@@ -103,11 +103,19 @@ creates the tables of the users and of the authorities, then each entity has the
103
103
  (`<timestamp>-AddedEntity<Entity>.ts`, the timestamp of its `changelogDate`) and, when it has relationships, the one adding
104
104
  its foreign keys and its join tables (`<timestamp>-AddedEntityConstraints<Entity>.ts`, its timestamp plus 3000000000000),
105
105
  run after the tables of every entity, like the constraints changelogs of Liquibase. A database the application ran them
106
- on already is not changed by a regeneration of a migration: when an entity changes, add the migration of the change
107
- (`npm run typeorm:migration:generate`) or recreate the development database. Like the Liquibase changelogs without
108
- incremental changelogs, a regeneration rewrites the migrations of the entity changed (a new column in the migration of its
109
- table): restore the migrations a database ran already (`git restore src/migrations`), keeping the entities, before
110
- generating the migration of the change, so an empty database runs them all.
106
+ on already is not changed by a regeneration of a migration.
107
+
108
+ Like the incremental changelogs of Liquibase, a regeneration keeps the migrations of an entity and generates the ones of
109
+ its changes since, run after the others (their timestamp plus <%= nodejsMigrationTimestampOffset %>):
110
+ `<timestamp>-UpdatedEntity<Entity>.ts` adds the columns of the added fields and drops the ones of the removed fields,
111
+ then `<timestamp>-UpdatedEntityConstraints<Entity>.ts` adds and drops the join columns, their foreign keys and the join
112
+ tables of the relationships, and `<timestamp>-RemovedEntity<Entity>.ts` drops the join tables and the table of a removed
113
+ entity, after the updates of the entities whose relationships referenced it. The changes are the changelogs JHipster
114
+ records in the `objects` of `.yo-rc.json`, one for each change of the database schema of an entity: keep them with the
115
+ migrations, the migrations of the application are the ones of its changelogs. A field changed (its type, its
116
+ validations) and a table renamed are not migrated, a dropped column or table keeps none of its values, and a required
117
+ field added to a table having rows needs a migration of your own giving them a value
118
+ (`npm run typeorm:migration:generate`).
111
119
 
112
120
  The application runs the migrations [src/orm.config.ts](src/orm.config.ts) lists: import the class of a migration of
113
121
  your own and add it to `migrations`, so the application runs it at startup, like the ones JHipster adds. The
@@ -121,9 +121,14 @@ describe('Session authentication', () => {
121
121
  });
122
122
 
123
123
  it('logs in the user of the login, in lower case, in a session of 30 minutes without requests', async () => {
124
+ const sessions = app.get<Repository<Session>>(getRepositoryToken(Session));
124
125
  const response = await login({ username: 'Session-User', password, 'remember-me': 'false' });
125
126
  expect(response.status).toBe(200);
126
- expect(cookieExpiry(response, 'JSESSIONID')).toBeLessThanOrEqual(Date.now() + 30 * 60 * 1000);
127
+ // Like the JSESSIONID of JHipster Spring Boot, a cookie until the browser is closed: the session expires on the server.
128
+ expect(setCookie(response, 'JSESSIONID')).not.toMatch(/Expires|Max-Age/);
129
+ expect(setCookie(response, 'XSRF-TOKEN')).not.toMatch(/Expires|Max-Age/);
130
+ const [stored] = await sessions.find({ where: { login: 'session-user' } });
131
+ expect(stored.expires.getTime()).toBeLessThanOrEqual(Date.now() + 30 * 60 * 1000);
127
132
  const { body } = await agent.get('/api/account').expect(200);
128
133
  expect(body).toMatchObject({ login: 'session-user' });
129
134
  expect(body.password).toBeUndefined();
@@ -158,7 +163,17 @@ describe('Session authentication', () => {
158
163
  const response = await login({ username: 'session-user', password, 'remember-me': 'true' });
159
164
  expect(response.status).toBe(200);
160
165
  expect(cookieExpiry(response, 'JSESSIONID')).toBeGreaterThan(Date.now() + 30 * 24 * 60 * 60 * 1000);
161
- expect(cookieExpiry(response, 'XSRF-TOKEN')).toBeGreaterThan(Date.now() + 30 * 24 * 60 * 60 * 1000);
166
+ // The CSRF cookie lasts until the browser is closed, the next response giving the token of the session again.
167
+ expect(setCookie(response, 'XSRF-TOKEN')).not.toMatch(/Expires|Max-Age/);
168
+ const token = cookieValue(response, 'XSRF-TOKEN');
169
+ const reopened = request(app.getHttpServer()).get('/api/account').set('Cookie', setCookie(response, 'JSESSIONID')!.split(';')[0]);
170
+ expect(cookieValue(await reopened.expect(200), 'XSRF-TOKEN')).toEqual(token);
171
+ });
172
+
173
+ it('sets no cookie in the responses of a logged session: a response of a session ended by a login meanwhile puts nothing back', async () => {
174
+ await login({ username: 'session-user', password });
175
+ const response = await agent.get('/api/account').expect(200);
176
+ expect(response.headers['set-cookie']).toBeUndefined();
162
177
  });
163
178
 
164
179
  it('refuses the unsafe requests of a session without its CSRF token, the logout included', async () => {
@@ -12,7 +12,6 @@ import { HeaderUtil } from './common/header-util.ts';
12
12
  import { ProblemDetailsFilter } from './common/errors/problem-details.filter.ts';
13
13
  import { ValidationException } from './common/errors/problem.exception.ts';
14
14
  <%_ if (authenticationUsesCsrf) { _%>
15
- import { SESSION_MAX_AGE } from './auth/csrf.middleware.ts';
16
15
  import { SessionStore } from './auth/session.store.ts';
17
16
  <%_ } _%>
18
17
 
@@ -142,9 +141,10 @@ export function configureApp(app: INestApplication): INestApplication {
142
141
  store: app.get(SessionStore),
143
142
  resave: false,
144
143
  saveUninitialized: false,
145
- // Like the session of JHipster Spring Boot, it expires after 30 minutes without requests.
146
- rolling: true,
147
- cookie: { httpOnly: true, sameSite: 'lax', secure: 'auto', maxAge: SESSION_MAX_AGE },
144
+ // Like the JSESSIONID of JHipster Spring Boot, a cookie until the browser is closed, set when the session is created
145
+ // only: the session expires on the server, after 30 minutes without requests (SessionStore). A cookie set again by
146
+ // every response would let a response of a session ended by a login meanwhile put the ended session back.
147
+ cookie: { httpOnly: true, sameSite: 'lax', secure: 'auto' },
148
148
  }),
149
149
  );
150
150
  <%_ } _%>
@@ -28,9 +28,13 @@ describe('CsrfMiddleware', () => {
28
28
  expect(() => checkLoginCsrfToken({ headers: { cookie: 'SESSION=1; XSRF-TOKEN=t0k', 'x-xsrf-token': 't0k' } } as any)).not.toThrow();
29
29
  });
30
30
 
31
- it('gives the CSRF cookie of a remember-me session its lifetime', () => {
31
+ it('gives the CSRF cookie of a remember-me session until the browser is closed, like JHipster Spring Boot', () => {
32
32
  const { res } = call('GET', { user: { login: 'user' }, csrfToken: 't0k', cookie: { originalMaxAge: 1000 } });
33
- expect(res.cookie).toHaveBeenCalledWith('XSRF-TOKEN', 't0k', expect.objectContaining({ maxAge: 1000 }));
33
+ expect(res.cookie).toHaveBeenCalledWith('XSRF-TOKEN', 't0k', expect.not.objectContaining({ maxAge: expect.anything() }));
34
+ });
35
+
36
+ it('gives a CSRF cookie until the browser is closed to a client without a session, like JHipster Spring Boot', () => {
37
+ expect(call('GET', {}).res.cookie).toHaveBeenCalledWith('XSRF-TOKEN', expect.any(String), expect.not.objectContaining({ maxAge: expect.anything() }));
34
38
  });
35
39
  <%_ } else { _%>
36
40
  it('lets the requests without a logged session pass, without the cookie', () => {
@@ -40,10 +44,17 @@ describe('CsrfMiddleware', () => {
40
44
  });
41
45
  <%_ } _%>
42
46
 
43
- it('gives the token of a logged session back as the XSRF-TOKEN cookie on its safe requests too', () => {
47
+ it('gives the token of a logged session as the XSRF-TOKEN cookie on its safe requests too, until the browser is closed', () => {
44
48
  const { res, next } = call('GET', { user: { login: 'user' }, csrfToken: 't0k' });
45
49
  expect(next).toHaveBeenCalled();
46
- expect(res.cookie).toHaveBeenCalledWith('XSRF-TOKEN', 't0k', expect.objectContaining({ path: '/', maxAge: expect.any(Number) }));
50
+ expect(res.cookie).toHaveBeenCalledWith('XSRF-TOKEN', 't0k', expect.objectContaining({ path: '/' }));
51
+ expect(res.cookie).toHaveBeenCalledWith('XSRF-TOKEN', 't0k', expect.not.objectContaining({ maxAge: expect.anything() }));
52
+ });
53
+
54
+ it('does not set the cookie of a client with the token of its session: a response of a session ended meanwhile puts no old token back', () => {
55
+ const { res, next } = call('GET', { user: { login: 'user' }, csrfToken: 't0k' }, { cookie: 'JSESSIONID=s1; XSRF-TOKEN=t0k' });
56
+ expect(next).toHaveBeenCalled();
57
+ expect(res.cookie).not.toHaveBeenCalled();
47
58
  });
48
59
 
49
60
  it('refuses an unsafe request of a logged session without its token, the logout too, like Spring Security', () => {
@@ -2,13 +2,15 @@ import { randomUUID } from 'crypto';
2
2
  import { ForbiddenException, Injectable, NestMiddleware } from '@nestjs/common';
3
3
  import { NextFunction, Request, Response } from 'express';
4
4
 
5
- /** The idle lifetime of a session, rolling: its cookie and the CSRF cookie expire together. */
5
+ /** The idle lifetime of a session, on the server: its cookie lasts until the browser is closed. */
6
6
  export const SESSION_MAX_AGE = 30 * 60 * 1000;
7
7
  <%_ if (authenticationTypeSession) { _%>
8
8
 
9
9
  /**
10
10
  * The idle lifetime of a session logged in with remember-me, like the validity of the remember-me tokens of JHipster
11
- * Spring Boot (31 days): kept in the database (SessionStore), the session outlives a restart like those tokens.
11
+ * Spring Boot (31 days): kept in the database (SessionStore), the session outlives a restart like those tokens. Its
12
+ * cookie expires 31 days after the login, unlike the remember-me cookie of JHipster Spring Boot, renewed at each new
13
+ * session it logs in.
12
14
  */
13
15
  export const REMEMBER_ME_MAX_AGE = 31 * 24 * 60 * 60 * 1000;
14
16
  <%_ } _%>
@@ -16,11 +18,13 @@ export const REMEMBER_ME_MAX_AGE = 31 * 24 * 60 * 60 * 1000;
16
18
  /** The methods that change nothing, which the CSRF token does not protect, like the ones of Spring Security. */
17
19
  const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS', 'TRACE'];
18
20
 
19
- /** The XSRF-TOKEN cookie the clients (Angular HttpClient, axios) send back as the X-XSRF-TOKEN header. */
20
- export function setCsrfCookie(res: Response, token: string, maxAge = SESSION_MAX_AGE): void {
21
- res.cookie('XSRF-TOKEN', token, { sameSite: 'lax', path: '/', maxAge });
21
+ /**
22
+ * The XSRF-TOKEN cookie the clients (Angular HttpClient, axios) send back as the X-XSRF-TOKEN header: until the browser is
23
+ * closed, like the one of JHipster Spring Boot, a remember-me session included.
24
+ */
25
+ export function setCsrfCookie(res: Response, token: string): void {
26
+ res.cookie('XSRF-TOKEN', token, { sameSite: 'lax', path: '/' });
22
27
  }
23
- <%_ if (authenticationTypeSession) { _%>
24
28
 
25
29
  /** The XSRF-TOKEN cookie of a request. */
26
30
  function csrfCookie(req: Request): string | undefined {
@@ -30,6 +34,7 @@ function csrfCookie(req: Request): string | undefined {
30
34
  .find(value => value.startsWith('XSRF-TOKEN='));
31
35
  return cookie ? decodeURIComponent(cookie.slice('XSRF-TOKEN='.length)) : undefined;
32
36
  }
37
+ <%_ if (authenticationTypeSession) { _%>
33
38
 
34
39
  /**
35
40
  * The CSRF check of the session login, like the CookieCsrfTokenRepository of Spring Security: the X-XSRF-TOKEN header is
@@ -46,7 +51,7 @@ export function checkLoginCsrfToken(req: Request): void {
46
51
 
47
52
  /**
48
53
  * The CSRF protection of the sessions, like the CSRF filter of Spring Security and the CookieCsrfFilter of JHipster Spring
49
- * Boot: every response of a logged session gives its token back as the XSRF-TOKEN cookie, and every unsafe request of it,
54
+ * Boot: a logged session gives its token as the XSRF-TOKEN cookie to a client without it, and every unsafe request of it,
50
55
  * the logout included, must send it as X-XSRF-TOKEN, else 403. A request with a bearer token has no session to protect.
51
56
  <%_ if (authenticationTypeSession) { _%>
52
57
  * A client without a logged session gets a token cookie, which its login must send back (checkLoginCsrfToken).
@@ -66,8 +71,12 @@ export class CsrfMiddleware implements NestMiddleware {
66
71
  return;
67
72
  }
68
73
  session.csrfToken ??= randomUUID();
69
- // The CSRF cookie expires with the session cookie<% if (authenticationTypeSession) { %>, later for a remember-me login<% } %>.
70
- setCsrfCookie(res, session.csrfToken, session.cookie?.originalMaxAge ?? SESSION_MAX_AGE);
74
+ if (csrfCookie(req) !== session.csrfToken) {
75
+ // Like Spring Security, the cookie is set when the client has not the token of its session only (a remember-me
76
+ // session after the browser is closed): a response of a session ended by a login meanwhile does not put the token
77
+ // of the ended session back.
78
+ setCsrfCookie(res, session.csrfToken);
79
+ }
71
80
  if (!SAFE_METHODS.includes(req.method) && req.headers['x-xsrf-token'] !== session.csrfToken) {
72
81
  throw new ForbiddenException('Invalid CSRF token');
73
82
  }
@@ -59,7 +59,7 @@ export class SessionStore extends session.Store implements OnModuleInit, OnModul
59
59
  }
60
60
 
61
61
  touch(sid: string, data: session.SessionData, callback?: Callback): void {
62
- // The rolling expiry of a session its request did not change.
62
+ // The idle expiry of a session its request did not change.
63
63
  this.track(this.repository.update({ sid }, { expires: expiresOf(data) })).then(() => callback?.(), error => callback?.(error));
64
64
  }
65
65
 
@@ -104,7 +104,7 @@ export class SessionStore extends session.Store implements OnModuleInit, OnModul
104
104
  }
105
105
  }
106
106
 
107
- /** The end of a session: the expiry of its cookie. */
107
+ /** The end of a session: the expiry of its cookie with remember-me, else 30 minutes after its last request. */
108
108
  function expiresOf(data: session.SessionData): Date {
109
109
  return data.cookie?.expires ? new Date(data.cookie.expires) : new Date(Date.now() + SESSION_MAX_AGE);
110
110
  }
@@ -64,10 +64,10 @@ export class UserSessionController {
64
64
  session.userAgent = req.get('user-agent');
65
65
  if (REMEMBER_ME_VALUES.includes(String(form['remember-me']).toLowerCase())) {
66
66
  // Like the remember-me of JHipster Spring Boot, the login is kept after the browser is closed, until the session
67
- // is unused for 31 days.
67
+ // is unused for 31 days (its cookie expires 31 days after the login).
68
68
  session.cookie.maxAge = REMEMBER_ME_MAX_AGE;
69
69
  }
70
- setCsrfCookie(res, session.csrfToken, session.cookie.originalMaxAge);
70
+ setCsrfCookie(res, session.csrfToken);
71
71
  await new Promise<void>((resolve, reject) => session.save((error?: Error) => (error ? reject(error) : resolve())));
72
72
  }
73
73
 
@@ -1,4 +1,4 @@
1
- import { type ColumnType, type QueryRunner, Table, type TableColumnOptions, TableForeignKey } from 'typeorm';
1
+ import { type ColumnType, type QueryRunner, Table, TableColumn, type TableColumnOptions, TableForeignKey, TableIndex, TableUnique } from 'typeorm';
2
2
  import type { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata.js';
3
3
 
4
4
  import { databaseColumnType, databaseDatetimePrecision } from '../../common/database/column-types.ts';
@@ -111,32 +111,87 @@ export const createTable = async (
111
111
  columns: MigrationColumn[],
112
112
  options: { relationUniques?: string[] } = {},
113
113
  ): Promise<void> => {
114
+ const { tableColumns, uniques, indices } = uniqueColumns(queryRunner, name, columns, options.relationUniques);
115
+ await queryRunner.createTable(new Table({ name, columns: tableColumns, uniques, indices }), true);
116
+ };
117
+
118
+ /**
119
+ * The columns of a table with their unique constraints, as TypeORM stores them: unique columns, unique constraints or
120
+ * unique indices, by database.
121
+ */
122
+ const uniqueColumns = (queryRunner: QueryRunner, tableName: string, columns: MigrationColumn[], relationUniqueColumns: string[] = []) => {
114
123
  const { driver, namingStrategy } = queryRunner.connection;
115
124
  const uniqueIndices = uniqueIndexDatabases.includes(driver.options.type);
116
125
  const relationUniqueIndices = uniqueIndices || driver.options.type === 'mssql';
117
126
  // The unique constraint of the join column of a one-to-one relationship, named like TypeORM names it.
118
- const relationUniques = (options.relationUniques ?? []).map(columnName => ({
119
- name: namingStrategy.relationConstraintName(name, [columnName]),
127
+ const relationUniques = relationUniqueColumns.map(columnName => ({
128
+ name: namingStrategy.relationConstraintName(tableName, [columnName]),
120
129
  columnNames: [columnName],
121
130
  }));
122
- await queryRunner.createTable(
123
- new Table({
124
- name,
125
- columns: columns.map(column => tableColumn(queryRunner, { ...column, unique: column.unique && !uniqueIndices })),
126
- uniques: relationUniqueIndices ? [] : relationUniques,
127
- indices: [
128
- ...(uniqueIndices ? columns.filter(column => column.unique).map(column => ({ columnNames: [column.name], isUnique: true })) : []),
129
- ...(relationUniqueIndices
130
- ? relationUniques.map(unique => ({
131
- ...unique,
132
- isUnique: true,
133
- where: driver.options.type === 'mssql' ? `${driver.escape(unique.columnNames[0])} IS NOT NULL` : undefined,
134
- }))
135
- : []),
136
- ],
137
- }),
138
- true,
131
+ return {
132
+ tableColumns: columns.map(column => tableColumn(queryRunner, { ...column, unique: column.unique && !uniqueIndices })),
133
+ uniques: relationUniqueIndices ? [] : relationUniques,
134
+ indices: [
135
+ ...(uniqueIndices ? columns.filter(column => column.unique).map(column => ({ columnNames: [column.name], isUnique: true })) : []),
136
+ ...(relationUniqueIndices
137
+ ? relationUniques.map(unique => ({
138
+ ...unique,
139
+ isUnique: true,
140
+ where: driver.options.type === 'mssql' ? `${driver.escape(unique.columnNames[0])} IS NOT NULL` : undefined,
141
+ }))
142
+ : []),
143
+ ],
144
+ };
145
+ };
146
+
147
+ /** The table of a migration, which the database has. */
148
+ const existingTable = async (queryRunner: QueryRunner, tableName: string): Promise<Table> => {
149
+ const table = await queryRunner.getTable(tableName);
150
+ if (!table) {
151
+ throw new Error(`The table ${tableName} does not exist`);
152
+ }
153
+ return table;
154
+ };
155
+
156
+ /**
157
+ * Adds the columns of a table missing in the database, like createTable creates them: a database synchronized from the
158
+ * entities has them already.
159
+ */
160
+ export const addColumns = async (
161
+ queryRunner: QueryRunner,
162
+ tableName: string,
163
+ columns: MigrationColumn[],
164
+ options: { relationUniques?: string[] } = {},
165
+ ): Promise<void> => {
166
+ const table = await existingTable(queryRunner, tableName);
167
+ const missing = columns.filter(column => !table.findColumnByName(column.name));
168
+ if (missing.length === 0) return;
169
+ const missingNames = missing.map(column => column.name);
170
+ const { tableColumns, uniques, indices } = uniqueColumns(
171
+ queryRunner,
172
+ tableName,
173
+ missing,
174
+ (options.relationUniques ?? []).filter(name => missingNames.includes(name)),
139
175
  );
176
+ await queryRunner.addColumns(table, tableColumns.map(column => new TableColumn(column)));
177
+ if (uniques.length > 0) {
178
+ await queryRunner.createUniqueConstraints(tableName, uniques.map(unique => new TableUnique(unique)));
179
+ }
180
+ if (indices.length > 0) {
181
+ await queryRunner.createIndices(tableName, indices.map(index => new TableIndex(index)));
182
+ }
183
+ };
184
+
185
+ /** Drops the columns of a table the database has, with their foreign keys. */
186
+ export const dropColumns = async (queryRunner: QueryRunner, tableName: string, columnNames: string[]): Promise<void> => {
187
+ const table = await existingTable(queryRunner, tableName);
188
+ const existing = columnNames.filter(name => table.findColumnByName(name));
189
+ if (existing.length === 0) return;
190
+ const foreignKeys = table.foreignKeys.filter(foreignKey => foreignKey.columnNames.some(name => existing.includes(name)));
191
+ if (foreignKeys.length > 0) {
192
+ await queryRunner.dropForeignKeys(table, foreignKeys);
193
+ }
194
+ await queryRunner.dropColumns(tableName, existing);
140
195
  };
141
196
 
142
197
  /**
@@ -144,10 +199,7 @@ export const createTable = async (
144
199
  * has them already.
145
200
  */
146
201
  export const createForeignKeys = async (queryRunner: QueryRunner, tableName: string, foreignKeys: MigrationForeignKey[]): Promise<void> => {
147
- const table = await queryRunner.getTable(tableName);
148
- if (!table) {
149
- throw new Error(`The table ${tableName} does not exist`);
150
- }
202
+ const table = await existingTable(queryRunner, tableName);
151
203
  const missing = foreignKeys.filter(
152
204
  foreignKey =>
153
205
  !table.foreignKeys.some(existing => existing.columnNames.length === 1 && existing.columnNames[0] === foreignKey.columnName),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mshima/generator-jhipster-nodejs",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "A NodeJS blueprint that creates the backend using NodeJS with NestJS framework",
5
5
  "keywords": [
6
6
  "yeoman-generator",
@@ -55,7 +55,7 @@
55
55
  },
56
56
  "dependencies": {
57
57
  "chalk": "^5.3.0",
58
- "generator-jhipster": "npm:@mshima/generator-jhipster@0.0.9"
58
+ "generator-jhipster": "npm:@mshima/generator-jhipster@0.0.10"
59
59
  },
60
60
  "devDependencies": {
61
61
  "ejs-lint": "2.0.1",