From cc80d3b33de32658d97fd84ad11db6df3a9d83a0 Mon Sep 17 00:00:00 2001 From: Emil Gunnarsson Date: Wed, 8 Jan 2025 16:14:40 +0100 Subject: [PATCH 01/11] use openapi cli plugin for sample dtos and schema --- nest-cli.json | 8 +++- src/samples/dto/create-sample.dto.ts | 10 ++--- src/samples/dto/update-sample.dto.ts | 40 +++++++---------- src/samples/samples.service.spec.ts | 1 - src/samples/schemas/sample.schema.ts | 64 +++++++++------------------- 5 files changed, 46 insertions(+), 77 deletions(-) diff --git a/nest-cli.json b/nest-cli.json index 6e477fdd4..9ae1faa63 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -2,6 +2,12 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "plugins": ["@nestjs/swagger"] + "plugins": [{ + "name": "@nestjs/swagger", + "options": { + "dtoFileNameSuffix": [".dto.ts", "sample.schema.ts"], + "introspectComments": true + } + }] } } diff --git a/src/samples/dto/create-sample.dto.ts b/src/samples/dto/create-sample.dto.ts index 9483c0029..0dddb547b 100644 --- a/src/samples/dto/create-sample.dto.ts +++ b/src/samples/dto/create-sample.dto.ts @@ -1,14 +1,10 @@ -import { ApiProperty } from "@nestjs/swagger"; import { IsOptional, IsString } from "class-validator"; import { UpdateSampleDto } from "./update-sample.dto"; export class CreateSampleDto extends UpdateSampleDto { - @ApiProperty({ - type: String, - required: false, - description: - "Globally unique identifier of a sample. This could be provided as an input value or generated by the system.", - }) + /** + * Globally unique identifier of a sample. This could be provided as an input value or generated by the system. + */ @IsString() @IsOptional() readonly sampleId?: string; diff --git a/src/samples/dto/update-sample.dto.ts b/src/samples/dto/update-sample.dto.ts index f8166dd7e..992d6c980 100644 --- a/src/samples/dto/update-sample.dto.ts +++ b/src/samples/dto/update-sample.dto.ts @@ -1,45 +1,35 @@ -import { ApiProperty, PartialType } from "@nestjs/swagger"; +import { PartialType } from "@nestjs/swagger"; import { IsBoolean, IsObject, IsOptional, IsString } from "class-validator"; import { OwnableDto } from "../../common/dto/ownable.dto"; export class UpdateSampleDto extends OwnableDto { - @ApiProperty({ - type: String, - required: false, - description: "The owner of the sample.", - }) + /** + * The owner of the sample. + */ @IsString() @IsOptional() readonly owner?: string; - @ApiProperty({ - type: String, - required: false, - description: "A description of the sample.", - }) + /** + * A description of the sample. + */ @IsString() @IsOptional() readonly description?: string; - @ApiProperty({ - type: Object, - default: {}, - required: false, - description: "JSON object containing the sample characteristics metadata.", - }) + /** + * JSON object containing the sample characteristics metadata. + */ @IsObject() @IsOptional() - readonly sampleCharacteristics?: Record; + readonly sampleCharacteristics?: Record = {}; - @ApiProperty({ - type: Boolean, - default: false, - required: false, - description: "Flag is true when data are made publicly available.", - }) + /** + * Flag is true when data are made publicly available. + */ @IsBoolean() @IsOptional() - readonly isPublished?: boolean; + readonly isPublished?: boolean = false; } export class PartialUpdateSampleDto extends PartialType(UpdateSampleDto) {} diff --git a/src/samples/samples.service.spec.ts b/src/samples/samples.service.spec.ts index 1933161b4..707da68d9 100644 --- a/src/samples/samples.service.spec.ts +++ b/src/samples/samples.service.spec.ts @@ -43,7 +43,6 @@ const mockSample: SampleClass = { describe("SamplesService", () => { let service: SamplesService; - // eslint-disable-next-line @typescript-eslint/no-unused-vars let sampleModel: Model; beforeEach(async () => { diff --git a/src/samples/schemas/sample.schema.ts b/src/samples/schemas/sample.schema.ts index 213f62ea6..857dbf7af 100644 --- a/src/samples/schemas/sample.schema.ts +++ b/src/samples/schemas/sample.schema.ts @@ -1,5 +1,5 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; -import { ApiProperty } from "@nestjs/swagger"; +import { ApiHideProperty } from "@nestjs/swagger"; import { Document } from "mongoose"; import { Attachment } from "src/attachments/schemas/attachment.schema"; import { OwnableClass } from "src/common/schemas/ownable.schema"; @@ -16,68 +16,46 @@ export type SampleDocument = SampleClass & Document; timestamps: true, }) export class SampleClass extends OwnableClass { + @ApiHideProperty() @Prop({ type: String }) _id: string; - @ApiProperty({ - type: String, - default: () => uuidv4(), - required: true, - description: - "Globally unique identifier of a sample. This could be provided as an input value or generated by the system.", - }) + /** + * Globally unique identifier of a sample. This could be provided as an input value or generated by the system. + */ @Prop({ type: String, unique: true, required: true, default: () => uuidv4() }) sampleId: string; - @ApiProperty({ - type: String, - required: false, - description: "The owner of the sample.", - }) + /** + * The owner of the sample. + */ @Prop({ type: String, required: false }) owner?: string; - @ApiProperty({ - type: String, - required: false, - description: "A description of the sample.", - }) + /** + * A description of the sample. + */ @Prop({ type: String, required: false }) description?: string; - @ApiProperty({ - type: Object, - default: {}, - required: false, - description: "JSON object containing the sample characteristics metadata.", - }) + /** + * JSON object containing the sample characteristics metadata. + */ @Prop({ type: Object, required: false, default: {} }) - sampleCharacteristics?: Record; + sampleCharacteristics?: Record = {}; } export class SampleWithAttachmentsAndDatasets extends SampleClass { - /* - @ApiProperty({ type: "array", items: { $ref: getSchemaPath(Attachment) } }) - @Prop([AttachmentSchema])*/ + /** + * Attachments that are related to this sample. + */ // this property should not be present in the database model - @ApiProperty({ - type: Attachment, - isArray: true, - required: false, - description: "Attachments that are related to this sample.", - }) attachments?: Attachment[]; - /* - @ApiProperty({ type: "array", items: { $ref: getSchemaPath(Dataset) } }) - @Prop([DatasetSchema])*/ + /** + * Datasets that are related to this sample. + */ // this property should not be present in the database model - @ApiProperty({ - type: DatasetClass, - isArray: true, - required: false, - description: "Datasets that are related to this sample.", - }) datasets?: DatasetClass[]; } From 7ae5ff2e995d87b2fef6ddaf32173b86258f8291 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Pedersen?= Date: Wed, 22 Jan 2025 12:51:12 +0100 Subject: [PATCH 02/11] Update dependabot.yml Add a group for nestjs packages to see if then a major update can be done automatically --- .github/dependabot.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a8773005d..8bff87d3c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -25,3 +25,11 @@ updates: labels: - "dependencies" - "npm" + groups: + nestjs: + patterns: + - "@nestjs*" + update-types: + - "minor" + - "patch" + - "major" From a5df0ac9456fe22ff180824dc96b87e5f0155925 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Jan 2025 12:30:35 +0000 Subject: [PATCH 03/11] build(deps): bump uuid from 11.0.4 to 11.0.5 Bumps [uuid](https://github.com/uuidjs/uuid) from 11.0.4 to 11.0.5. - [Release notes](https://github.com/uuidjs/uuid/releases) - [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md) - [Commits](https://github.com/uuidjs/uuid/compare/v11.0.4...v11.0.5) --- updated-dependencies: - dependency-name: uuid dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package-lock.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 297ea9c7e..1fec3ac32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14437,13 +14437,14 @@ } }, "node_modules/uuid": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.4.tgz", - "integrity": "sha512-IzL6VtTTYcAhA/oghbFJ1Dkmqev+FpQWnCBaKq/gUluLxliWvO8DPFWfIviRmYbtaavtSQe4WBL++rFjdcGWEg==", + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", + "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], + "license": "MIT", "bin": { "uuid": "dist/esm/bin/uuid" } From 7d2330b03d23e15ac279416778472b3872d37b13 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Jan 2025 12:41:35 +0000 Subject: [PATCH 04/11] build(deps-dev): bump @faker-js/faker from 9.3.0 to 9.4.0 Bumps [@faker-js/faker](https://github.com/faker-js/faker) from 9.3.0 to 9.4.0. - [Release notes](https://github.com/faker-js/faker/releases) - [Changelog](https://github.com/faker-js/faker/blob/next/CHANGELOG.md) - [Commits](https://github.com/faker-js/faker/compare/v9.3.0...v9.4.0) --- updated-dependencies: - dependency-name: "@faker-js/faker" dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1fec3ac32..3f50d3f1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1413,9 +1413,9 @@ } }, "node_modules/@faker-js/faker": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.3.0.tgz", - "integrity": "sha512-r0tJ3ZOkMd9xsu3VRfqlFR6cz0V/jFYRswAIpC+m/DIfAUXq7g8N7wTAlhSANySXYGKzGryfDXwtwsY8TxEIDw==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.4.0.tgz", + "integrity": "sha512-85+k0AxaZSTowL0gXp8zYWDIrWclTbRPg/pm/V0dSFZ6W6D4lhcG3uuZl4zLsEKfEvs69xDbLN2cHQudwp95JA==", "dev": true, "funding": [ { @@ -1423,6 +1423,7 @@ "url": "https://opencollective.com/fakerjs" } ], + "license": "MIT", "engines": { "node": ">=18.0.0", "npm": ">=9.0.0" From f695f3a9e31e5a32ac98e787b5558b8379d3e9fb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Jan 2025 12:52:54 +0000 Subject: [PATCH 05/11] build(deps-dev): bump eslint-plugin-prettier from 5.2.1 to 5.2.3 Bumps [eslint-plugin-prettier](https://github.com/prettier/eslint-plugin-prettier) from 5.2.1 to 5.2.3. - [Release notes](https://github.com/prettier/eslint-plugin-prettier/releases) - [Changelog](https://github.com/prettier/eslint-plugin-prettier/blob/master/CHANGELOG.md) - [Commits](https://github.com/prettier/eslint-plugin-prettier/compare/v5.2.1...v5.2.3) --- updated-dependencies: - dependency-name: eslint-plugin-prettier dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package-lock.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3f50d3f1c..e98bc000d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6945,10 +6945,11 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.1.tgz", - "integrity": "sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.3.tgz", + "integrity": "sha512-qJ+y0FfCp/mQYQ/vWQ3s7eUlFEL4PyKfAJxsnYTJ4YT73nsJBWqmEpFryxV9OeUiqmsTsYJ5Y+KDNaeP31wrRw==", "dev": true, + "license": "MIT", "dependencies": { "prettier-linter-helpers": "^1.0.0", "synckit": "^0.9.1" From 8eda7edfc8b97504602dbe12d939a758a074ce34 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Jan 2025 13:04:09 +0000 Subject: [PATCH 06/11] build(deps-dev): bump @types/node from 22.10.5 to 22.10.7 Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.10.5 to 22.10.7. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package-lock.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index e98bc000d..244ba3f56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3820,9 +3820,10 @@ "dev": true }, "node_modules/@types/node": { - "version": "22.10.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz", - "integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==", + "version": "22.10.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz", + "integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==", + "license": "MIT", "dependencies": { "undici-types": "~6.20.0" } From e9d67981c839f1a7d402e6b6a224d47b58dff409 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Jan 2025 13:15:17 +0000 Subject: [PATCH 07/11] build(deps): bump undici from 6.21.0 to 6.21.1 Bumps [undici](https://github.com/nodejs/undici) from 6.21.0 to 6.21.1. - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v6.21.0...v6.21.1) --- updated-dependencies: - dependency-name: undici dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 244ba3f56..1fa520b18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14344,9 +14344,10 @@ } }, "node_modules/undici": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.0.tgz", - "integrity": "sha512-BUgJXc752Kou3oOIuU1i+yZZypyZRqNPW0vqoMPl8VaoalSfeR0D8/t4iAS3yirs79SSMTxTag+ZC86uswv+Cw==", + "version": "6.21.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz", + "integrity": "sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==", + "license": "MIT", "engines": { "node": ">=18.17" } From a3e285d447db5454dac8af7abe80a60d0404ba7f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Jan 2025 13:26:37 +0000 Subject: [PATCH 08/11] build(deps-dev): bump @typescript-eslint/eslint-plugin Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 8.20.0 to 8.21.0. - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.21.0/packages/eslint-plugin) --- updated-dependencies: - dependency-name: "@typescript-eslint/eslint-plugin" dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 313 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 295 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1fa520b18..a920cc20d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4013,17 +4013,17 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.20.0.tgz", - "integrity": "sha512-naduuphVw5StFfqp4Gq4WhIBE2gN1GEmMUExpJYknZJdRnc+2gDzB8Z3+5+/Kv33hPQRDGzQO/0opHE72lZZ6A==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.21.0.tgz", + "integrity": "sha512-eTH+UOR4I7WbdQnG4Z48ebIA6Bgi7WO8HvFEneeYBxG8qCOYgTOFPSg6ek9ITIDvGjDQzWHcoWHCDO2biByNzA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/type-utils": "8.20.0", - "@typescript-eslint/utils": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/scope-manager": "8.21.0", + "@typescript-eslint/type-utils": "8.21.0", + "@typescript-eslint/utils": "8.21.0", + "@typescript-eslint/visitor-keys": "8.21.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -4042,6 +4042,69 @@ "typescript": ">=4.8.4 <5.8.0" } }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.21.0.tgz", + "integrity": "sha512-G3IBKz0/0IPfdeGRMbp+4rbjfSSdnGkXsM/pFZA8zM9t9klXDnB/YnKOBQ0GoPmoROa4bCq2NeHgJa5ydsQ4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.21.0", + "@typescript-eslint/visitor-keys": "8.21.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.21.0.tgz", + "integrity": "sha512-PAL6LUuQwotLW2a8VsySDBwYMm129vFm4tMVlylzdoTybTHaAi0oBp7Ac6LhSrHHOdLM3efH+nAR6hAWoMF89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.21.0.tgz", + "integrity": "sha512-BkLMNpdV6prozk8LlyK/SOoWLmUFi+ZD+pcqti9ILCbVvHGk1ui1g4jJOc2WDLaeExz2qWwojxlPce5PljcT3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.21.0", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@typescript-eslint/parser": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.20.0.tgz", @@ -4086,14 +4149,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.20.0.tgz", - "integrity": "sha512-bPC+j71GGvA7rVNAHAtOjbVXbLN5PkwqMvy1cwGeaxUoRQXVuKCebRoLzm+IPW/NtFFpstn1ummSIasD5t60GA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.21.0.tgz", + "integrity": "sha512-95OsL6J2BtzoBxHicoXHxgk3z+9P3BEcQTpBKriqiYzLKnM2DeSqs+sndMKdamU8FosiadQFT3D+BSL9EKnAJQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.20.0", - "@typescript-eslint/utils": "8.20.0", + "@typescript-eslint/typescript-estree": "8.21.0", + "@typescript-eslint/utils": "8.21.0", "debug": "^4.3.4", "ts-api-utils": "^2.0.0" }, @@ -4109,6 +4172,104 @@ "typescript": ">=4.8.4 <5.8.0" } }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.21.0.tgz", + "integrity": "sha512-PAL6LUuQwotLW2a8VsySDBwYMm129vFm4tMVlylzdoTybTHaAi0oBp7Ac6LhSrHHOdLM3efH+nAR6hAWoMF89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.21.0.tgz", + "integrity": "sha512-x+aeKh/AjAArSauz0GiQZsjT8ciadNMHdkUSwBB9Z6PrKc/4knM4g3UfHml6oDJmKC88a6//cdxnO/+P2LkMcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.21.0", + "@typescript-eslint/visitor-keys": "8.21.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.21.0.tgz", + "integrity": "sha512-BkLMNpdV6prozk8LlyK/SOoWLmUFi+ZD+pcqti9ILCbVvHGk1ui1g4jJOc2WDLaeExz2qWwojxlPce5PljcT3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.21.0", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@typescript-eslint/types": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.20.0.tgz", @@ -4177,16 +4338,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.20.0.tgz", - "integrity": "sha512-dq70RUw6UK9ei7vxc4KQtBRk7qkHZv447OUZ6RPQMQl71I3NZxQJX/f32Smr+iqWrB02pHKn2yAdHBb0KNrRMA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.21.0.tgz", + "integrity": "sha512-xcXBfcq0Kaxgj7dwejMbFyq7IOHgpNMtVuDveK7w3ZGwG9owKzhALVwKpTF2yrZmEwl9SWdetf3fxNzJQaVuxw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/typescript-estree": "8.20.0" + "@typescript-eslint/scope-manager": "8.21.0", + "@typescript-eslint/types": "8.21.0", + "@typescript-eslint/typescript-estree": "8.21.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4200,6 +4361,122 @@ "typescript": ">=4.8.4 <5.8.0" } }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.21.0.tgz", + "integrity": "sha512-G3IBKz0/0IPfdeGRMbp+4rbjfSSdnGkXsM/pFZA8zM9t9klXDnB/YnKOBQ0GoPmoROa4bCq2NeHgJa5ydsQ4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.21.0", + "@typescript-eslint/visitor-keys": "8.21.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.21.0.tgz", + "integrity": "sha512-PAL6LUuQwotLW2a8VsySDBwYMm129vFm4tMVlylzdoTybTHaAi0oBp7Ac6LhSrHHOdLM3efH+nAR6hAWoMF89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.21.0.tgz", + "integrity": "sha512-x+aeKh/AjAArSauz0GiQZsjT8ciadNMHdkUSwBB9Z6PrKc/4knM4g3UfHml6oDJmKC88a6//cdxnO/+P2LkMcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.21.0", + "@typescript-eslint/visitor-keys": "8.21.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.21.0.tgz", + "integrity": "sha512-BkLMNpdV6prozk8LlyK/SOoWLmUFi+ZD+pcqti9ILCbVvHGk1ui1g4jJOc2WDLaeExz2qWwojxlPce5PljcT3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.21.0", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@typescript-eslint/visitor-keys": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.20.0.tgz", From c57f0d08f9d0488384f46f9ab91ead15ade8bf92 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Jan 2025 13:37:57 +0000 Subject: [PATCH 09/11] build(deps-dev): bump @typescript-eslint/parser from 8.20.0 to 8.21.0 Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 8.20.0 to 8.21.0. - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.21.0/packages/parser) --- updated-dependencies: - dependency-name: "@typescript-eslint/parser" dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 305 +++------------------------------------------- 1 file changed, 14 insertions(+), 291 deletions(-) diff --git a/package-lock.json b/package-lock.json index a920cc20d..d4d75c38c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4042,80 +4042,17 @@ "typescript": ">=4.8.4 <5.8.0" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.21.0.tgz", - "integrity": "sha512-G3IBKz0/0IPfdeGRMbp+4rbjfSSdnGkXsM/pFZA8zM9t9klXDnB/YnKOBQ0GoPmoROa4bCq2NeHgJa5ydsQ4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.21.0", - "@typescript-eslint/visitor-keys": "8.21.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.21.0.tgz", - "integrity": "sha512-PAL6LUuQwotLW2a8VsySDBwYMm129vFm4tMVlylzdoTybTHaAi0oBp7Ac6LhSrHHOdLM3efH+nAR6hAWoMF89A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { + "node_modules/@typescript-eslint/parser": { "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.21.0.tgz", - "integrity": "sha512-BkLMNpdV6prozk8LlyK/SOoWLmUFi+ZD+pcqti9ILCbVvHGk1ui1g4jJOc2WDLaeExz2qWwojxlPce5PljcT3w==", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.21.0.tgz", + "integrity": "sha512-Wy+/sdEH9kI3w9civgACwabHbKl+qIOu0uFZ9IMKzX3Jpv9og0ZBJrZExGrPpFAY7rWsXuxs5e7CPPP17A4eYA==", "dev": true, "license": "MIT", "dependencies": { + "@typescript-eslint/scope-manager": "8.21.0", "@typescript-eslint/types": "8.21.0", - "eslint-visitor-keys": "^4.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.20.0.tgz", - "integrity": "sha512-gKXG7A5HMyjDIedBi6bUrDcun8GIjnI8qOwVLiY3rx6T/sHP/19XLJOnIq/FgQvWLHja5JN/LSE7eklNBr612g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/typescript-estree": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/typescript-estree": "8.21.0", + "@typescript-eslint/visitor-keys": "8.21.0", "debug": "^4.3.4" }, "engines": { @@ -4131,14 +4068,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.20.0.tgz", - "integrity": "sha512-J7+VkpeGzhOt3FeG1+SzhiMj9NzGD/M6KoGn9f4dbz3YzK9hvbhVTmLj/HiTp9DazIzJ8B4XcM80LrR9Dm1rJw==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.21.0.tgz", + "integrity": "sha512-G3IBKz0/0IPfdeGRMbp+4rbjfSSdnGkXsM/pFZA8zM9t9klXDnB/YnKOBQ0GoPmoROa4bCq2NeHgJa5ydsQ4mA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0" + "@typescript-eslint/types": "8.21.0", + "@typescript-eslint/visitor-keys": "8.21.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4172,7 +4109,7 @@ "typescript": ">=4.8.4 <5.8.0" } }, - "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { + "node_modules/@typescript-eslint/types": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.21.0.tgz", "integrity": "sha512-PAL6LUuQwotLW2a8VsySDBwYMm129vFm4tMVlylzdoTybTHaAi0oBp7Ac6LhSrHHOdLM3efH+nAR6hAWoMF89A==", @@ -4186,7 +4123,7 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { + "node_modules/@typescript-eslint/typescript-estree": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.21.0.tgz", "integrity": "sha512-x+aeKh/AjAArSauz0GiQZsjT8ciadNMHdkUSwBB9Z6PrKc/4knM4g3UfHml6oDJmKC88a6//cdxnO/+P2LkMcg==", @@ -4213,104 +4150,6 @@ "typescript": ">=4.8.4 <5.8.0" } }, - "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.21.0.tgz", - "integrity": "sha512-BkLMNpdV6prozk8LlyK/SOoWLmUFi+ZD+pcqti9ILCbVvHGk1ui1g4jJOc2WDLaeExz2qWwojxlPce5PljcT3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.21.0", - "eslint-visitor-keys": "^4.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@typescript-eslint/type-utils/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.20.0.tgz", - "integrity": "sha512-cqaMiY72CkP+2xZRrFt3ExRBu0WmVitN/rYPZErA80mHjHx/Svgp8yfbzkJmDoQ/whcytOPO9/IZXnOc+wigRA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.20.0.tgz", - "integrity": "sha512-Y7ncuy78bJqHI35NwzWol8E0X7XkRVS4K4P4TCyzWkOJih5NDvtoRDW4Ba9YJJoB2igm9yXDdYI/+fkiiAxPzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <5.8.0" - } - }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -4361,66 +4200,7 @@ "typescript": ">=4.8.4 <5.8.0" } }, - "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.21.0.tgz", - "integrity": "sha512-G3IBKz0/0IPfdeGRMbp+4rbjfSSdnGkXsM/pFZA8zM9t9klXDnB/YnKOBQ0GoPmoROa4bCq2NeHgJa5ydsQ4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.21.0", - "@typescript-eslint/visitor-keys": "8.21.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.21.0.tgz", - "integrity": "sha512-PAL6LUuQwotLW2a8VsySDBwYMm129vFm4tMVlylzdoTybTHaAi0oBp7Ac6LhSrHHOdLM3efH+nAR6hAWoMF89A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.21.0.tgz", - "integrity": "sha512-x+aeKh/AjAArSauz0GiQZsjT8ciadNMHdkUSwBB9Z6PrKc/4knM4g3UfHml6oDJmKC88a6//cdxnO/+P2LkMcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.21.0", - "@typescript-eslint/visitor-keys": "8.21.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <5.8.0" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { + "node_modules/@typescript-eslint/visitor-keys": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.21.0.tgz", "integrity": "sha512-BkLMNpdV6prozk8LlyK/SOoWLmUFi+ZD+pcqti9ILCbVvHGk1ui1g4jJOc2WDLaeExz2qWwojxlPce5PljcT3w==", @@ -4438,63 +4218,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/utils/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.20.0.tgz", - "integrity": "sha512-v/BpkeeYAsPkKCkR8BDwcno0llhzWVqPOamQrAEMdpZav2Y9OVjd9dwJyBLJWwf335B5DmlifECIkZRJCaGaHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.20.0", - "eslint-visitor-keys": "^4.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", From adaacd0571799535734b517d817d5bdd35372adb Mon Sep 17 00:00:00 2001 From: Emil Gunnarsson Date: Wed, 22 Jan 2025 22:42:13 +0100 Subject: [PATCH 10/11] refactor(proposal): use openapi plugin (#1629) * use openapi plugin for proposal module --- nest-cli.json | 7 +- .../dto/create-measurement-period.dto.ts | 34 ++--- src/proposals/dto/create-proposal.dto.ts | 10 +- src/proposals/dto/update-proposal.dto.ts | 120 ++++++--------- .../schemas/measurement-period.schema.ts | 34 ++--- src/proposals/schemas/proposal.schema.ts | 139 +++++++----------- 6 files changed, 127 insertions(+), 217 deletions(-) diff --git a/nest-cli.json b/nest-cli.json index 9ae1faa63..0357673e1 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -5,7 +5,12 @@ "plugins": [{ "name": "@nestjs/swagger", "options": { - "dtoFileNameSuffix": [".dto.ts", "sample.schema.ts"], + "dtoFileNameSuffix": [ + ".dto.ts", + "sample.schema.ts", + "proposal.schema.ts", + "measurement-period.schema.ts" + ], "introspectComments": true } }] diff --git a/src/proposals/dto/create-measurement-period.dto.ts b/src/proposals/dto/create-measurement-period.dto.ts index 03a9a75d2..3ed4b417b 100644 --- a/src/proposals/dto/create-measurement-period.dto.ts +++ b/src/proposals/dto/create-measurement-period.dto.ts @@ -1,37 +1,27 @@ -import { ApiProperty } from "@nestjs/swagger"; import { IsDateString, IsOptional, IsString } from "class-validator"; export class CreateMeasurementPeriodDto { - @ApiProperty({ - type: String, - required: true, - description: - "Instrument or beamline identifier where measurement was pursued, e.g. /PSI/SLS/TOMCAT", - }) + /** + * Instrument or beamline identifier where measurement was pursued, e.g. /PSI/SLS/TOMCAT + */ @IsString() readonly instrument: string; - @ApiProperty({ - type: Date, - description: - "Time when measurement period started, format according to chapter 5.6 internet date/time format in RFC 3339. Local times without timezone/offset info are automatically transformed to UTC using the timezone of the API server.", - }) + /** + * Time when measurement period started, format according to chapter 5.6 internet date/time format in RFC 3339. Local times without timezone/offset info are automatically transformed to UTC using the timezone of the API server. + */ @IsDateString() readonly start: Date; - @ApiProperty({ - type: Date, - description: - "Time when measurement period ended, format according to chapter 5.6 internet date/time format in RFC 3339. Local times without timezone/offset info are automatically transformed to UTC using the timezone of the API server.", - }) + /** + * Time when measurement period ended, format according to chapter 5.6 internet date/time format in RFC 3339. Local times without timezone/offset info are automatically transformed to UTC using the timezone of the API server. + */ @IsDateString() readonly end: Date; - @ApiProperty({ - type: String, - description: - "Additional information relevant for this measurement period, e.g. if different accounts were used for data taking.", - }) + /** + * Additional information relevant for this measurement period, e.g. if different accounts were used for data taking. + */ @IsOptional() @IsString() readonly comment?: string; diff --git a/src/proposals/dto/create-proposal.dto.ts b/src/proposals/dto/create-proposal.dto.ts index b0ab075d5..7d2c1a99f 100644 --- a/src/proposals/dto/create-proposal.dto.ts +++ b/src/proposals/dto/create-proposal.dto.ts @@ -1,14 +1,10 @@ -import { ApiProperty } from "@nestjs/swagger"; import { IsString } from "class-validator"; import { UpdateProposalDto } from "./update-proposal.dto"; export class CreateProposalDto extends UpdateProposalDto { - @ApiProperty({ - type: String, - required: true, - description: - "Globally unique identifier of a proposal, eg. PID-prefix/internal-proposal-number. PID prefix is auto prepended.", - }) + /** + * Globally unique identifier of a proposal, eg. PID-prefix/internal-proposal-number. PID prefix is auto prepended. + */ @IsString() readonly proposalId: string; } diff --git a/src/proposals/dto/update-proposal.dto.ts b/src/proposals/dto/update-proposal.dto.ts index 2e5fc2e06..f936737a7 100644 --- a/src/proposals/dto/update-proposal.dto.ts +++ b/src/proposals/dto/update-proposal.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty, ApiTags, PartialType } from "@nestjs/swagger"; +import { ApiTags, PartialType } from "@nestjs/swagger"; import { Type } from "class-transformer"; import { IsArray, @@ -14,132 +14,100 @@ import { CreateMeasurementPeriodDto } from "./create-measurement-period.dto"; @ApiTags("proposals") export class UpdateProposalDto extends OwnableDto { - @ApiProperty({ - type: String, - required: false, - description: "Email of principal investigator.", - }) + /** + * Email of principal investigator. + */ @IsOptional() @IsEmail() readonly pi_email?: string; - @ApiProperty({ - type: String, - required: false, - description: "First name of principal investigator.", - }) + /** + * First name of principal investigator. + */ @IsOptional() @IsString() readonly pi_firstname?: string; - @ApiProperty({ - type: String, - required: false, - description: "Last name of principal investigator.", - }) + /** + * Last name of principal investigator. + */ @IsOptional() @IsString() readonly pi_lastname?: string; - @ApiProperty({ - type: String, - required: true, - description: "Email of main proposer.", - }) + /** + * Email of main proposer. + */ @IsEmail() readonly email: string; - @ApiProperty({ - type: String, - required: false, - description: "First name of main proposer.", - }) + /** + * First name of main proposer. + */ @IsOptional() @IsString() readonly firstname?: string; - @ApiProperty({ - type: String, - required: false, - description: "Last name of main proposer.", - }) + /** + * Last name of main proposer. + */ @IsOptional() @IsString() readonly lastname?: string; - @ApiProperty({ - type: String, - required: true, - description: "The title of the proposal.", - }) + /** + * The title of the proposal. + */ @IsString() readonly title: string; - @ApiProperty({ - type: String, - required: false, - description: "The proposal abstract.", - }) + /** + * The proposal abstract. + */ @IsOptional() @IsString() readonly abstract?: string; - @ApiProperty({ - type: Date, - required: false, - description: "The date when the data collection starts.", - }) + /** + * The date when the data collection starts. + */ @IsOptional() @IsDateString() readonly startTime?: Date; - @ApiProperty({ - type: Date, - required: false, - description: "The date when data collection finishes.", - }) + /** + * The date when data collection finishes. + */ @IsOptional() @IsDateString() readonly endTime?: Date; - @ApiProperty({ - type: CreateMeasurementPeriodDto, - isArray: true, - required: false, - description: - "Embedded information used inside proposals to define which type of experiment has to be pursued, where (at which instrument) and when.", - }) + /** + * Embedded information used inside proposals to define which type of experiment has to be pursued, where (at which instrument) and when. + */ @IsArray() @IsOptional() @ValidateNested({ each: true }) @Type(() => CreateMeasurementPeriodDto) readonly MeasurementPeriodList?: CreateMeasurementPeriodDto[]; - @ApiProperty({ - type: Object, - required: false, - default: {}, - description: "JSON object containing the proposal metadata.", - }) + /** + * JSON object containing the proposal metadata. + */ @IsOptional() @IsObject() - readonly metadata?: Record; + readonly metadata?: Record = {}; - @ApiProperty({ - type: String, - required: false, - description: "Parent proposal id.", - }) + /** + * Parent proposal id. + */ @IsOptional() @IsString() readonly parentProposalId?: string; - @ApiProperty({ - type: String, - required: false, - description: - "Characterize type of proposal, use some of the configured values", - }) + /** + * Characterize type of proposal, use some of the configured values + */ @IsOptional() @IsString() readonly type?: string; diff --git a/src/proposals/schemas/measurement-period.schema.ts b/src/proposals/schemas/measurement-period.schema.ts index 3ccd6df18..a39fcf859 100644 --- a/src/proposals/schemas/measurement-period.schema.ts +++ b/src/proposals/schemas/measurement-period.schema.ts @@ -1,5 +1,4 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; -import { ApiProperty } from "@nestjs/swagger"; import { Document } from "mongoose"; import { QueryableClass } from "src/common/schemas/queryable.schema"; @@ -7,36 +6,27 @@ export type MeasurementPeriodDocument = MeasurementPeriodClass & Document; @Schema() export class MeasurementPeriodClass extends QueryableClass { - @ApiProperty({ - type: String, - required: true, - description: - "Instrument or beamline identifier where measurement was pursued, e.g. /PSI/SLS/TOMCAT", - }) + /** + * Instrument or beamline identifier where measurement was pursued, e.g. /PSI/SLS/TOMCAT + */ @Prop({ type: String, required: true, index: true }) instrument: string; - @ApiProperty({ - type: Date, - description: - "Time when measurement period started, format according to chapter 5.6 internet date/time format in RFC 3339. Local times without timezone/offset info are automatically transformed to UTC using the timezone of the API server.", - }) + /** + * Time when measurement period started, format according to chapter 5.6 internet date/time format in RFC 3339. Local times without timezone/offset info are automatically transformed to UTC using the timezone of the API server. + */ @Prop({ type: Date, index: true }) start: Date; - @ApiProperty({ - type: Date, - description: - "Time when measurement period ended, format according to chapter 5.6 internet date/time format in RFC 3339. Local times without timezone/offset info are automatically transformed to UTC using the timezone of the API server.", - }) + /** + * Time when measurement period ended, format according to chapter 5.6 internet date/time format in RFC 3339. Local times without timezone/offset info are automatically transformed to UTC using the timezone of the API server. + */ @Prop({ type: Date, index: true }) end: Date; - @ApiProperty({ - type: String, - description: - "Additional information relevant for this measurement period, e.g. if different accounts were used for data taking.", - }) + /** + * Additional information relevant for this measurement period, e.g. if different accounts were used for data taking. + */ @Prop({ type: String }) comment: string; } diff --git a/src/proposals/schemas/proposal.schema.ts b/src/proposals/schemas/proposal.schema.ts index a51110203..323012d5c 100644 --- a/src/proposals/schemas/proposal.schema.ts +++ b/src/proposals/schemas/proposal.schema.ts @@ -1,5 +1,5 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; -import { ApiProperty } from "@nestjs/swagger"; +import { ApiHideProperty } from "@nestjs/swagger"; import { Document, Schema as MongooseSchema } from "mongoose"; import { OwnableClass } from "src/common/schemas/ownable.schema"; @@ -21,12 +21,9 @@ export type ProposalDocument = ProposalClass & Document; minimize: false, }) export class ProposalClass extends OwnableClass { - @ApiProperty({ - type: String, - required: true, - description: - "Globally unique identifier of a proposal, eg. PID-prefix/internal-proposal-number. PID prefix is auto prepended.", - }) + /** + * Globally unique identifier of a proposal, eg. PID-prefix/internal-proposal-number. PID prefix is auto prepended. + */ @Prop({ type: String, unique: true, @@ -34,16 +31,15 @@ export class ProposalClass extends OwnableClass { }) proposalId: string; + @ApiHideProperty() @Prop({ type: String, }) _id: string; - @ApiProperty({ - type: String, - required: false, - description: "Email of principal investigator.", - }) + /** + * Email of principal investigator. + */ @Prop({ type: String, required: false, @@ -51,155 +47,120 @@ export class ProposalClass extends OwnableClass { }) pi_email?: string; - @ApiProperty({ - type: String, - required: false, - description: "First name of principal investigator.", - }) + /** + * First name of principal investigator. + */ @Prop({ type: String, required: false, }) pi_firstname?: string; - @ApiProperty({ - type: String, - required: false, - description: "Last name of principal investigator.", - }) + /** + * Last name of principal investigator. + */ @Prop({ type: String, required: false, }) pi_lastname?: string; - @ApiProperty({ - type: String, - required: true, - description: "Email of main proposer.", - }) + /** + * Email of main proposer. + */ @Prop({ type: String, required: true, }) email: string; - @ApiProperty({ - type: String, - required: false, - description: "First name of main proposer.", - }) + /** + * First name of main proposer. + */ @Prop({ type: String, required: false, }) firstname?: string; - @ApiProperty({ - type: String, - required: false, - description: "Last name of main proposer.", - }) + /** + * Last name of main proposer. + */ @Prop({ type: String, required: false, }) lastname?: string; - @ApiProperty({ - type: String, - required: true, - description: "The title of the proposal.", - }) + /** + * The title of the proposal. + */ @Prop({ type: String, required: true, }) title: string; - @ApiProperty({ - type: String, - required: false, - description: "The proposal abstract.", - }) + /** + * The proposal abstract. + */ @Prop({ type: String, required: false, }) abstract?: string; - @ApiProperty({ - type: Date, - required: false, - description: "The date when the data collection starts.", - }) + /** + * The date when the data collection starts. + */ @Prop({ type: Date, required: false, }) startTime?: Date; - @ApiProperty({ - type: Date, - required: false, - description: "The date when data collection finishes.", - }) + /** + * The date when data collection finishes. + */ @Prop({ type: Date, required: false, }) endTime?: Date; - @ApiProperty({ - type: MeasurementPeriodClass, - isArray: true, - required: false, - description: - "Embedded information used inside proposals to define which type of experiment has to be pursued, where (at which instrument) and when.", - }) + /** + * Embedded information used inside proposals to define which type of experiment has to be pursued, where (at which instrument) and when. + */ @Prop({ type: [MeasurementPeriodSchema], required: false, }) MeasurementPeriodList?: MeasurementPeriodClass[]; - @ApiProperty({ - type: MongooseSchema.Types.Mixed, - required: false, - default: {}, - description: "JSON object containing the proposal metadata.", - }) + /** + * JSON object containing the proposal metadata. + */ @Prop({ type: MongooseSchema.Types.Mixed, required: false, default: {} }) - metadata?: Record; + metadata?: Record = {}; - @ApiProperty({ - type: String, - required: false, - description: "Parent proposal id", - default: null, - nullable: true, - }) + /** + * Parent proposal id + */ @Prop({ type: String, - required: false, default: null, ref: "Proposal", }) - parentProposalId: string; + parentProposalId: string | null = null; - @ApiProperty({ - type: String, - required: true, - default: DEFAULT_PROPOSAL_TYPE, - description: - "Characterize type of proposal, use some of the configured values", - }) + /** + * Characterize type of proposal, use some of the configured values + */ @Prop({ type: String, - required: true, default: DEFAULT_PROPOSAL_TYPE, }) - type: string; + type: string = DEFAULT_PROPOSAL_TYPE; } export const ProposalSchema = SchemaFactory.createForClass(ProposalClass); From b64bafb9e156901491a155dbf4e5a6a2c100a13b Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 23 Jan 2025 11:23:41 +0100 Subject: [PATCH 11/11] feat: add new dataset v4 controller (#1541) * start with adding the v4 datasets controller * start the main cleanup on the dataset v4 controller * cleanup and fix some linting errors * use the versioning of datasets controller properly and finalize the initial v4 controller * do some more cleanup in the new controller * use improved regex to extract the version from the URI * refactor how we use config module and make accessGroups easily accessible * fix failing unit tests * improve dataset dto types for sdk generation * add aggregation for findById and findOne on the datasets.v4 controller * add some notes and todos on whats left * do some more cleanup and improve include filters for aggregation * more cleanup and filter refactor and validation * try to revert some package upgrades * try to revert some more package changes * test the new content property * content property final improvements * use content variable instead of funciton * cleanup * finalize all filters for find dataset endpoints * fix most of the PR review comments * fix v3 dataset controller access filters * some small improvements and leads how to solve the nested relational field access based filters * try to revert some chages that are making tests fail * fix api tests as well * fix: update GitHub Actions workflow to conditionally upload SDK artifacts and add cleanup step for non-push events (#1562) * add granular access checks for relational fields * add new environment variable variable description in github actions * feat: add public endpoints for better separation of concerns * add sort and skip in the find one complete * move the public dataset v4 endpoints in own controller * review feedback improvements * fix some review comments and add fullfacet to public endpoints as well * fix default sort value * feat: add api tests for the new controllers (#1580) ## Description This is PR that adds unit and api tests for the dataset v4 controllers. ## Motivation After adding the new controllers we need to test all this refactor and also the public endpoints and access to the data. ## Fixes * https://jira.ess.eu/browse/SWAP-4366 ## Changes: * adding tests for dataset v4 controllers ## Tests included - [x] Included for each change/fix? - [x] Passing? ## Documentation - [x] swagger documentation updated (required for API changes) - [ ] official documentation updated ### official documentation info * revert some changes in package-lock * fix final review comments and suggestions --------- Co-authored-by: Jay Co-authored-by: Max Novelli --- .github/workflows/release-and-publish-sdk.yml | 3 + .github/workflows/upload-sdk-artifact.yml | 3 + package-lock.json | 31 - package.json | 1 - src/admin/admin.module.ts | 2 - src/app.module.ts | 12 +- .../access-group-service-factory.ts | 2 - src/auth/auth.module.ts | 4 +- src/casl/casl-ability.factory.spec.ts | 3 +- src/casl/casl-ability.factory.ts | 351 ++++--- src/casl/casl.module.ts | 2 + src/common/dto/ownable.dto.ts | 3 +- src/common/pipes/filter.pipe.ts | 8 +- src/common/types.ts | 40 + src/common/utils.ts | 83 +- src/config/configuration.ts | 62 +- src/datasets/datasets-access.service.ts | 170 +++ .../datasets-public.v4.controller.spec.ts | 26 + src/datasets/datasets-public.v4.controller.ts | 280 +++++ src/datasets/datasets.controller.spec.ts | 2 + src/datasets/datasets.controller.ts | 149 ++- src/datasets/datasets.module.ts | 13 +- src/datasets/datasets.service.spec.ts | 4 + src/datasets/datasets.service.ts | 127 ++- src/datasets/datasets.v4.controller.spec.ts | 31 + src/datasets/datasets.v4.controller.ts | 849 +++++++++++++++ .../dto/create-dataset-obsolete.dto.ts | 2 +- .../create-derived-dataset-obsolete.dto.ts | 2 +- .../dto/create-raw-dataset-obsolete.dto.ts | 2 +- .../dto/output-dataset-obsolete.dto.ts | 2 +- src/datasets/dto/output-dataset.dto.ts | 9 + src/datasets/dto/update-dataset.dto.ts | 21 +- .../interfaces/dataset-filters.interface.ts | 14 +- src/datasets/pipes/filter-validation.pipe.ts | 75 ++ src/datasets/pipes/include-validation.pipe.ts | 34 + src/datasets/pipes/pid-validation.pipe.ts | 38 + src/datasets/types/dataset-filter-content.ts | 87 ++ src/datasets/types/dataset-lookup.ts | 66 ++ src/datasets/{ => types}/dataset-type.enum.ts | 0 src/elastic-search/elastic-search.module.ts | 10 +- src/health/health.module.ts | 3 +- .../instruments.controller.spec.ts | 2 + src/instruments/instruments.controller.ts | 40 +- src/jobs/jobs.controller.spec.ts | 2 + src/jobs/jobs.controller.ts | 5 +- src/jobs/jobs.module.ts | 2 - src/logbooks/logbooks.module.ts | 4 +- src/loggers/logger.module.ts | 2 - .../dto/update-origdatablock.dto.ts | 6 +- .../origdatablocks.controller.ts | 1 - src/policies/policies.controller.spec.ts | 2 + src/policies/policies.controller.ts | 4 +- src/policies/policies.module.ts | 2 - src/policies/policies.service.ts | 8 +- src/proposals/proposals.controller.ts | 10 +- src/proposals/proposals.module.ts | 3 +- src/published-data/published-data.module.ts | 4 +- src/samples/samples.controller.ts | 2 +- src/samples/samples.module.ts | 2 - src/users/user-identities.controller.ts | 3 +- src/users/users.module.ts | 4 +- src/users/users.service.spec.ts | 4 +- test/DatasetV4.js | 985 ++++++++++++++++++ test/DatasetV4Access.js | 488 +++++++++ test/DatasetV4Public.js | 567 ++++++++++ test/Instrument.js | 4 +- test/TestData.js | 168 ++- 67 files changed, 4514 insertions(+), 436 deletions(-) create mode 100644 src/common/types.ts create mode 100644 src/datasets/datasets-access.service.ts create mode 100644 src/datasets/datasets-public.v4.controller.spec.ts create mode 100644 src/datasets/datasets-public.v4.controller.ts create mode 100644 src/datasets/datasets.v4.controller.spec.ts create mode 100644 src/datasets/datasets.v4.controller.ts create mode 100644 src/datasets/pipes/filter-validation.pipe.ts create mode 100644 src/datasets/pipes/include-validation.pipe.ts create mode 100644 src/datasets/pipes/pid-validation.pipe.ts create mode 100644 src/datasets/types/dataset-filter-content.ts create mode 100644 src/datasets/types/dataset-lookup.ts rename src/datasets/{ => types}/dataset-type.enum.ts (100%) create mode 100644 test/DatasetV4.js create mode 100644 test/DatasetV4Access.js create mode 100644 test/DatasetV4Public.js diff --git a/.github/workflows/release-and-publish-sdk.yml b/.github/workflows/release-and-publish-sdk.yml index 38f1f5c67..30d66449d 100644 --- a/.github/workflows/release-and-publish-sdk.yml +++ b/.github/workflows/release-and-publish-sdk.yml @@ -112,6 +112,9 @@ jobs: env: MONGODB_URI: "mongodb://localhost:27017/scicat" JWT_SECRET: thisIsTheJwtSecret + # It disables the content property on some of the endpoints as the sdk generator complains about it. + # We want to keep it when the app is running to improve the swagger documentation and usage. + SDK_PACKAGE_SWAGGER_HELPERS_DISABLED: true run: | npm install -g wait-on && npm install npm run start & wait-on http://localhost:3000/api/v3/health --timeout 200000 diff --git a/.github/workflows/upload-sdk-artifact.yml b/.github/workflows/upload-sdk-artifact.yml index b580b88a1..df1616312 100644 --- a/.github/workflows/upload-sdk-artifact.yml +++ b/.github/workflows/upload-sdk-artifact.yml @@ -33,6 +33,9 @@ jobs: env: MONGODB_URI: "mongodb://localhost:27017/scicat" JWT_SECRET: thisIsTheJwtSecret + # It disables the content property on some of the endpoints as the sdk generator complains about it. + # We want to keep it when the app is running to improve the swagger documentation and usage. + SDK_PACKAGE_SWAGGER_HELPERS_DISABLED: true run: | npm install -g wait-on && npm install npm run start & wait-on http://localhost:3000/api/v3/health --timeout 200000 diff --git a/package-lock.json b/package-lock.json index d4d75c38c..2d85c4c29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -79,7 +79,6 @@ "chai-http": "^5.1.1", "concurrently": "^9.0.0", "eslint": "^9.0.0", - "eslint-config-loopback": "^13.1.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.0.0", "globals": "^15.12.0", @@ -6909,15 +6908,6 @@ } } }, - "node_modules/eslint-config-loopback": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/eslint-config-loopback/-/eslint-config-loopback-13.1.0.tgz", - "integrity": "sha512-Dg4IylCM5ysK9LsfzNZYLpnBjkgsBnjLMcprAMW8r7EMSody4GwOzeMixlkboNxeXZAG0z7aezh3fIJcOWFEVg==", - "dev": true, - "dependencies": { - "eslint-plugin-mocha": "^5.2.0" - } - }, "node_modules/eslint-config-prettier": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.0.1.tgz", @@ -6930,21 +6920,6 @@ "eslint": ">=7.0.0" } }, - "node_modules/eslint-plugin-mocha": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-mocha/-/eslint-plugin-mocha-5.3.0.tgz", - "integrity": "sha512-3uwlJVLijjEmBeNyH60nzqgA1gacUWLUmcKV8PIGNvj1kwP/CTgAWQHn2ayyJVwziX+KETkr9opNwT1qD/RZ5A==", - "dev": true, - "dependencies": { - "ramda": "^0.26.1" - }, - "engines": { - "node": ">=6.0.0" - }, - "peerDependencies": { - "eslint": ">= 4.0.0" - } - }, "node_modules/eslint-plugin-prettier": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.3.tgz", @@ -12490,12 +12465,6 @@ } ] }, - "node_modules/ramda": { - "version": "0.26.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.26.1.tgz", - "integrity": "sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ==", - "dev": true - }, "node_modules/random-bytes": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", diff --git a/package.json b/package.json index c5bf51e07..da0423a57 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,6 @@ "chai-http": "^5.1.1", "concurrently": "^9.0.0", "eslint": "^9.0.0", - "eslint-config-loopback": "^13.1.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.0.0", "globals": "^15.12.0", diff --git a/src/admin/admin.module.ts b/src/admin/admin.module.ts index abf43368b..87159e3ae 100644 --- a/src/admin/admin.module.ts +++ b/src/admin/admin.module.ts @@ -1,11 +1,9 @@ import { Module } from "@nestjs/common"; import { AdminService } from "./admin.service"; import { AdminController } from "./admin.controller"; -import { ConfigModule } from "@nestjs/config"; @Module({ controllers: [AdminController], - imports: [ConfigModule], providers: [AdminService], exports: [AdminService], }) diff --git a/src/app.module.ts b/src/app.module.ts index d76078361..585824733 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -36,13 +36,15 @@ import { MetricsModule } from "./metrics/metrics.module"; @Module({ imports: [ - AttachmentsModule, - AuthModule, - CaslModule, - CommonModule, ConfigModule.forRoot({ load: [configuration], + isGlobal: true, + cache: true, }), + AuthModule, + CaslModule, + AttachmentsModule, + CommonModule, // NOTE: `ConditionalModule.registerWhen` directly uses `process.env` as it does not support // dependency injection for `ConfigService`. This approach ensures compatibility while // leveraging environment variables for conditional module loading. @@ -59,7 +61,6 @@ import { MetricsModule } from "./metrics/metrics.module"; LogbooksModule, EventEmitterModule.forRoot(), MailerModule.forRootAsync({ - imports: [ConfigModule], useFactory: async (configService: ConfigService) => { const port = configService.get("smtp.port"); return { @@ -88,7 +89,6 @@ import { MetricsModule } from "./metrics/metrics.module"; inject: [ConfigService], }), MongooseModule.forRootAsync({ - imports: [ConfigModule], useFactory: async (configService: ConfigService) => ({ uri: configService.get("mongodbUri"), }), diff --git a/src/auth/access-group-provider/access-group-service-factory.ts b/src/auth/access-group-provider/access-group-service-factory.ts index 848470a2d..6c9f7c689 100644 --- a/src/auth/access-group-provider/access-group-service-factory.ts +++ b/src/auth/access-group-provider/access-group-service-factory.ts @@ -6,12 +6,10 @@ import { AccessGroupFromPayloadService } from "./access-group-from-payload.servi import { HttpService } from "@nestjs/axios"; import { AccessGroupFromMultipleProvidersService } from "./access-group-from-multiple-providers.service"; import { Logger } from "@nestjs/common"; -import { ConfigModule } from "@nestjs/config"; /* * this is the default function which provides an empty array as groups */ export const accessGroupServiceFactory = { - imports: [ConfigModule], provide: AccessGroupService, useFactory: (configService: ConfigService) => { Logger.debug("Service factory starting", "accessGroupServiceFactory"); diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 7f65ff246..3963da415 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -7,7 +7,7 @@ import { AuthController } from "./auth.controller"; import { JwtModule } from "@nestjs/jwt"; import { JwtStrategy } from "./strategies/jwt.strategy"; import { LdapStrategy } from "./strategies/ldap.strategy"; -import { ConfigModule, ConfigService } from "@nestjs/config"; +import { ConfigService } from "@nestjs/config"; import { UsersService } from "src/users/users.service"; import { OidcConfig } from "src/config/configuration"; import { BuildOpenIdClient, OidcStrategy } from "./strategies/oidc.strategy"; @@ -42,9 +42,7 @@ const OidcStrategyFactory = { @Module({ imports: [ - ConfigModule, JwtModule.registerAsync({ - imports: [ConfigModule], useFactory: async (configService: ConfigService) => ({ secret: configService.get("jwt.secret"), signOptions: { expiresIn: configService.get("jwt.expiresIn") }, diff --git a/src/casl/casl-ability.factory.spec.ts b/src/casl/casl-ability.factory.spec.ts index 80df5bb09..8663851c3 100644 --- a/src/casl/casl-ability.factory.spec.ts +++ b/src/casl/casl-ability.factory.spec.ts @@ -1,7 +1,8 @@ +import { ConfigService } from "@nestjs/config"; import { CaslAbilityFactory } from "./casl-ability.factory"; describe("CaslAbilityFactory", () => { it("should be defined", () => { - expect(new CaslAbilityFactory()).toBeDefined(); + expect(new CaslAbilityFactory(new ConfigService())).toBeDefined(); }); }); diff --git a/src/casl/casl-ability.factory.ts b/src/casl/casl-ability.factory.ts index 628beabf4..e6699a585 100644 --- a/src/casl/casl-ability.factory.ts +++ b/src/casl/casl-ability.factory.ts @@ -7,8 +7,10 @@ import { createMongoAbility, } from "@casl/ability"; import { Injectable, InternalServerErrorException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { Attachment } from "src/attachments/schemas/attachment.schema"; import { JWTUser } from "src/auth/interfaces/jwt-user.interface"; +import { AccessGroupsType } from "src/config/configuration"; // import { Role } from "src/auth/role.enum"; import { Datablock } from "src/datablocks/schemas/datablock.schema"; import { DatasetClass } from "src/datasets/schemas/dataset.schema"; @@ -25,7 +27,7 @@ import { UserIdentity } from "src/users/schemas/user-identity.schema"; import { UserSettings } from "src/users/schemas/user-settings.schema"; import { User } from "src/users/schemas/user.schema"; import { Action } from "./action.enum"; -import configuration from "src/config/configuration"; +// import configuration from "src/config/configuration"; type Subjects = | string @@ -54,6 +56,12 @@ export type AppAbility = MongoAbility; @Injectable() export class CaslAbilityFactory { + constructor(private configService: ConfigService) { + this.accessGroups = + this.configService.get("accessGroups"); + } + private accessGroups; + private endpointAccessors: { [endpoint: string]: (user: JWTUser) => AppAbility; } = { @@ -90,17 +98,34 @@ export class CaslAbilityFactory { /* unauthenticated users **/ + can(Action.DatasetReadManyPublic, DatasetClass); + can(Action.DatasetReadOnePublic, DatasetClass, { + isPublished: true, + }); + // - + can(Action.DatasetAttachmentReadPublic, DatasetClass, { + isPublished: true, + }); + // - + can(Action.DatasetOrigdatablockReadPublic, DatasetClass, { + isPublished: true, + }); + // - + can(Action.DatasetDatablockReadPublic, DatasetClass, { + isPublished: true, + }); + cannot(Action.DatasetCreate, DatasetClass); - can(Action.DatasetRead, DatasetClass); + cannot(Action.DatasetRead, DatasetClass); cannot(Action.DatasetUpdate, DatasetClass); // - cannot(Action.DatasetAttachmentCreate, DatasetClass); - can(Action.DatasetAttachmentRead, DatasetClass); + cannot(Action.DatasetAttachmentRead, DatasetClass); cannot(Action.DatasetAttachmentUpdate, DatasetClass); cannot(Action.DatasetAttachmentDelete, DatasetClass); // - cannot(Action.DatasetOrigdatablockCreate, DatasetClass); - can(Action.DatasetOrigdatablockRead, DatasetClass); + cannot(Action.DatasetOrigdatablockRead, DatasetClass); cannot(Action.DatasetOrigdatablockUpdate, DatasetClass); // - cannot(Action.DatasetDatablockCreate, DatasetClass); @@ -110,7 +135,7 @@ export class CaslAbilityFactory { cannot(Action.DatasetLogbookRead, DatasetClass); } else { if ( - user.currentGroups.some((g) => configuration().deleteGroups.includes(g)) + user.currentGroups.some((g) => this.accessGroups?.delete.includes(g)) ) { /* / user that belongs to any of the group listed in DELETE_GROUPS @@ -134,7 +159,7 @@ export class CaslAbilityFactory { } if ( - user.currentGroups.some((g) => configuration().adminGroups.includes(g)) + user.currentGroups.some((g) => this.accessGroups?.admin.includes(g)) ) { /* / user that belongs to any of the group listed in ADMIN_GROUPS @@ -160,7 +185,7 @@ export class CaslAbilityFactory { can(Action.DatasetLogbookRead, DatasetClass); } else if ( user.currentGroups.some((g) => - configuration().createDatasetPrivilegedGroups.includes(g), + this.accessGroups?.createDatasetPrivileged.includes(g), ) ) { /** @@ -187,9 +212,9 @@ export class CaslAbilityFactory { can(Action.DatasetLogbookRead, DatasetClass); } else if ( user.currentGroups.some((g) => - configuration().createDatasetWithPidGroups.includes(g), + this.accessGroups?.createDatasetWithPid.includes(g), ) || - configuration().createDatasetWithPidGroups.includes("#all") + this.accessGroups?.createDatasetWithPid.includes("#all") ) { /** /* users belonging to CREATE_DATASET_WITH_PID_GROUPS @@ -215,9 +240,9 @@ export class CaslAbilityFactory { can(Action.DatasetLogbookRead, DatasetClass); } else if ( user.currentGroups.some((g) => - configuration().createDatasetGroups.includes(g), + this.accessGroups?.createDataset.includes(g), ) || - configuration().createDatasetGroups.includes("#all") + this.accessGroups?.createDataset.includes("#all") ) { /** /* users belonging to CREATE_DATASET_GROUPS @@ -279,7 +304,7 @@ export class CaslAbilityFactory { if ( user && - user.currentGroups.some((g) => configuration().adminGroups.includes(g)) + user.currentGroups.some((g) => this.accessGroups?.admin.includes(g)) ) { /* / user that belongs to any of the group listed in ADMIN_GROUPS @@ -304,7 +329,7 @@ export class CaslAbilityFactory { cannot(Action.InstrumentDelete, Instrument); } else { if ( - user.currentGroups.some((g) => configuration().deleteGroups.includes(g)) + user.currentGroups.some((g) => this.accessGroups?.delete.includes(g)) ) { /* * user that belongs to any of the group listed in DELETE_GROUPS @@ -316,7 +341,7 @@ export class CaslAbilityFactory { } if ( - user.currentGroups.some((g) => configuration().adminGroups.includes(g)) + user.currentGroups.some((g) => this.accessGroups?.admin.includes(g)) ) { /** * authenticated users belonging to any of the group listed in ADMIN_GROUPS @@ -351,7 +376,7 @@ export class CaslAbilityFactory { cannot(Action.JobsCreate, JobClass); cannot(Action.JobsUpdate, JobClass); } else if ( - user.currentGroups.some((g) => configuration().adminGroups.includes(g)) + user.currentGroups.some((g) => this.accessGroups?.admin.includes(g)) ) { /** * authenticated users belonging to any of the group listed in ADMIN_GROUPS @@ -361,9 +386,7 @@ export class CaslAbilityFactory { can(Action.JobsCreate, JobClass); can(Action.JobsUpdate, JobClass); } else if ( - user.currentGroups.some((g) => - configuration().createJobGroups.includes(g), - ) + user.currentGroups.some((g) => this.accessGroups?.createJob.includes(g)) ) { /** * authenticated users belonging to any of the group listed in CREATE_JOBS_GROUPS @@ -373,9 +396,7 @@ export class CaslAbilityFactory { can(Action.JobsCreate, JobClass); can(Action.JobsUpdate, JobClass); } else if ( - user.currentGroups.some((g) => - configuration().updateJobGroups.includes(g), - ) + user.currentGroups.some((g) => this.accessGroups?.updateJob.includes(g)) ) { /** * authenticated users belonging to any of the group listed in UPDATE_JOBS_GROUPS @@ -421,9 +442,7 @@ export class CaslAbilityFactory { const { can, cannot, build } = new AbilityBuilder( createMongoAbility, ); - if ( - user.currentGroups.some((g) => configuration().deleteGroups.includes(g)) - ) { + if (user.currentGroups.some((g) => this.accessGroups?.delete.includes(g))) { /* / user that belongs to any of the group listed in DELETE_GROUPS */ @@ -437,9 +456,7 @@ export class CaslAbilityFactory { cannot(Action.OrigdatablockDelete, OrigDatablock); } - if ( - user.currentGroups.some((g) => configuration().adminGroups.includes(g)) - ) { + if (user.currentGroups.some((g) => this.accessGroups?.admin.includes(g))) { /* / user that belongs to any of the group listed in ADMIN_GROUPS */ @@ -449,7 +466,7 @@ export class CaslAbilityFactory { can(Action.OrigdatablockUpdate, OrigDatablock); } else if ( user.currentGroups.some((g) => - configuration().createDatasetPrivilegedGroups.includes(g), + this.accessGroups?.createDatasetPrivileged.includes(g), ) ) { /** @@ -461,9 +478,9 @@ export class CaslAbilityFactory { can(Action.OrigdatablockUpdate, OrigDatablock); } else if ( user.currentGroups.some((g) => - configuration().createDatasetWithPidGroups.includes(g), + this.accessGroups?.createDatasetWithPid.includes(g), ) || - configuration().createDatasetWithPidGroups.includes("#all") + this.accessGroups?.createDatasetWithPid.includes("#all") ) { /** /* users belonging to CREATE_DATASET_WITH_PID_GROUPS @@ -474,9 +491,9 @@ export class CaslAbilityFactory { can(Action.OrigdatablockUpdate, OrigDatablock); } else if ( user.currentGroups.some((g) => - configuration().createDatasetGroups.includes(g), + this.accessGroups?.createDataset.includes(g), ) || - configuration().createDatasetGroups.includes("#all") + this.accessGroups?.createDataset.includes("#all") ) { /** /* users belonging to CREATE_DATASET_GROUPS @@ -506,7 +523,7 @@ export class CaslAbilityFactory { ); if ( user && - user.currentGroups.some((g) => configuration().deleteGroups.includes(g)) + user.currentGroups.some((g) => this.accessGroups?.delete.includes(g)) ) { /* / user that belongs to any of the group listed in DELETE_GROUPS @@ -514,7 +531,7 @@ export class CaslAbilityFactory { can(Action.Delete, Policy); } else if ( user && - user.currentGroups.some((g) => configuration().adminGroups.includes(g)) + user.currentGroups.some((g) => this.accessGroups?.admin.includes(g)) ) { /* / user that belongs to any of the group listed in ADMIN_GROUPS @@ -548,7 +565,7 @@ export class CaslAbilityFactory { cannot(Action.ProposalsAttachmentUpdate, ProposalClass); cannot(Action.ProposalsAttachmentDelete, ProposalClass); } else if ( - user.currentGroups.some((g) => configuration().deleteGroups.includes(g)) + user.currentGroups.some((g) => this.accessGroups?.delete.includes(g)) ) { /* / user that belongs to any of the group listed in DELETE_GROUPS @@ -556,7 +573,7 @@ export class CaslAbilityFactory { can(Action.ProposalsDelete, ProposalClass); } else if ( - user.currentGroups.some((g) => configuration().adminGroups.includes(g)) + user.currentGroups.some((g) => this.accessGroups?.admin.includes(g)) ) { /** * authenticated users belonging to any of the group listed in ADMIN_GROUPS @@ -572,7 +589,7 @@ export class CaslAbilityFactory { can(Action.ProposalsAttachmentDelete, ProposalClass); } else if ( user.currentGroups.some((g) => { - return configuration().proposalGroups.includes(g); + return this.accessGroups?.proposal.includes(g); }) ) { /** @@ -621,7 +638,7 @@ export class CaslAbilityFactory { if ( user && - user.currentGroups.some((g) => configuration().deleteGroups.includes(g)) + user.currentGroups.some((g) => this.accessGroups?.delete.includes(g)) ) { /* / user that belongs to any of the group listed in DELETE_GROUPS @@ -659,7 +676,7 @@ export class CaslAbilityFactory { // ------------------------------------- if ( - user.currentGroups.some((g) => configuration().deleteGroups.includes(g)) + user.currentGroups.some((g) => this.accessGroups?.delete.includes(g)) ) { // ------------------------------------- // users that belong to any of the group listed in DELETE_GROUPS @@ -676,7 +693,7 @@ export class CaslAbilityFactory { } if ( - user.currentGroups.some((g) => configuration().adminGroups.includes(g)) + user.currentGroups.some((g) => this.accessGroups?.admin.includes(g)) ) { // ------------------------------------- // users belonging to any of the group listed in ADMIN_GROUPS @@ -692,7 +709,7 @@ export class CaslAbilityFactory { can(Action.SampleDatasetRead, SampleClass); } else if ( user.currentGroups.some((g) => - configuration().samplePrivilegedGroups.includes(g), + this.accessGroups?.samplePrivileged.includes(g), ) ) { // ------------------------------------- @@ -708,10 +725,8 @@ export class CaslAbilityFactory { can(Action.SampleAttachmentDelete, SampleClass); can(Action.SampleDatasetRead, SampleClass); } else if ( - user.currentGroups.some((g) => - configuration().sampleGroups.includes(g), - ) || - configuration().sampleGroups.includes("#all") + user.currentGroups.some((g) => this.accessGroups?.sample.includes(g)) || + this.accessGroups?.sample.includes("#all") ) { // ------------------------------------- // users belonging to any of the group listed in SAMPLE_GROUPS @@ -737,9 +752,7 @@ export class CaslAbilityFactory { cannot(Action.SampleAttachmentCreate, SampleClass); cannot(Action.SampleAttachmentUpdate, SampleClass); if ( - !user.currentGroups.some((g) => - configuration().deleteGroups.includes(g), - ) + !user.currentGroups.some((g) => this.accessGroups?.delete.includes(g)) ) { cannot(Action.SampleAttachmentDelete, SampleClass); } @@ -772,7 +785,7 @@ export class CaslAbilityFactory { cannot(Action.UserDeleteAny, User); } else { if ( - user.currentGroups.some((g) => configuration().adminGroups.includes(g)) + user.currentGroups.some((g) => this.accessGroups?.admin.includes(g)) ) { /* / user that belongs to any of the group listed in ADMIN_GROUPS @@ -839,7 +852,7 @@ export class CaslAbilityFactory { }); } else { if ( - user.currentGroups.some((g) => configuration().deleteGroups.includes(g)) + user.currentGroups.some((g) => this.accessGroups?.delete.includes(g)) ) { /* / user that belongs to any of the group listed in DELETE_GROUPS @@ -853,7 +866,7 @@ export class CaslAbilityFactory { } if ( - user.currentGroups.some((g) => configuration().adminGroups.includes(g)) + user.currentGroups.some((g) => this.accessGroups?.admin.includes(g)) ) { /* / user that belongs to any of the group listed in ADMIN_GROUPS @@ -879,7 +892,7 @@ export class CaslAbilityFactory { can(Action.DatasetLogbookReadAny, DatasetClass); } else if ( user.currentGroups.some((g) => - configuration().createDatasetPrivilegedGroups.includes(g), + this.accessGroups?.createDatasetPrivileged.includes(g), ) ) { /** @@ -951,9 +964,9 @@ export class CaslAbilityFactory { }); } else if ( user.currentGroups.some((g) => - configuration().createDatasetWithPidGroups.includes(g), + this.accessGroups?.createDatasetWithPid.includes(g), ) || - configuration().createDatasetWithPidGroups.includes("#all") + this.accessGroups?.createDatasetWithPid.includes("#all") ) { /** /* users belonging to CREATE_DATASET_WITH_PID_GROUPS @@ -1032,9 +1045,9 @@ export class CaslAbilityFactory { }); } else if ( user.currentGroups.some((g) => - configuration().createDatasetGroups.includes(g), + this.accessGroups?.createDataset.includes(g), ) || - configuration().createDatasetGroups.includes("#all") + this.accessGroups?.createDataset.includes("#all") ) { /** /* users belonging to CREATE_DATASET_GROUPS @@ -1174,114 +1187,128 @@ export class CaslAbilityFactory { const { can, build } = new AbilityBuilder( createMongoAbility, ); - if ( - user.currentGroups.some((g) => configuration().deleteGroups.includes(g)) - ) { - /* + if (!user) { + /** + /* unauthenticated users + **/ + + can(Action.OrigdatablockReadManyPublic, SampleClass); + can(Action.OrigdatablockReadOnePublic, SampleClass, { + isPublished: true, + }); + can(Action.DatasetOrigdatablockReadPublic, SampleClass, { + isPublished: true, + }); + } else { + if ( + user.currentGroups.some((g) => this.accessGroups?.delete.includes(g)) + ) { + /* / user that belongs to any of the group listed in DELETE_GROUPS */ - can(Action.OrigdatablockDeleteAny, OrigDatablock); - } - if ( - user.currentGroups.some((g) => configuration().adminGroups.includes(g)) - ) { - /* + can(Action.OrigdatablockDeleteAny, OrigDatablock); + } + if ( + user.currentGroups.some((g) => this.accessGroups?.admin.includes(g)) + ) { + /* / user that belongs to any of the group listed in ADMIN_GROUPS */ - can(Action.OrigdatablockReadAny, OrigDatablock); - can(Action.OrigdatablockCreateAny, OrigDatablock); - can(Action.OrigdatablockUpdateAny, OrigDatablock); - } else if ( - user.currentGroups.some((g) => - configuration().createDatasetPrivilegedGroups.includes(g), - ) - ) { - /** + can(Action.OrigdatablockReadAny, OrigDatablock); + can(Action.OrigdatablockCreateAny, OrigDatablock); + can(Action.OrigdatablockUpdateAny, OrigDatablock); + } else if ( + user.currentGroups.some((g) => + this.accessGroups?.createDatasetPrivileged.includes(g), + ) + ) { + /** /* users belonging to CREATE_DATASET_PRIVILEGED_GROUPS **/ - can(Action.OrigdatablockCreateAny, OrigDatablock); - can(Action.OrigdatablockReadManyAccess, OrigDatablock); - can(Action.OrigdatablockReadOneAccess, OrigDatablock, { - ownerGroup: { $in: user.currentGroups }, - }); - can(Action.OrigdatablockReadOneAccess, OrigDatablock, { - accessGroups: { $in: user.currentGroups }, - }); - can(Action.OrigdatablockReadOneAccess, OrigDatablock, { - ownerGroup: { $in: user.currentGroups }, - }); - can(Action.OrigdatablockUpdateOwner, OrigDatablock, { - ownerGroup: { $in: user.currentGroups }, - }); - } else if ( - user.currentGroups.some((g) => - configuration().createDatasetWithPidGroups.includes(g), - ) || - configuration().createDatasetWithPidGroups.includes("#all") - ) { - /** + can(Action.OrigdatablockCreateAny, OrigDatablock); + can(Action.OrigdatablockReadManyAccess, OrigDatablock); + can(Action.OrigdatablockReadOneAccess, OrigDatablock, { + ownerGroup: { $in: user.currentGroups }, + }); + can(Action.OrigdatablockReadOneAccess, OrigDatablock, { + accessGroups: { $in: user.currentGroups }, + }); + can(Action.OrigdatablockReadOneAccess, OrigDatablock, { + ownerGroup: { $in: user.currentGroups }, + }); + can(Action.OrigdatablockUpdateOwner, OrigDatablock, { + ownerGroup: { $in: user.currentGroups }, + }); + } else if ( + user.currentGroups.some((g) => + this.accessGroups?.createDatasetWithPid.includes(g), + ) || + this.accessGroups?.createDatasetWithPid.includes("#all") + ) { + /** /* users belonging to CREATE_DATASET_WITH_PID_GROUPS **/ - can(Action.OrigdatablockCreateOwner, OrigDatablock, { - ownerGroup: { $in: user.currentGroups }, - }); - can(Action.OrigdatablockReadManyAccess, OrigDatablock); - can(Action.OrigdatablockReadOneAccess, OrigDatablock, { - ownerGroup: { $in: user.currentGroups }, - }); - can(Action.OrigdatablockReadOneAccess, OrigDatablock, { - accessGroups: { $in: user.currentGroups }, - }); - can(Action.OrigdatablockReadOneAccess, OrigDatablock, { - isPublished: true, - }); - can(Action.OrigdatablockUpdateOwner, OrigDatablock, { - ownerGroup: { $in: user.currentGroups }, - }); - } else if ( - user.currentGroups.some((g) => - configuration().createDatasetGroups.includes(g), - ) || - configuration().createDatasetGroups.includes("#all") - ) { - /** + can(Action.OrigdatablockCreateOwner, OrigDatablock, { + ownerGroup: { $in: user.currentGroups }, + }); + can(Action.OrigdatablockReadManyAccess, OrigDatablock); + can(Action.OrigdatablockReadOneAccess, OrigDatablock, { + ownerGroup: { $in: user.currentGroups }, + }); + can(Action.OrigdatablockReadOneAccess, OrigDatablock, { + accessGroups: { $in: user.currentGroups }, + }); + can(Action.OrigdatablockReadOneAccess, OrigDatablock, { + isPublished: true, + }); + can(Action.OrigdatablockUpdateOwner, OrigDatablock, { + ownerGroup: { $in: user.currentGroups }, + }); + } else if ( + user.currentGroups.some((g) => + this.accessGroups?.createDataset.includes(g), + ) || + this.accessGroups?.createDataset.includes("#all") + ) { + /** /* users belonging to CREATE_DATASET_GROUPS **/ - can(Action.OrigdatablockCreateOwner, OrigDatablock, { - ownerGroup: { $in: user.currentGroups }, - }); - can(Action.OrigdatablockReadManyAccess, OrigDatablock); - can(Action.OrigdatablockReadOneAccess, OrigDatablock, { - ownerGroup: { $in: user.currentGroups }, - }); - can(Action.OrigdatablockReadOneAccess, OrigDatablock, { - accessGroups: { $in: user.currentGroups }, - }); - can(Action.OrigdatablockReadOneAccess, OrigDatablock, { - isPublished: true, - }); - can(Action.OrigdatablockUpdateOwner, OrigDatablock, { - ownerGroup: { $in: user.currentGroups }, - }); - } else if (user) { - /** + can(Action.OrigdatablockCreateOwner, OrigDatablock, { + ownerGroup: { $in: user.currentGroups }, + }); + can(Action.OrigdatablockReadManyAccess, OrigDatablock); + can(Action.OrigdatablockReadOneAccess, OrigDatablock, { + ownerGroup: { $in: user.currentGroups }, + }); + can(Action.OrigdatablockReadOneAccess, OrigDatablock, { + accessGroups: { $in: user.currentGroups }, + }); + can(Action.OrigdatablockReadOneAccess, OrigDatablock, { + isPublished: true, + }); + can(Action.OrigdatablockUpdateOwner, OrigDatablock, { + ownerGroup: { $in: user.currentGroups }, + }); + } else if (user) { + /** /* authenticated users **/ - can(Action.OrigdatablockReadManyAccess, OrigDatablock); - can(Action.OrigdatablockReadOneAccess, OrigDatablock, { - ownerGroup: { $in: user.currentGroups }, - }); - can(Action.OrigdatablockReadOneAccess, OrigDatablock, { - accessGroups: { $in: user.currentGroups }, - }); - can(Action.OrigdatablockReadOneAccess, OrigDatablock, { - isPublished: true, - }); + can(Action.OrigdatablockReadManyAccess, OrigDatablock); + can(Action.OrigdatablockReadOneAccess, OrigDatablock, { + ownerGroup: { $in: user.currentGroups }, + }); + can(Action.OrigdatablockReadOneAccess, OrigDatablock, { + accessGroups: { $in: user.currentGroups }, + }); + can(Action.OrigdatablockReadOneAccess, OrigDatablock, { + isPublished: true, + }); + } } return build({ detectSubjectType: (item) => @@ -1293,16 +1320,12 @@ export class CaslAbilityFactory { const { can, build } = new AbilityBuilder( createMongoAbility, ); - if ( - user.currentGroups.some((g) => configuration().adminGroups.includes(g)) - ) { + if (user.currentGroups.some((g) => this.accessGroups?.admin.includes(g))) { can(Action.JobsReadAny, JobClass); can(Action.JobsCreateAny, JobClass); can(Action.JobsUpdateAny, JobClass); } else if ( - user.currentGroups.some((g) => - configuration().createJobGroups.includes(g), - ) + user.currentGroups.some((g) => this.accessGroups?.createJob.includes(g)) ) { /** * authenticated users belonging to any of the group listed in CREATE_JOBS_GROUPS @@ -1318,9 +1341,7 @@ export class CaslAbilityFactory { ownerGroup: { $in: user.currentGroups }, }); } else if ( - user.currentGroups.some((g) => - configuration().updateJobGroups.includes(g), - ) + user.currentGroups.some((g) => this.accessGroups?.updateJob.includes(g)) ) { /** * authenticated users belonging to any of the group listed in UPDATE_JOBS_GROUPS @@ -1362,14 +1383,14 @@ export class CaslAbilityFactory { isPublished: true, }); } else if ( - user.currentGroups.some((g) => configuration().deleteGroups.includes(g)) + user.currentGroups.some((g) => this.accessGroups?.delete.includes(g)) ) { /* / user that belongs to any of the group listed in DELETE_GROUPS */ can(Action.ProposalsDeleteAny, ProposalClass); } else if ( - user.currentGroups.some((g) => configuration().adminGroups.includes(g)) + user.currentGroups.some((g) => this.accessGroups?.admin.includes(g)) ) { /** * authenticated users belonging to any of the group listed in ADMIN_GROUPS @@ -1385,7 +1406,7 @@ export class CaslAbilityFactory { can(Action.ProposalsAttachmentDeleteAny, ProposalClass); } else if ( user.currentGroups.some((g) => { - return configuration().proposalGroups.includes(g); + return this.accessGroups?.proposal.includes(g); }) ) { /** @@ -1475,7 +1496,7 @@ export class CaslAbilityFactory { // ------------------------------------- if ( - user.currentGroups.some((g) => configuration().deleteGroups.includes(g)) + user.currentGroups.some((g) => this.accessGroups?.delete.includes(g)) ) { // ------------------------------------- // users that belong to any of the group listed in DELETE_GROUPS @@ -1493,7 +1514,7 @@ export class CaslAbilityFactory { } if ( - user.currentGroups.some((g) => configuration().adminGroups.includes(g)) + user.currentGroups.some((g) => this.accessGroups?.admin.includes(g)) ) { // ------------------------------------- // users belonging to any of the group listed in ADMIN_GROUPS @@ -1508,7 +1529,7 @@ export class CaslAbilityFactory { can(Action.SampleAttachmentDeleteAny, SampleClass); } else if ( user.currentGroups.some((g) => - configuration().samplePrivilegedGroups.includes(g), + this.accessGroups?.samplePrivileged.includes(g), ) ) { // ------------------------------------- @@ -1546,10 +1567,8 @@ export class CaslAbilityFactory { ownerGroup: { $in: user.currentGroups }, }); } else if ( - user.currentGroups.some((g) => - configuration().sampleGroups.includes(g), - ) || - configuration().sampleGroups.includes("#all") + user.currentGroups.some((g) => this.accessGroups?.sample.includes(g)) || + this.accessGroups?.sample.includes("#all") ) { // ------------------------------------- // users belonging to any of the group listed in SAMPLE_GROUPS diff --git a/src/casl/casl.module.ts b/src/casl/casl.module.ts index eb9986a86..ee6dbd216 100644 --- a/src/casl/casl.module.ts +++ b/src/casl/casl.module.ts @@ -1,7 +1,9 @@ import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; import { CaslAbilityFactory } from "./casl-ability.factory"; @Module({ + imports: [ConfigModule], providers: [CaslAbilityFactory], exports: [CaslAbilityFactory], }) diff --git a/src/common/dto/ownable.dto.ts b/src/common/dto/ownable.dto.ts index db3a5eafc..b3a09e95f 100644 --- a/src/common/dto/ownable.dto.ts +++ b/src/common/dto/ownable.dto.ts @@ -11,8 +11,9 @@ export class OwnableDto { readonly ownerGroup: string; @ApiProperty({ - type: [String], + type: String, required: false, + isArray: true, description: "List of groups which have access to this item.", }) @IsOptional() diff --git a/src/common/pipes/filter.pipe.ts b/src/common/pipes/filter.pipe.ts index 75fa38b4f..6b4bad1df 100644 --- a/src/common/pipes/filter.pipe.ts +++ b/src/common/pipes/filter.pipe.ts @@ -20,16 +20,16 @@ export class FilterPipe let filter = inValue.filter; // subsitute the loopback operators to mongo equivalent // nin => $in - // eslint-disable-next-line @/quotes + filter = filter.replace(/{"inq":/g, '{"$in":'); // nin => $nin - // eslint-disable-next-line @/quotes + filter = filter.replace(/{"nin":/g, '{"$nin":'); // and => $and - // eslint-disable-next-line @/quotes + filter = filter.replace(/{"and":\[/g, '{"$and":['); // and => $or - // eslint-disable-next-line @/quotes + filter = filter.replace(/{"or":\[/g, '{"$or":['); outValue.filter = filter; } diff --git a/src/common/types.ts b/src/common/types.ts new file mode 100644 index 000000000..4b3f1a08e --- /dev/null +++ b/src/common/types.ts @@ -0,0 +1,40 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IFullFacets } from "src/elastic-search/interfaces/es-common.type"; + +export class FullFacetFilters { + @ApiPropertyOptional() + facets?: string; + + @ApiPropertyOptional() + fields?: string; +} + +export class FullQueryFilters { + @ApiPropertyOptional() + limits?: string; + + @ApiPropertyOptional() + fields?: string; +} + +class TotalSets { + @ApiProperty({ type: Number }) + totalSets: number; +} + +export class FullFacetResponse implements IFullFacets { + @ApiProperty({ type: TotalSets, isArray: true }) + all: [TotalSets]; + + [key: string]: object; +} + +export class CountApiResponse { + @ApiProperty({ type: Number }) + count: number; +} + +export class IsValidResponse { + @ApiProperty({ type: Boolean }) + isvalid: boolean; +} diff --git a/src/common/utils.ts b/src/common/utils.ts index 0dfe1ea52..b035453fe 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -1,10 +1,8 @@ -/* eslint-disable @/quotes */ import { Logger } from "@nestjs/common"; import { inspect } from "util"; import { DateTime } from "luxon"; import { format, unit, Unit, createUnit } from "mathjs"; import { Expression, FilterQuery, Model, PipelineStage } from "mongoose"; -import { DatasetType } from "src/datasets/dataset-type.enum"; import { IAxiosError, IFilters, @@ -12,8 +10,7 @@ import { IScientificFilter, } from "./interfaces/common.interface"; import { ScientificRelation } from "./scientific-relation.enum"; -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { IFullFacets } from "src/elastic-search/interfaces/es-common.type"; +import { DatasetType } from "src/datasets/types/dataset-type.enum"; // add Ã… to mathjs accepted units as equivalent to angstrom const isAlphaOriginal = Unit.isValidAlpha; @@ -283,12 +280,13 @@ export const handleAxiosRequestError = ( export const updateTimesToUTC = (dateKeys: (keyof T)[], instance: T): T => { dateKeys.forEach((key) => { if (instance[key]) { - const dateField = instance[key] as unknown as string; + const dateField = instance[key] as string; instance[key] = DateTime.fromISO(dateField, { zone: DateTime.local().zoneName as string, - }).toISO() as unknown as T[keyof T]; + }).toISO() as T[keyof T]; } }); + return instance; }; @@ -325,6 +323,24 @@ export const parseLimitFilters = ( return { limit, skip, sort }; }; +export const parsePipelineSort = (sort: Record) => { + const pipelineSort: Record = {}; + for (const property in sort) { + pipelineSort[property] = sort[property] === "asc" ? 1 : -1; + } + + return pipelineSort; +}; + +export const parsePipelineProjection = (fieldsProjection: string[]) => { + const pipelineProjection: Record = {}; + fieldsProjection.forEach((field) => { + pipelineProjection[field] = true; + }); + + return pipelineProjection; +}; + export const parseLimitFiltersForPipeline = ( limits: ILimitsFilter | undefined, ): PipelineStage[] => { @@ -793,6 +809,16 @@ export const createFullfacetPipeline = ( return pipeline; }; +export const addApiVersionField = ( + obj: T, + routePath: string, +) => { + // Extract the number from the route path. For now this is the only solution. + const apiVersion = routePath.match(/(?<=\/v)(.*?)(?=\/)/gi)?.[0]; + + Object.assign(obj, { version: apiVersion }); +}; + export const addCreatedByFields = ( obj: T, username: string, @@ -1015,40 +1041,11 @@ export const sleep = (ms: number) => { return new Promise((resolve) => setTimeout(resolve, ms)); }; -export class FullFacetFilters { - @ApiPropertyOptional() - facets?: string; - - @ApiPropertyOptional() - fields?: string; -} - -export class FullQueryFilters { - @ApiPropertyOptional() - limits?: string; - - @ApiPropertyOptional() - fields?: string; -} - -class TotalSets { - @ApiProperty({ type: Number }) - totalSets: number; -} - -export class FullFacetResponse implements IFullFacets { - @ApiProperty({ type: TotalSets, isArray: true }) - all: [TotalSets]; - - [key: string]: object; -} - -export class CountApiResponse { - @ApiProperty({ type: Number }) - count: number; -} - -export class IsValidResponse { - @ApiProperty({ type: Boolean }) - isvalid: boolean; -} +export const isJsonString = (str: string) => { + try { + JSON.parse(str); + } catch { + return false; + } + return true; +}; diff --git a/src/config/configuration.ts b/src/config/configuration.ts index ae595c9df..a8056d972 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -2,30 +2,29 @@ import * as fs from "fs"; import { merge } from "lodash"; import localconfiguration from "./localconfiguration"; import { boolean } from "mathjs"; -import { DatasetType } from "src/datasets/dataset-type.enum"; import { DEFAULT_PROPOSAL_TYPE } from "src/proposals/schemas/proposal.schema"; +import { DatasetType } from "src/datasets/types/dataset-type.enum"; const configuration = () => { const accessGroupsStaticValues = - process.env.ACCESS_GROUPS_STATIC_VALUES || ("" as string); - const adminGroups = process.env.ADMIN_GROUPS || ("" as string); - const deleteGroups = process.env.DELETE_GROUPS || ("" as string); - const createDatasetGroups = - process.env.CREATE_DATASET_GROUPS || ("#all" as string); + process.env.ACCESS_GROUPS_STATIC_VALUES || ""; + const adminGroups = process.env.ADMIN_GROUPS || ""; + const deleteGroups = process.env.DELETE_GROUPS || ""; + const createDatasetGroups = process.env.CREATE_DATASET_GROUPS || "#all"; const createDatasetWithPidGroups = - process.env.CREATE_DATASET_WITH_PID_GROUPS || ("" as string); + process.env.CREATE_DATASET_WITH_PID_GROUPS || ""; const createDatasetPrivilegedGroups = - process.env.CREATE_DATASET_PRIVILEGED_GROUPS || ("" as string); + process.env.CREATE_DATASET_PRIVILEGED_GROUPS || ""; const datasetCreationValidationEnabled = process.env.DATASET_CREATION_VALIDATION_ENABLED || false; const datasetCreationValidationRegex = - process.env.DATASET_CREATION_VALIDATION_REGEX || ("" as string); + process.env.DATASET_CREATION_VALIDATION_REGEX || ""; - const createJobGroups = process.env.CREATE_JOB_GROUPS || ("" as string); - const updateJobGroups = process.env.UPDATE_JOB_GROUPS || ("" as string); + const createJobGroups = process.env.CREATE_JOB_GROUPS || ""; + const updateJobGroups = process.env.UPDATE_JOB_GROUPS || ""; - const proposalGroups = process.env.PROPOSAL_GROUPS || ("" as string); - const sampleGroups = process.env.SAMPLE_GROUPS || ("#all" as string); + const proposalGroups = process.env.PROPOSAL_GROUPS || ""; + const sampleGroups = process.env.SAMPLE_GROUPS || "#all"; const samplePrivilegedGroups = process.env.SAMPLE_PRIVILEGED_GROUPS || ("" as string); @@ -33,7 +32,7 @@ const configuration = () => { process.env.OIDC_USERQUERY_FILTER || ("" as string); const oidcUsernameFieldMapping = - process.env.OIDC_USERINFO_MAPPING_FIELD_USERNAME || ("" as string); + process.env.OIDC_USERINFO_MAPPING_FIELD_USERNAME || ""; const defaultLogger = { type: "DefaultLogger", @@ -82,24 +81,24 @@ const configuration = () => { }, swaggerPath: process.env.SWAGGER_PATH || "explorer", loggerConfigs: jsonConfigMap.loggers || [defaultLogger], - adminGroups: adminGroups.split(",").map((v) => v.trim()) ?? [], - deleteGroups: deleteGroups.split(",").map((v) => v.trim()) ?? [], - createDatasetGroups: createDatasetGroups.split(",").map((v) => v.trim()), - createDatasetWithPidGroups: createDatasetWithPidGroups - .split(",") - .map((v) => v.trim()), - createDatasetPrivilegedGroups: createDatasetPrivilegedGroups - .split(",") - .map((v) => v.trim()), - proposalGroups: proposalGroups.split(",").map((v) => v.trim()), - sampleGroups: sampleGroups.split(",").map((v) => v.trim()), - samplePrivilegedGroups: samplePrivilegedGroups - .split(",") - .map((v) => v.trim()), - datasetCreationValidationEnabled: datasetCreationValidationEnabled, + accessGroups: { + admin: adminGroups.split(",").map((v) => v.trim()) ?? [], + delete: deleteGroups.split(",").map((v) => v.trim()) ?? [], + createDataset: createDatasetGroups.split(",").map((v) => v.trim()), + createDatasetWithPid: createDatasetWithPidGroups + .split(",") + .map((v) => v.trim()), + createDatasetPrivileged: createDatasetPrivilegedGroups + .split(",") + .map((v) => v.trim()), + proposal: proposalGroups.split(",").map((v) => v.trim()), + sample: sampleGroups.split(",").map((v) => v.trim()), + samplePrivileged: samplePrivilegedGroups.split(",").map((v) => v.trim()), + createJob: createJobGroups, + updateJob: updateJobGroups, + }, + datasetCreationValidationEnabled: boolean(datasetCreationValidationEnabled), datasetCreationValidationRegex: datasetCreationValidationRegex, - createJobGroups: createJobGroups, - updateJobGroups: updateJobGroups, logoutURL: process.env.LOGOUT_URL ?? "", // Example: http://localhost:3000/ accessGroupsGraphQlConfig: { enabled: boolean(process.env?.ACCESS_GROUPS_GRAPHQL_ENABLED || false), @@ -234,5 +233,6 @@ const configuration = () => { }; export type OidcConfig = ReturnType["oidc"]; +export type AccessGroupsType = ReturnType["accessGroups"]; export default configuration; diff --git a/src/datasets/datasets-access.service.ts b/src/datasets/datasets-access.service.ts new file mode 100644 index 000000000..97d83ac5f --- /dev/null +++ b/src/datasets/datasets-access.service.ts @@ -0,0 +1,170 @@ +import { Inject, Injectable, Scope } from "@nestjs/common"; +import { REQUEST } from "@nestjs/core"; +import { Request } from "express"; +import { PipelineStage } from "mongoose"; +import { JWTUser } from "src/auth/interfaces/jwt-user.interface"; +import { Action } from "src/casl/action.enum"; +import { DatasetLookupKeysEnum } from "./types/dataset-lookup"; +import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; +import { ProposalClass } from "src/proposals/schemas/proposal.schema"; +import { Instrument } from "src/instruments/schemas/instrument.schema"; +import { OrigDatablock } from "src/origdatablocks/schemas/origdatablock.schema"; +import { SampleClass } from "src/samples/schemas/sample.schema"; +import { Datablock } from "src/datablocks/schemas/datablock.schema"; + +@Injectable({ scope: Scope.REQUEST }) +export class DatasetsAccessService { + constructor( + private caslAbilityFactory: CaslAbilityFactory, + @Inject(REQUEST) private request: Request, + ) {} + + getRelationViewAccess(field: DatasetLookupKeysEnum, user: JWTUser) { + switch (field) { + case DatasetLookupKeysEnum.proposals: { + const ability = this.caslAbilityFactory.proposalsInstanceAccess(user); + const canViewAny = ability.can(Action.ProposalsReadAny, ProposalClass); + const canViewAccess = ability.can( + Action.ProposalsReadManyAccess, + ProposalClass, + ); + const canViewOwner = ability.can( + Action.ProposalsReadManyOwner, + ProposalClass, + ); + const canViewPublic = ability.can( + Action.ProposalsReadManyPublic, + ProposalClass, + ); + + return { canViewAny, canViewOwner, canViewAccess, canViewPublic }; + } + case DatasetLookupKeysEnum.origdatablocks: { + const ability = + this.caslAbilityFactory.origDatablockInstanceAccess(user); + const canViewAny = ability.can( + Action.OrigdatablockReadAny, + OrigDatablock, + ); + const canViewAccess = ability.can( + Action.OrigdatablockReadManyAccess, + OrigDatablock, + ); + const canViewOwner = ability.can( + Action.OrigdatablockReadManyOwner, + OrigDatablock, + ); + const canViewPublic = ability.can( + Action.OrigdatablockReadManyPublic, + OrigDatablock, + ); + + return { canViewAny, canViewOwner, canViewAccess, canViewPublic }; + } + case DatasetLookupKeysEnum.datablocks: { + const ability = this.caslAbilityFactory.datasetInstanceAccess(user); + const canViewAny = ability.can( + Action.DatasetDatablockReadAny, + Datablock, + ); + const canViewAccess = ability.can( + Action.DatasetDatablockReadAccess, + Datablock, + ); + const canViewOwner = ability.can( + Action.DatasetDatablockReadOwner, + Datablock, + ); + const canViewPublic = ability.can( + Action.DatasetDatablockReadPublic, + Datablock, + ); + + return { canViewAny, canViewOwner, canViewAccess, canViewPublic }; + } + case DatasetLookupKeysEnum.samples: { + const ability = this.caslAbilityFactory.samplesInstanceAccess(user); + const canViewAny = ability.can(Action.SampleReadAny, SampleClass); + const canViewAccess = ability.can( + Action.SampleReadManyAccess, + SampleClass, + ); + const canViewOwner = ability.can( + Action.SampleReadManyOwner, + SampleClass, + ); + const canViewPublic = ability.can( + Action.SampleReadManyPublic, + SampleClass, + ); + + return { canViewAny, canViewOwner, canViewAccess, canViewPublic }; + } + case DatasetLookupKeysEnum.instruments: { + // TODO: Fix this if the instrument access change + const ability = this.caslAbilityFactory.instrumentEndpointAccess(user); + const canViewAny = ability.can(Action.InstrumentRead, Instrument); + + return { + canViewAny, + canViewOwner: false, + canViewAccess: false, + canViewPublic: true, + }; + } + default: + return { + canViewAny: false, + canViewOwner: false, + canViewAccess: false, + canViewPublic: true, + }; + } + } + + addRelationFieldAccess(fieldValue: PipelineStage.Lookup) { + const currentUser = this.request.user as JWTUser; + + const access = this.getRelationViewAccess( + fieldValue.$lookup.as as DatasetLookupKeysEnum, + currentUser, + ); + + if (access) { + const { canViewAny, canViewAccess, canViewOwner } = access; + + if (!canViewAny) { + if (canViewAccess) { + fieldValue.$lookup.pipeline = [ + { + $match: { + $or: [ + { ownerGroup: { $in: currentUser.currentGroups } }, + { accessGroups: { $in: currentUser.currentGroups } }, + { sharedWith: { $in: [currentUser.email] } }, + { isPublished: true }, + ], + }, + }, + ]; + } else if (canViewOwner) { + fieldValue.$lookup.pipeline = [ + { + $match: { + ownerGroup: { $in: currentUser.currentGroups }, + }, + }, + ]; + } else { + fieldValue.$lookup.pipeline = [ + { + $match: { + isPublished: true, + }, + }, + ]; + } + } + } + } +} diff --git a/src/datasets/datasets-public.v4.controller.spec.ts b/src/datasets/datasets-public.v4.controller.spec.ts new file mode 100644 index 000000000..d8ecd4021 --- /dev/null +++ b/src/datasets/datasets-public.v4.controller.spec.ts @@ -0,0 +1,26 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { DatasetsService } from "./datasets.service"; +import { DatasetsPublicV4Controller } from "./datasets-public.v4.controller"; +import { ConfigModule } from "@nestjs/config"; + +class DatasetsServiceMock {} + +describe("DatasetsController", () => { + let controller: DatasetsPublicV4Controller; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [DatasetsPublicV4Controller], + imports: [ConfigModule], + providers: [{ provide: DatasetsService, useClass: DatasetsServiceMock }], + }).compile(); + + controller = module.get( + DatasetsPublicV4Controller, + ); + }); + + it("should be defined", () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/datasets/datasets-public.v4.controller.ts b/src/datasets/datasets-public.v4.controller.ts new file mode 100644 index 000000000..e87124cd2 --- /dev/null +++ b/src/datasets/datasets-public.v4.controller.ts @@ -0,0 +1,280 @@ +import { Controller, Get, Param, Query, HttpStatus } from "@nestjs/common"; +import { + ApiExtraModels, + ApiOperation, + ApiParam, + ApiQuery, + ApiResponse, + ApiTags, +} from "@nestjs/swagger"; +import { DatasetsService } from "./datasets.service"; +import { DatasetDocument } from "./schemas/dataset.schema"; +import { + IDatasetFields, + IDatasetFiltersV4, +} from "./interfaces/dataset-filters.interface"; +import { IFacets, IFilters } from "src/common/interfaces/common.interface"; + +import { HistoryClass } from "./schemas/history.schema"; +import { TechniqueClass } from "./schemas/technique.schema"; +import { RelationshipClass } from "./schemas/relationship.schema"; +import { OutputDatasetDto } from "./dto/output-dataset.dto"; +import { + CountApiResponse, + FullFacetFilters, + FullFacetResponse, +} from "src/common/types"; +import { DatasetLookupKeysEnum } from "./types/dataset-lookup"; +import { IncludeValidationPipe } from "./pipes/include-validation.pipe"; +import { FilterValidationPipe } from "./pipes/filter-validation.pipe"; +import { getSwaggerDatasetFilterContent } from "./types/dataset-filter-content"; +import { AllowAny } from "src/auth/decorators/allow-any.decorator"; + +@ApiExtraModels(HistoryClass, TechniqueClass, RelationshipClass) +@ApiTags("datasets public v4") +@Controller({ path: "datasets/public", version: "4" }) +export class DatasetsPublicV4Controller { + constructor(private datasetsService: DatasetsService) {} + + addPublicFilter(filter: IDatasetFiltersV4) { + if (!filter.where) { + filter.where = {}; + } + + filter.where = { ...filter.where, isPublished: true }; + } + + // GET /datasets/public + @AllowAny() + @Get() + @ApiOperation({ + summary: "It returns a list of public datasets.", + description: + "It returns a list of public datasets. The list returned can be modified by providing a filter.", + }) + @ApiQuery({ + name: "filter", + description: + "Database filters to apply when retrieving the public datasets", + required: false, + type: String, + content: getSwaggerDatasetFilterContent(), + }) + @ApiResponse({ + status: HttpStatus.OK, + type: OutputDatasetDto, + isArray: true, + description: "Return the datasets requested", + }) + async findAllPublic( + @Query("filter", new FilterValidationPipe(), new IncludeValidationPipe()) + queryFilter: string, + ) { + const parsedFilter = JSON.parse(queryFilter ?? "{}"); + + this.addPublicFilter(parsedFilter); + + const datasets = await this.datasetsService.findAllComplete(parsedFilter); + + return datasets; + } + + // GET /datasets/public/fullfacets + @AllowAny() + @Get("/fullfacet") + @ApiQuery({ + name: "filters", + description: + "Defines list of field names, for which facet counts should be calculated", + required: false, + type: FullFacetFilters, + example: + '{"facets": ["type","creationLocation","ownerGroup","keywords"], fields: {}}', + }) + @ApiResponse({ + status: HttpStatus.OK, + type: FullFacetResponse, + isArray: true, + description: "Return fullfacet response for datasets requested", + }) + async fullfacet( + @Query() filters: { fields?: string; facets?: string }, + ): Promise[]> { + const fields: IDatasetFields = JSON.parse(filters.fields ?? "{}"); + + fields.isPublished = true; + + const parsedFilters: IFacets = { + fields: fields, + facets: JSON.parse(filters.facets ?? "[]"), + }; + + return this.datasetsService.fullFacet(parsedFilters); + } + + // GET /datasets/public/metadataKeys + @AllowAny() + @Get("/metadataKeys") + @ApiOperation({ + summary: + "It returns a list of metadata keys contained in the public datasets matching the filter provided.", + description: + "It returns a list of metadata keys contained in the public datasets matching the filter provided.
This endpoint still needs some work on the filter and facets specification.", + }) + @ApiQuery({ + name: "fields", + description: + "Define the filter conditions by specifying the name of values of fields requested. There is also support for a `text` search to look for strings anywhere in the dataset.", + required: false, + type: String, + example: {}, + }) + @ApiQuery({ + name: "limits", + description: "Define further query parameters like skip, limit, order", + required: false, + type: String, + example: '{ "skip": 0, "limit": 25, "order": "creationTime:desc" }', + }) + @ApiResponse({ + status: HttpStatus.OK, + type: String, + isArray: true, + description: "Return metadata keys for list of public datasets selected", + }) + // NOTE: This one needs to be discussed as well but it gets the metadata keys from the dataset but it doesnt do it with the nested fields. Think about it + async publicMetadataKeys( + @Query() filters: { fields?: string; limits?: string }, + ) { + let fields: IDatasetFields = JSON.parse(filters.fields ?? "{}"); + + fields = { ...fields, isPublished: true }; + + const parsedFilters: IFilters = { + fields: fields, + limits: JSON.parse(filters.limits ?? "{}"), + }; + + return this.datasetsService.metadataKeys(parsedFilters); + } + + // GET /datasets/public/findOne + @AllowAny() + @Get("/findOne") + @ApiOperation({ + summary: "It returns the first public dataset found.", + description: + "It returns the first public dataset of the ones that matches the filter provided. The list returned can be modified by providing a filter.", + }) + @ApiQuery({ + name: "filter", + description: "Database filters to apply when retrieving public dataset", + required: true, + type: String, + content: getSwaggerDatasetFilterContent({ + where: true, + include: true, + fields: true, + limits: true, + }), + }) + @ApiResponse({ + status: HttpStatus.OK, + type: OutputDatasetDto, + description: "Return the datasets requested", + }) + async findOnePublic( + @Query("filter", new FilterValidationPipe(), new IncludeValidationPipe()) + queryFilter: string, + ): Promise { + const parsedFilter = JSON.parse(queryFilter ?? "{}"); + + this.addPublicFilter(parsedFilter); + + return this.datasetsService.findOneComplete(parsedFilter); + } + + // GET /datasets/public/count + @AllowAny() + @Get("/count") + @ApiOperation({ + summary: "It returns the number of public datasets.", + description: + "It returns a number of public datasets matching the where filter if provided.", + }) + @ApiQuery({ + name: "filter", + description: + "Database filters to apply when retrieving count for public datasets", + required: false, + type: String, + content: getSwaggerDatasetFilterContent({ + where: true, + include: false, + fields: false, + limits: false, + }), + }) + @ApiResponse({ + status: HttpStatus.OK, + type: CountApiResponse, + description: + "Return the number of public datasets in the following format: { count: integer }", + }) + async countPublic( + @Query( + "filter", + new FilterValidationPipe({ + where: true, + include: false, + fields: false, + limits: false, + }), + ) + queryFilter?: string, + ) { + const parsedFilter = JSON.parse(queryFilter ?? "{}"); + + this.addPublicFilter(parsedFilter); + + return this.datasetsService.count(parsedFilter); + } + + // GET /datasets/public/:id + @AllowAny() + @Get("/:pid") + @ApiParam({ + name: "pid", + description: "Id of the public dataset to return", + type: String, + }) + @ApiResponse({ + status: HttpStatus.OK, + type: OutputDatasetDto, + isArray: false, + description: "Return public dataset with pid specified", + }) + @ApiQuery({ + name: "include", + enum: DatasetLookupKeysEnum, + type: String, + required: false, + isArray: true, + }) + async findByIdPublic( + @Param("pid") id: string, + @Query("include", new IncludeValidationPipe()) + include: DatasetLookupKeysEnum[] | DatasetLookupKeysEnum, + ) { + const includeArray = Array.isArray(include) + ? include + : include && Array(include); + + const dataset = await this.datasetsService.findOneComplete({ + where: { pid: id, isPublished: true }, + include: includeArray, + }); + + return dataset; + } +} diff --git a/src/datasets/datasets.controller.spec.ts b/src/datasets/datasets.controller.spec.ts index 1a683dd6b..c96eff919 100644 --- a/src/datasets/datasets.controller.spec.ts +++ b/src/datasets/datasets.controller.spec.ts @@ -6,6 +6,7 @@ import { OrigDatablocksService } from "src/origdatablocks/origdatablocks.service import { DatasetsController } from "./datasets.controller"; import { DatasetsService } from "./datasets.service"; import { LogbooksService } from "src/logbooks/logbooks.service"; +import { ConfigService } from "@nestjs/config"; class AttachmentsServiceMock {} @@ -30,6 +31,7 @@ describe("DatasetsController", () => { { provide: DatablocksService, useClass: DatablocksServiceMock }, { provide: DatasetsService, useClass: DatasetsServiceMock }, { provide: OrigDatablocksService, useClass: OrigDatablocksServiceMock }, + ConfigService, ], }).compile(); diff --git a/src/datasets/datasets.controller.ts b/src/datasets/datasets.controller.ts index bfadfe301..e54716f96 100644 --- a/src/datasets/datasets.controller.ts +++ b/src/datasets/datasets.controller.ts @@ -82,13 +82,8 @@ import { } from "./dto/update-derived-dataset-obsolete.dto"; import { CreateDatasetDatablockDto } from "src/datablocks/dto/create-dataset-datablock"; import { - CountApiResponse, filterDescription, filterExample, - FullFacetFilters, - FullFacetResponse, - FullQueryFilters, - IsValidResponse, replaceLikeOperator, } from "src/common/utils"; import { HistoryClass } from "./schemas/history.schema"; @@ -96,8 +91,6 @@ import { TechniqueClass } from "./schemas/technique.schema"; import { RelationshipClass } from "./schemas/relationship.schema"; import { JWTUser } from "src/auth/interfaces/jwt-user.interface"; import { LogbooksService } from "src/logbooks/logbooks.service"; -import configuration from "src/config/configuration"; -import { DatasetType } from "./dataset-type.enum"; import { OutputDatasetObsoleteDto } from "./dto/output-dataset-obsolete.dto"; import { CreateDatasetDto } from "./dto/create-dataset.dto"; import { @@ -105,6 +98,16 @@ import { UpdateDatasetDto, } from "./dto/update-dataset.dto"; import { Logbook } from "src/logbooks/schemas/logbook.schema"; +import { ConfigService } from "@nestjs/config"; +import { AccessGroupsType } from "src/config/configuration"; +import { DatasetType } from "./types/dataset-type.enum"; +import { + CountApiResponse, + FullFacetFilters, + FullFacetResponse, + FullQueryFilters, + IsValidResponse, +} from "src/common/types"; @ApiBearerAuth() @ApiExtraModels( @@ -116,7 +119,7 @@ import { Logbook } from "src/logbooks/schemas/logbook.schema"; RelationshipClass, ) @ApiTags("datasets") -@Controller("datasets") +@Controller({ path: "datasets", version: "3" }) export class DatasetsController { constructor( private attachmentsService: AttachmentsService, @@ -125,7 +128,22 @@ export class DatasetsController { private origDatablocksService: OrigDatablocksService, private caslAbilityFactory: CaslAbilityFactory, private logbooksService: LogbooksService, - ) {} + private configService: ConfigService, + ) { + this.accessGroups = + this.configService.get("accessGroups"); + this.datasetCreationValidationEnabled = this.configService.get( + "datasetCreationValidationEnabled", + ); + this.datasetCreationValidationRegex = this.configService.get( + "datasetCreationValidationRegex", + ); + this.datasetTypes = this.configService.get("datasetTypes"); + } + private accessGroups; + private datasetCreationValidationEnabled; + private datasetCreationValidationRegex; + private datasetTypes; getFilters( headers: Record, @@ -185,14 +203,32 @@ export class DatasetsController { if (!canViewAny) { if (canViewAccess) { - mergedFilters.where["$or"] = [ - { ownerGroup: { $in: user.currentGroups } }, - { accessGroups: { $in: user.currentGroups } }, - { sharedWith: { $in: user.email } }, - { isPublished: true }, - ]; + if (mergedFilters.where["$and"]) { + mergedFilters.where["$and"].push({ + $or: [ + { ownerGroup: { $in: user.currentGroups } }, + { accessGroups: { $in: user.currentGroups } }, + { sharedWith: { $in: [user.email] } }, + { isPublished: true }, + ], + }); + } else { + mergedFilters.where["$and"] = [ + { + $or: [ + { ownerGroup: { $in: user.currentGroups } }, + { accessGroups: { $in: user.currentGroups } }, + { sharedWith: { $in: [user.email] } }, + { isPublished: true }, + ], + }, + ]; + } } else if (canViewOwner) { - mergedFilters.where = [{ ownerGroup: { $in: user.currentGroups } }]; + mergedFilters.where = { + ...mergedFilters.where, + ownerGroup: { $in: user.currentGroups }, + }; } else if (canViewPublic) { mergedFilters.where = { isPublished: true }; } @@ -323,18 +359,18 @@ export class DatasetsController { getUserPermissionsFromGroups(user: JWTUser) { const userIsAdmin = user.currentGroups.some((g) => - configuration().adminGroups.includes(g), + this.accessGroups?.admin.includes(g), ); const userCanCreateDatasetPrivileged = - configuration().createDatasetPrivilegedGroups.some((value) => + this.accessGroups?.createDatasetPrivileged.some((value) => user.currentGroups.includes(value), ); const userCanCreateDatasetWithPid = - configuration().createDatasetWithPidGroups.some((value) => + this.accessGroups?.createDatasetWithPid.some((value) => user.currentGroups.includes(value), ); const userCanCreateDatasetWithoutPid = - configuration().createDatasetGroups.some((value) => + this.accessGroups?.createDataset.some((value) => user.currentGroups.includes(value), ); @@ -390,11 +426,11 @@ export class DatasetsController { // now checks if we need to validate the pid if ( - configuration().datasetCreationValidationEnabled && - configuration().datasetCreationValidationRegex && + this.datasetCreationValidationEnabled && + this.datasetCreationValidationRegex && dataset.pid ) { - const re = new RegExp(configuration().datasetCreationValidationRegex); + const re = new RegExp(this.datasetCreationValidationRegex); if (!re.test(dataset.pid)) { throw new BadRequestException( @@ -457,9 +493,7 @@ export class DatasetsController { | PartialUpdateDerivedDatasetObsoleteDto | PartialUpdateDatasetDto, ): CreateDatasetDto | UpdateDatasetDto | PartialUpdateDatasetDto { - const propertiesModifier: Record = { - version: "v3", - }; + const propertiesModifier: Record = {}; if ("proposalId" in inputObsoleteDataset) { propertiesModifier.proposalIds = [ @@ -669,7 +703,7 @@ export class DatasetsController { "A dataset with this this unique key already exists!", ); } else { - throw new InternalServerErrorException(); + throw new InternalServerErrorException(error); } } } @@ -717,9 +751,8 @@ export class DatasetsController { CreateDatasetDto) ) { if ( - !(Object.values(configuration().datasetTypes) as string[]).includes( - outputDatasetDto.type, - ) + this.datasetTypes && + !Object.values(this.datasetTypes).includes(outputDatasetDto.type) ) { throw new HttpException( { @@ -825,8 +858,11 @@ export class DatasetsController { // GET /datasets @UseGuards(PoliciesGuard) - @CheckPolicies("datasets", (ability: AppAbility) => - ability.can(Action.DatasetRead, DatasetClass), + @CheckPolicies( + "datasets", + (ability: AppAbility) => + ability.can(Action.DatasetRead, DatasetClass) || + ability.can(Action.DatasetReadManyPublic, DatasetClass), ) @UseInterceptors(MainDatasetsPublicInterceptor) @Get() @@ -913,8 +949,11 @@ export class DatasetsController { // GET /datasets/fullquery @UseGuards(PoliciesGuard) - @CheckPolicies("datasets", (ability: AppAbility) => - ability.can(Action.DatasetRead, DatasetClass), + @CheckPolicies( + "datasets", + (ability: AppAbility) => + ability.can(Action.DatasetRead, DatasetClass) || + ability.can(Action.DatasetReadManyPublic, DatasetClass), ) @UseInterceptors(SubDatasetsPublicInterceptor, FullQueryInterceptor) @Get("/fullquery") @@ -976,8 +1015,11 @@ export class DatasetsController { // GET /fullfacets @UseGuards(PoliciesGuard) - @CheckPolicies("datasets", (ability: AppAbility) => - ability.can(Action.DatasetRead, DatasetClass), + @CheckPolicies( + "datasets", + (ability: AppAbility) => + ability.can(Action.DatasetRead, DatasetClass) || + ability.can(Action.DatasetReadManyPublic, DatasetClass), ) @UseInterceptors(SubDatasetsPublicInterceptor) @Get("/fullfacet") @@ -1034,8 +1076,11 @@ export class DatasetsController { // GET /datasets/metadataKeys @UseGuards(PoliciesGuard) - @CheckPolicies("datasets", (ability: AppAbility) => - ability.can(Action.DatasetRead, DatasetClass), + @CheckPolicies( + "datasets", + (ability: AppAbility) => + ability.can(Action.DatasetRead, DatasetClass) || + ability.can(Action.DatasetReadManyPublic, DatasetClass), ) @UseInterceptors(SubDatasetsPublicInterceptor) @Get("/metadataKeys") @@ -1113,8 +1158,11 @@ export class DatasetsController { // GET /datasets/findOne @UseGuards(PoliciesGuard) - @CheckPolicies("datasets", (ability: AppAbility) => - ability.can(Action.DatasetRead, DatasetClass), + @CheckPolicies( + "datasets", + (ability: AppAbility) => + ability.can(Action.DatasetRead, DatasetClass) || + ability.can(Action.DatasetReadOnePublic, DatasetClass), ) @Get("/findOne") @ApiOperation({ @@ -1188,8 +1236,11 @@ export class DatasetsController { // GET /datasets/count @UseGuards(PoliciesGuard) - @CheckPolicies("datasets", (ability: AppAbility) => - ability.can(Action.DatasetRead, DatasetClass), + @CheckPolicies( + "datasets", + (ability: AppAbility) => + ability.can(Action.DatasetRead, DatasetClass) || + ability.can(Action.DatasetReadManyPublic, DatasetClass), ) @Get("/count") @ApiOperation({ @@ -1229,8 +1280,11 @@ export class DatasetsController { // GET /datasets/:id //@UseGuards(PoliciesGuard) @UseGuards(PoliciesGuard) - @CheckPolicies("datasets", (ability: AppAbility) => - ability.can(Action.DatasetRead, DatasetClass), + @CheckPolicies( + "datasets", + (ability: AppAbility) => + ability.can(Action.DatasetRead, DatasetClass) || + ability.can(Action.DatasetReadOnePublic, DatasetClass), ) @Get("/:pid") @ApiParam({ @@ -1595,8 +1649,11 @@ export class DatasetsController { // GET /datasets/:id/thumbnail @UseGuards(PoliciesGuard) - @CheckPolicies("datasets", (ability: AppAbility) => - ability.can(Action.DatasetRead, DatasetClass), + @CheckPolicies( + "datasets", + (ability: AppAbility) => + ability.can(Action.DatasetRead, DatasetClass) || + ability.can(Action.DatasetReadOnePublic, DatasetClass), ) // @UseGuards(PoliciesGuard) @Get("/:pid/thumbnail") diff --git a/src/datasets/datasets.module.ts b/src/datasets/datasets.module.ts index 11a364084..48d6d73a3 100644 --- a/src/datasets/datasets.module.ts +++ b/src/datasets/datasets.module.ts @@ -5,7 +5,6 @@ import { DatasetsController } from "./datasets.controller"; import { DatasetsService } from "./datasets.service"; import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; import { AttachmentsModule } from "src/attachments/attachments.module"; -import { ConfigModule } from "@nestjs/config"; import { OrigDatablocksModule } from "src/origdatablocks/origdatablocks.module"; import { DatablocksModule } from "src/datablocks/datablocks.module"; import { InitialDatasetsModule } from "src/initial-datasets/initial-datasets.module"; @@ -13,11 +12,13 @@ import { LogbooksModule } from "src/logbooks/logbooks.module"; import { PoliciesService } from "src/policies/policies.service"; import { PoliciesModule } from "src/policies/policies.module"; import { ElasticSearchModule } from "src/elastic-search/elastic-search.module"; +import { DatasetsV4Controller } from "./datasets.v4.controller"; +import { DatasetsPublicV4Controller } from "./datasets-public.v4.controller"; +import { DatasetsAccessService } from "./datasets-access.service"; @Module({ imports: [ AttachmentsModule, - ConfigModule, DatablocksModule, OrigDatablocksModule, InitialDatasetsModule, @@ -64,7 +65,11 @@ import { ElasticSearchModule } from "src/elastic-search/elastic-search.module"; ]), ], exports: [DatasetsService], - controllers: [DatasetsController], - providers: [DatasetsService, CaslAbilityFactory], + controllers: [ + DatasetsPublicV4Controller, + DatasetsController, + DatasetsV4Controller, + ], + providers: [DatasetsService, CaslAbilityFactory, DatasetsAccessService], }) export class DatasetsModule {} diff --git a/src/datasets/datasets.service.spec.ts b/src/datasets/datasets.service.spec.ts index cd14bbd4a..e823ffca8 100644 --- a/src/datasets/datasets.service.spec.ts +++ b/src/datasets/datasets.service.spec.ts @@ -7,6 +7,8 @@ import { LogbooksService } from "src/logbooks/logbooks.service"; import { ElasticSearchService } from "src/elastic-search/elastic-search.service"; import { DatasetsService } from "./datasets.service"; import { DatasetClass } from "./schemas/dataset.schema"; +import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; +import { DatasetsAccessService } from "./datasets-access.service"; class InitialDatasetsServiceMock {} @@ -101,12 +103,14 @@ describe("DatasetsService", () => { }, }, DatasetsService, + DatasetsAccessService, { provide: InitialDatasetsService, useClass: InitialDatasetsServiceMock, }, { provide: LogbooksService, useClass: LogbooksServiceMock }, { provide: ElasticSearchService, useClass: ElasticSearchServiceMock }, + CaslAbilityFactory, ], }).compile(); diff --git a/src/datasets/datasets.service.ts b/src/datasets/datasets.service.ts index b327aa5aa..70fbb0a31 100644 --- a/src/datasets/datasets.service.ts +++ b/src/datasets/datasets.service.ts @@ -9,16 +9,27 @@ import { ConfigService } from "@nestjs/config"; import { REQUEST } from "@nestjs/core"; import { InjectModel } from "@nestjs/mongoose"; import { Request } from "express"; -import { FilterQuery, Model, QueryOptions, UpdateQuery } from "mongoose"; +import { + FilterQuery, + Model, + PipelineStage, + ProjectionType, + QueryOptions, + RootFilterQuery, + UpdateQuery, +} from "mongoose"; import { JWTUser } from "src/auth/interfaces/jwt-user.interface"; import { IFacets, IFilters } from "src/common/interfaces/common.interface"; import { + addApiVersionField, addCreatedByFields, addUpdatedByField, createFullfacetPipeline, createFullqueryFilter, extractMetadataKeys, parseLimitFilters, + parsePipelineProjection, + parsePipelineSort, } from "src/common/utils"; import { ElasticSearchService } from "src/elastic-search/elastic-search.service"; import { InitialDatasetsService } from "src/initial-datasets/initial-datasets.service"; @@ -31,6 +42,13 @@ import { PartialUpdateDatasetWithHistoryDto, UpdateDatasetDto, } from "./dto/update-dataset.dto"; +import { isEmpty } from "lodash"; +import { OutputDatasetDto } from "./dto/output-dataset.dto"; +import { + DatasetLookupKeysEnum, + DATASET_LOOKUP_FIELDS, +} from "./types/dataset-lookup"; +import { DatasetsAccessService } from "./datasets-access.service"; @Injectable({ scope: Scope.REQUEST }) export class DatasetsService { @@ -41,6 +59,7 @@ export class DatasetsService { private datasetModel: Model, private initialDatasetsService: InitialDatasetsService, private logbooksService: LogbooksService, + private datasetsAccessService: DatasetsAccessService, @Inject(ElasticSearchService) private elasticSearchService: ElasticSearchService, @Inject(REQUEST) private request: Request, @@ -50,8 +69,36 @@ export class DatasetsService { } } + addLookupFields( + pipeline: PipelineStage[], + datasetLookupFields?: DatasetLookupKeysEnum[], + ) { + if (datasetLookupFields?.includes(DatasetLookupKeysEnum.all)) { + datasetLookupFields = Object.keys(DATASET_LOOKUP_FIELDS).filter( + (field) => field !== DatasetLookupKeysEnum.all, + ) as DatasetLookupKeysEnum[]; + } + + datasetLookupFields?.forEach((field) => { + const fieldValue = DATASET_LOOKUP_FIELDS[field]; + + if (fieldValue) { + fieldValue.$lookup.as = field; + + this.datasetsAccessService.addRelationFieldAccess(fieldValue); + + pipeline.push(fieldValue); + } + }); + } + async create(createDatasetDto: CreateDatasetDto): Promise { const username = (this.request.user as JWTUser).username; + // Add version to the datasets based on the apiVersion extracted from the route path or use default one + addApiVersionField( + createDatasetDto, + this.request.route.path || this.configService.get("versions.api"), + ); const createdDataset = new this.datasetModel( // insert created and updated fields addCreatedByFields(createDatasetDto, username), @@ -62,11 +109,10 @@ export class DatasetsService { return createdDataset.save(); } - async findAll( - filter: IFilters, - ): Promise { - const whereFilter: FilterQuery = filter.where ?? {}; - const fieldsProjection: FilterQuery = filter.fields ?? {}; + async findAll(filter: FilterQuery): Promise { + const whereFilter: RootFilterQuery = filter.where ?? {}; + const fieldsProjection: ProjectionType = + filter.fields ?? {}; const { limit, skip, sort } = parseLimitFilters(filter.limits); const datasetPromise = this.datasetModel .find(whereFilter, fieldsProjection) @@ -79,6 +125,41 @@ export class DatasetsService { return datasets; } + async findAllComplete( + filter: FilterQuery, + ): Promise { + const whereFilter: FilterQuery = filter.where ?? {}; + const fieldsProjection: string[] = filter.fields ?? {}; + const limits: QueryOptions = filter.limits ?? { + limit: 10, + skip: 0, + sort: { createdAt: "desc" }, + }; + + const pipeline: PipelineStage[] = [{ $match: whereFilter }]; + if (!isEmpty(fieldsProjection)) { + const projection = parsePipelineProjection(fieldsProjection); + pipeline.push({ $project: projection }); + } + + if (!isEmpty(limits.sort)) { + const sort = parsePipelineSort(limits.sort); + pipeline.push({ $sort: sort }); + } + + pipeline.push({ $limit: limits.limit || 10 }); + + pipeline.push({ $skip: limits.skip || 0 }); + + this.addLookupFields(pipeline, filter.include); + + const data = await this.datasetModel + .aggregate(pipeline) + .exec(); + + return data; + } + async fullquery( filter: IFilters, extraWhereClause: FilterQuery = {}, @@ -173,10 +254,42 @@ export class DatasetsService { return this.datasetModel.findOne(whereFilter, fieldsProjection).exec(); } + async findOneComplete( + filter: FilterQuery, + ): Promise { + const whereFilter: FilterQuery = filter.where ?? {}; + const fieldsProjection: string[] = filter.fields ?? {}; + const limits: QueryOptions = filter.limits ?? { + skip: 0, + sort: { createdAt: "desc" }, + }; + + const pipeline: PipelineStage[] = [{ $match: whereFilter }]; + if (!isEmpty(fieldsProjection)) { + const projection = parsePipelineProjection(fieldsProjection); + pipeline.push({ $project: projection }); + } + + if (!isEmpty(limits.sort)) { + const sort = parsePipelineSort(limits.sort); + pipeline.push({ $sort: sort }); + } + + pipeline.push({ $skip: limits.skip || 0 }); + + this.addLookupFields(pipeline, filter.include); + + const [data] = await this.datasetModel + .aggregate(pipeline) + .exec(); + + return data || null; + } + async count( filter: FilterQuery, ): Promise<{ count: number }> { - const whereFilter: FilterQuery = filter.where ?? {}; + const whereFilter: RootFilterQuery = filter.where ?? {}; let count = 0; if (this.ESClient && !filter.where) { const totalDocCount = await this.datasetModel.countDocuments(); diff --git a/src/datasets/datasets.v4.controller.spec.ts b/src/datasets/datasets.v4.controller.spec.ts new file mode 100644 index 000000000..75d92b2b6 --- /dev/null +++ b/src/datasets/datasets.v4.controller.spec.ts @@ -0,0 +1,31 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { CaslModule } from "src/casl/casl.module"; +import { DatasetsService } from "./datasets.service"; +import { LogbooksService } from "src/logbooks/logbooks.service"; +import { ConfigModule } from "@nestjs/config"; +import { DatasetsV4Controller } from "./datasets.v4.controller"; + +class DatasetsServiceMock {} + +class LogbooksServiceMock {} + +describe("DatasetsController", () => { + let controller: DatasetsV4Controller; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [DatasetsV4Controller], + imports: [CaslModule, ConfigModule], + providers: [ + { provide: LogbooksService, useClass: LogbooksServiceMock }, + { provide: DatasetsService, useClass: DatasetsServiceMock }, + ], + }).compile(); + + controller = module.get(DatasetsV4Controller); + }); + + it("should be defined", () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/datasets/datasets.v4.controller.ts b/src/datasets/datasets.v4.controller.ts new file mode 100644 index 000000000..d4bceb7d2 --- /dev/null +++ b/src/datasets/datasets.v4.controller.ts @@ -0,0 +1,849 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Patch, + Put, + Delete, + Query, + UseGuards, + UseInterceptors, + HttpCode, + HttpStatus, + NotFoundException, + Req, + ForbiddenException, + InternalServerErrorException, + ConflictException, +} from "@nestjs/common"; +import { + ApiBearerAuth, + ApiBody, + ApiExtraModels, + ApiOperation, + ApiParam, + ApiQuery, + ApiResponse, + ApiTags, +} from "@nestjs/swagger"; +import { Request } from "express"; +import { MongoError } from "mongodb"; +import { DatasetsService } from "./datasets.service"; +import { DatasetClass, DatasetDocument } from "./schemas/dataset.schema"; +import { PoliciesGuard } from "src/casl/guards/policies.guard"; +import { CheckPolicies } from "src/casl/decorators/check-policies.decorator"; +import { AppAbility, CaslAbilityFactory } from "src/casl/casl-ability.factory"; +import { Action } from "src/casl/action.enum"; +import { + IDatasetFields, + IDatasetFiltersV4, +} from "./interfaces/dataset-filters.interface"; +import { SubDatasetsPublicInterceptor } from "./interceptors/datasets-public.interceptor"; +import { UTCTimeInterceptor } from "src/common/interceptors/utc-time.interceptor"; +import { FormatPhysicalQuantitiesInterceptor } from "src/common/interceptors/format-physical-quantities.interceptor"; +import { IFacets, IFilters } from "src/common/interfaces/common.interface"; +import { validate } from "class-validator"; +import { HistoryInterceptor } from "src/common/interceptors/history.interceptor"; + +import { HistoryClass } from "./schemas/history.schema"; +import { TechniqueClass } from "./schemas/technique.schema"; +import { RelationshipClass } from "./schemas/relationship.schema"; +import { JWTUser } from "src/auth/interfaces/jwt-user.interface"; +import { LogbooksService } from "src/logbooks/logbooks.service"; +import { CreateDatasetDto } from "./dto/create-dataset.dto"; +import { + PartialUpdateDatasetDto, + UpdateDatasetDto, +} from "./dto/update-dataset.dto"; +import { Logbook } from "src/logbooks/schemas/logbook.schema"; +import { OutputDatasetDto } from "./dto/output-dataset.dto"; +import { + CountApiResponse, + FullFacetFilters, + FullFacetResponse, + IsValidResponse, +} from "src/common/types"; +import { DatasetLookupKeysEnum } from "./types/dataset-lookup"; +import { IncludeValidationPipe } from "./pipes/include-validation.pipe"; +import { PidValidationPipe } from "./pipes/pid-validation.pipe"; +import { FilterValidationPipe } from "./pipes/filter-validation.pipe"; +import { getSwaggerDatasetFilterContent } from "./types/dataset-filter-content"; +import { plainToInstance } from "class-transformer"; + +@ApiBearerAuth() +@ApiExtraModels( + CreateDatasetDto, + HistoryClass, + TechniqueClass, + RelationshipClass, +) +@ApiTags("datasets v4") +@Controller({ path: "datasets", version: "4" }) +export class DatasetsV4Controller { + constructor( + private datasetsService: DatasetsService, + private caslAbilityFactory: CaslAbilityFactory, + private logbooksService: LogbooksService, + ) {} + + async generateDatasetInstanceForPermissions( + dataset: DatasetClass | CreateDatasetDto, + ): Promise { + // NOTE: We need DatasetClass instance because casl module can not recognize the type from dataset mongo database model. If other fields are needed can be added later. + const datasetInstance = new DatasetClass(); + datasetInstance._id = ""; + datasetInstance.pid = dataset.pid || ""; + datasetInstance.accessGroups = dataset.accessGroups || []; + datasetInstance.ownerGroup = dataset.ownerGroup; + datasetInstance.sharedWith = dataset.sharedWith; + datasetInstance.isPublished = dataset.isPublished || false; + + return datasetInstance; + } + + async checkPermissionsForDatasetExtended( + request: Request, + datasetInput: CreateDatasetDto | string | null, + group: Action, + ) { + if (!datasetInput) { + throw new NotFoundException(`dataset: ${datasetInput} not found`); + } + + let dataset = null; + + if (typeof datasetInput === "string") { + dataset = await this.datasetsService.findOne({ + where: { pid: datasetInput }, + }); + + if (!dataset) { + throw new NotFoundException(`dataset: ${datasetInput} not found`); + } + } else { + dataset = datasetInput; + } + const user: JWTUser = request.user as JWTUser; + + const datasetInstance = + await this.generateDatasetInstanceForPermissions(dataset); + + const ability = this.caslAbilityFactory.datasetInstanceAccess(user); + + let canDoAction = false; + + if (group == Action.DatasetRead) { + canDoAction = + ability.can(Action.DatasetReadAny, DatasetClass) || + ability.can(Action.DatasetReadOneOwner, datasetInstance) || + ability.can(Action.DatasetReadOneAccess, datasetInstance) || + ability.can(Action.DatasetReadOnePublic, datasetInstance); + } else if (group == Action.DatasetCreate) { + canDoAction = + ability.can(Action.DatasetCreateAny, DatasetClass) || + ability.can(Action.DatasetCreateOwnerNoPid, datasetInstance) || + ability.can(Action.DatasetCreateOwnerWithPid, datasetInstance); + } else if (group == Action.DatasetUpdate) { + canDoAction = + ability.can(Action.DatasetUpdateAny, DatasetClass) || + ability.can(Action.DatasetUpdateOwner, datasetInstance); + } else if (group == Action.DatasetDelete) { + canDoAction = + ability.can(Action.DatasetDeleteAny, DatasetClass) || + ability.can(Action.DatasetDeleteOwner, datasetInstance); + } else if (group == Action.DatasetAttachmentRead) { + canDoAction = + ability.can(Action.DatasetAttachmentReadAny, DatasetClass) || + ability.can(Action.DatasetAttachmentReadOwner, datasetInstance) || + ability.can(Action.DatasetAttachmentReadAccess, datasetInstance) || + ability.can(Action.DatasetAttachmentReadPublic, datasetInstance); + } else if (group == Action.DatasetOrigdatablockRead) { + canDoAction = + ability.can(Action.DatasetOrigdatablockReadAny, DatasetClass) || + ability.can(Action.DatasetOrigdatablockReadOwner, datasetInstance) || + ability.can(Action.DatasetOrigdatablockReadAccess, datasetInstance) || + ability.can(Action.DatasetOrigdatablockReadPublic, datasetInstance); + } else if (group == Action.DatasetDatablockRead) { + canDoAction = + ability.can(Action.DatasetOrigdatablockReadAny, DatasetClass) || + ability.can(Action.DatasetDatablockReadOwner, datasetInstance) || + ability.can(Action.DatasetDatablockReadAccess, datasetInstance) || + ability.can(Action.DatasetDatablockReadPublic, datasetInstance); + } else if (group == Action.DatasetLogbookRead) { + canDoAction = + ability.can(Action.DatasetLogbookReadAny, DatasetClass) || + ability.can(Action.DatasetLogbookReadOwner, datasetInstance); + } + if (!canDoAction) { + throw new ForbiddenException("Unauthorized access"); + } + + return dataset; + } + + addAccessBasedFilters( + user: JWTUser, + filter: IDatasetFiltersV4, + ): IDatasetFiltersV4 { + const ability = this.caslAbilityFactory.datasetInstanceAccess(user); + const canViewAny = ability.can(Action.DatasetReadAny, DatasetClass); + const canViewOwner = ability.can(Action.DatasetReadManyOwner, DatasetClass); + const canViewAccess = ability.can( + Action.DatasetReadManyAccess, + DatasetClass, + ); + + if (!filter.where) { + filter.where = {}; + } + + if (!canViewAny) { + if (canViewAccess) { + if (filter.where["$and"]) { + filter.where["$and"].push({ + $or: [ + { ownerGroup: { $in: user.currentGroups } }, + { accessGroups: { $in: user.currentGroups } }, + { sharedWith: { $in: [user.email] } }, + { isPublished: true }, + ], + }); + } else { + filter.where["$and"] = [ + { + $or: [ + { ownerGroup: { $in: user.currentGroups } }, + { accessGroups: { $in: user.currentGroups } }, + { sharedWith: { $in: [user.email] } }, + { isPublished: true }, + ], + }, + ]; + } + } else if (canViewOwner) { + filter.where = { + ...filter.where, + ownerGroup: { $in: user.currentGroups }, + }; + } + } + + return filter; + } + + // POST /api/v4/datasets + @UseGuards(PoliciesGuard) + @CheckPolicies("datasets", (ability: AppAbility) => + ability.can(Action.DatasetCreate, DatasetClass), + ) + @UseInterceptors( + new UTCTimeInterceptor(["creationTime"]), + new UTCTimeInterceptor(["endTime"]), + new FormatPhysicalQuantitiesInterceptor("scientificMetadata"), + ) + @Post() + @ApiOperation({ + summary: + "It creates a new dataset. Type should be raw, derived or any of the customized types available in your instance", + description: + "It creates a new dataset and returns it completed with systems fields.", + }) + @ApiBody({ + description: "Input fields for the dataset to be created", + required: true, + type: CreateDatasetDto, + }) + @ApiResponse({ + status: HttpStatus.CREATED, + type: OutputDatasetDto, + description: "Create a new dataset and return its representation in SciCat", + }) + async create( + @Req() request: Request, + @Body(PidValidationPipe) + createDatasetDto: CreateDatasetDto, + ): Promise { + const datasetDto = await this.checkPermissionsForDatasetExtended( + request, + createDatasetDto, + Action.DatasetCreate, + ); + + try { + const createdDataset = await this.datasetsService.create(datasetDto); + + return createdDataset; + } catch (error) { + if ((error as MongoError).code === 11000) { + throw new ConflictException( + "A dataset with this this unique key already exists!", + ); + } else { + throw new InternalServerErrorException( + "Something went wrong. Please try again later.", + { cause: error }, + ); + } + } + } + + @UseGuards(PoliciesGuard) + @CheckPolicies("datasets", (ability: AppAbility) => + ability.can(Action.DatasetCreate, DatasetClass), + ) + @UseInterceptors( + new UTCTimeInterceptor(["creationTime"]), + new UTCTimeInterceptor(["endTime"]), + new FormatPhysicalQuantitiesInterceptor("scientificMetadata"), + ) + @HttpCode(HttpStatus.OK) + @Post("/isValid") + @ApiOperation({ + summary: "It validates the dataset provided as input.", + description: + "It validates the dataset provided as input, and returns true if the information is a valid dataset", + }) + @ApiBody({ + description: "Input fields for the dataset that needs to be validated", + required: true, + type: CreateDatasetDto, + }) + @ApiResponse({ + status: HttpStatus.OK, + type: IsValidResponse, + description: + "Check if the dataset provided pass validation. It return true if the validation is passed", + }) + async isValid( + @Req() request: Request, + @Body(PidValidationPipe) + createDatasetDto: object, + ) { + const createDatasetDtoInstance = plainToInstance( + CreateDatasetDto, + createDatasetDto, + ); + + const datasetDto = await this.checkPermissionsForDatasetExtended( + request, + createDatasetDtoInstance, + Action.DatasetCreate, + ); + + const errors = await validate(datasetDto); + + const valid = errors.length === 0; + + return { valid: valid }; + } + + // GET /datasets + @UseGuards(PoliciesGuard) + @CheckPolicies("datasets", (ability: AppAbility) => + ability.can(Action.DatasetRead, DatasetClass), + ) + @Get() + @ApiOperation({ + summary: "It returns a list of datasets.", + description: + "It returns a list of datasets. The list returned can be modified by providing a filter.", + }) + @ApiQuery({ + name: "filter", + description: "Database filters to apply when retrieving datasets", + required: false, + type: String, + content: getSwaggerDatasetFilterContent(), + }) + @ApiResponse({ + status: HttpStatus.OK, + type: OutputDatasetDto, + isArray: true, + description: "Return the datasets requested", + }) + async findAll( + @Req() request: Request, + @Query("filter", new FilterValidationPipe(), new IncludeValidationPipe()) + queryFilter: string, + ) { + const parsedFilter = JSON.parse(queryFilter ?? "{}"); + const mergedFilters = this.addAccessBasedFilters( + request.user as JWTUser, + parsedFilter, + ); + + const datasets = await this.datasetsService.findAllComplete(mergedFilters); + + return datasets; + } + + // GET /fullfacets + @UseGuards(PoliciesGuard) + @CheckPolicies("datasets", (ability: AppAbility) => + ability.can(Action.DatasetRead, DatasetClass), + ) + @UseInterceptors(SubDatasetsPublicInterceptor) + @Get("/fullfacet") + @ApiQuery({ + name: "filters", + description: + "Defines list of field names, for which facet counts should be calculated", + required: false, + type: FullFacetFilters, + example: + '{"facets": ["type","creationLocation","ownerGroup","keywords"], fields: {}}', + }) + @ApiResponse({ + status: HttpStatus.OK, + type: FullFacetResponse, + isArray: true, + description: "Return fullfacet response for datasets requested", + }) + async fullfacet( + @Req() request: Request, + @Query() filters: { fields?: string; facets?: string }, + ): Promise[]> { + const user: JWTUser = request.user as JWTUser; + const fields: IDatasetFields = JSON.parse(filters.fields ?? "{}"); + + const ability = this.caslAbilityFactory.datasetInstanceAccess(user); + const canViewAny = ability.can(Action.DatasetReadAny, DatasetClass); + + if (!canViewAny && !fields.isPublished) { + const canViewAccess = ability.can( + Action.DatasetReadManyAccess, + DatasetClass, + ); + const canViewOwner = ability.can( + Action.DatasetReadManyOwner, + DatasetClass, + ); + + if (canViewAccess) { + fields.userGroups = fields.userGroups ?? []; + fields.userGroups.push(...user.currentGroups); + } else if (canViewOwner) { + fields.ownerGroup = fields.ownerGroup ?? []; + fields.ownerGroup.push(...user.currentGroups); + } + } + + const parsedFilters: IFacets = { + fields: fields, + facets: JSON.parse(filters.facets ?? "[]"), + }; + + return this.datasetsService.fullFacet(parsedFilters); + } + + // GET /datasets/metadataKeys + @UseGuards(PoliciesGuard) + @CheckPolicies("datasets", (ability: AppAbility) => + ability.can(Action.DatasetRead, DatasetClass), + ) + @UseInterceptors(SubDatasetsPublicInterceptor) + @Get("/metadataKeys") + @ApiOperation({ + summary: + "It returns a list of metadata keys contained in the datasets matching the filter provided.", + description: + "It returns a list of metadata keys contained in the datasets matching the filter provided.
This endpoint still needs some work on the filter and facets specification.", + }) + @ApiQuery({ + name: "fields", + description: + "Define the filter conditions by specifying the name of values of fields requested. There is also support for a `text` search to look for strings anywhere in the dataset.", + required: false, + type: String, + example: {}, + }) + @ApiQuery({ + name: "limits", + description: "Define further query parameters like skip, limit, order", + required: false, + type: String, + example: '{ "skip": 0, "limit": 25, "order": "creationTime:desc" }', + }) + @ApiResponse({ + status: HttpStatus.OK, + type: String, + isArray: true, + description: "Return metadata keys for list of datasets selected", + }) + // NOTE: This one needs to be discussed as well but it gets the metadata keys from the dataset but it doesnt do it with the nested fields. Think about it + async metadataKeys( + @Req() request: Request, + @Query() filters: { fields?: string; limits?: string }, + ) { + const user: JWTUser = request.user as JWTUser; + const fields: IDatasetFields = JSON.parse(filters.fields ?? "{}"); + + const ability = this.caslAbilityFactory.datasetInstanceAccess(user); + const canViewAny = ability.can(Action.DatasetReadAny, DatasetClass); + + if (!canViewAny && !fields.isPublished) { + const canViewAccess = ability.can( + Action.DatasetReadManyAccess, + DatasetClass, + ); + const canViewOwner = ability.can( + Action.DatasetReadManyOwner, + DatasetClass, + ); + + if (canViewAccess) { + fields.userGroups?.push(...user.currentGroups); + } else if (canViewOwner) { + fields.ownerGroup?.push(...user.currentGroups); + } + } + + const parsedFilters: IFilters = { + fields: fields, + limits: JSON.parse(filters.limits ?? "{}"), + }; + return this.datasetsService.metadataKeys(parsedFilters); + } + + // GET /datasets/findOne + @UseGuards(PoliciesGuard) + @CheckPolicies("datasets", (ability: AppAbility) => + ability.can(Action.DatasetRead, DatasetClass), + ) + @Get("/findOne") + @ApiOperation({ + summary: "It returns the first dataset found.", + description: + "It returns the first dataset of the ones that matches the filter provided. The list returned can be modified by providing a filter.", + }) + @ApiQuery({ + name: "filter", + description: "Database filters to apply when retrieving dataset", + required: true, + type: String, + content: getSwaggerDatasetFilterContent({ + where: true, + include: true, + fields: true, + limits: true, + }), + }) + @ApiResponse({ + status: HttpStatus.OK, + type: OutputDatasetDto, + description: "Return the datasets requested", + }) + async findOne( + @Req() request: Request, + @Query("filter", new FilterValidationPipe(), new IncludeValidationPipe()) + queryFilter: string, + ): Promise { + const parsedFilter = JSON.parse(queryFilter ?? "{}"); + + const mergedFilters = this.addAccessBasedFilters( + request.user as JWTUser, + parsedFilter, + ); + + return this.datasetsService.findOneComplete(mergedFilters); + } + + // GET /datasets/count + @UseGuards(PoliciesGuard) + @CheckPolicies("datasets", (ability: AppAbility) => + ability.can(Action.DatasetRead, DatasetClass), + ) + @Get("/count") + @ApiOperation({ + summary: "It returns the number of datasets.", + description: + "It returns a number of datasets matching the where filter if provided.", + }) + @ApiQuery({ + name: "filter", + description: "Database filters to apply when retrieving count for datasets", + required: false, + type: String, + content: getSwaggerDatasetFilterContent({ + where: true, + include: false, + fields: false, + limits: false, + }), + }) + @ApiResponse({ + status: HttpStatus.OK, + type: CountApiResponse, + description: + "Return the number of datasets in the following format: { count: integer }", + }) + async count( + @Req() request: Request, + @Query( + "filter", + new FilterValidationPipe({ + where: true, + include: false, + fields: false, + limits: false, + }), + ) + queryFilter?: string, + ) { + const parsedFilter = JSON.parse(queryFilter ?? "{}"); + + const finalFilters = this.addAccessBasedFilters( + request.user as JWTUser, + parsedFilter, + ); + + return this.datasetsService.count(finalFilters); + } + + // GET /datasets/:id + //@UseGuards(PoliciesGuard) + @UseGuards(PoliciesGuard) + @CheckPolicies("datasets", (ability: AppAbility) => + ability.can(Action.DatasetRead, DatasetClass), + ) + @Get("/:pid") + @ApiParam({ + name: "pid", + description: "Id of the dataset to return", + type: String, + }) + @ApiResponse({ + status: HttpStatus.OK, + type: OutputDatasetDto, + isArray: false, + description: "Return dataset with pid specified", + }) + @ApiQuery({ + name: "include", + enum: DatasetLookupKeysEnum, + type: String, + required: false, + isArray: true, + }) + async findById( + @Req() request: Request, + @Param("pid") id: string, + @Query("include", new IncludeValidationPipe()) + include: DatasetLookupKeysEnum[] | DatasetLookupKeysEnum, + ) { + const includeArray = Array.isArray(include) + ? include + : include && Array(include); + + const dataset = await this.datasetsService.findOneComplete({ + where: { pid: id }, + include: includeArray, + }); + + await this.checkPermissionsForDatasetExtended( + request, + dataset, + Action.DatasetRead, + ); + + return dataset; + } + + // PATCH /datasets/:id + // body: modified fields + @UseGuards(PoliciesGuard) + @CheckPolicies("datasets", (ability: AppAbility) => + ability.can(Action.DatasetUpdate, DatasetClass), + ) + @UseInterceptors( + new UTCTimeInterceptor(["creationTime"]), + new UTCTimeInterceptor(["endTime"]), + new FormatPhysicalQuantitiesInterceptor("scientificMetadata"), + HistoryInterceptor, + ) + @Patch("/:pid") + @ApiOperation({ + summary: "It partially updates the dataset.", + description: + "It updates the dataset through the pid specified. It updates only the specified fields.", + }) + @ApiParam({ + name: "pid", + description: "Id of the dataset to modify", + type: String, + }) + @ApiBody({ + description: + "Fields that needs to be updated in the dataset. Only the fields that needs to be updated have to be passed in.", + required: true, + type: UpdateDatasetDto, + }) + @ApiResponse({ + status: HttpStatus.OK, + type: OutputDatasetDto, + description: + "Update an existing dataset and return its representation in SciCat", + }) + async findByIdAndUpdate( + @Req() request: Request, + @Param("pid") pid: string, + @Body() + updateDatasetDto: PartialUpdateDatasetDto, + ): Promise { + const foundDataset = await this.datasetsService.findOne({ + where: { pid }, + }); + + await this.checkPermissionsForDatasetExtended( + request, + foundDataset, + Action.DatasetUpdate, + ); + + const updatedDataset = await this.datasetsService.findByIdAndUpdate( + pid, + updateDatasetDto, + ); + + return updatedDataset; + } + + // PUT /datasets/:id + @UseGuards(PoliciesGuard) + @CheckPolicies("datasets", (ability: AppAbility) => + ability.can(Action.DatasetUpdate, DatasetClass), + ) + @UseInterceptors( + new UTCTimeInterceptor(["creationTime"]), + new UTCTimeInterceptor(["endTime"]), + new FormatPhysicalQuantitiesInterceptor("scientificMetadata"), + HistoryInterceptor, + ) + @Put("/:pid") + @ApiOperation({ + summary: "It updates the dataset.", + description: `It updates(replaces) the dataset specified through the pid provided. If optional fields are not provided they will be removed. + The PUT method is responsible for modifying an existing entity. The crucial part about it is that it is supposed to replace an entity. + Therefore, if we don’t send a field of an entity when performing a PUT request, the missing field should be removed from the document. + (Caution: This operation could result with data loss if all the dataset fields are not provided)`, + }) + @ApiParam({ + name: "pid", + description: "Id of the dataset to modify", + type: String, + }) + @ApiBody({ + description: + "Dataset object that needs to be updated. The whole dataset object with updated fields have to be passed in.", + required: true, + type: UpdateDatasetDto, + }) + @ApiResponse({ + status: HttpStatus.OK, + type: OutputDatasetDto, + description: + "Update an existing dataset and return its representation in SciCat", + }) + async findByIdAndReplace( + @Req() request: Request, + @Param("pid") pid: string, + @Body() + updateDatasetDto: UpdateDatasetDto, + ): Promise { + const foundDataset = await this.datasetsService.findOne({ + where: { pid }, + }); + + await this.checkPermissionsForDatasetExtended( + request, + foundDataset, + Action.DatasetUpdate, + ); + + const outputDatasetDto = await this.datasetsService.findByIdAndReplace( + pid, + updateDatasetDto, + ); + + return outputDatasetDto; + } + + // DELETE /datasets/:id + @UseGuards(PoliciesGuard) + @CheckPolicies("datasets", (ability: AppAbility) => + ability.can(Action.DatasetDelete, DatasetClass), + ) + @Delete("/:pid") + @ApiOperation({ + summary: "It deletes the dataset.", + description: "It delete the dataset specified through the pid specified.", + }) + @ApiParam({ + name: "pid", + description: "Id of the dataset to be deleted", + type: String, + }) + @ApiResponse({ + status: HttpStatus.OK, + type: OutputDatasetDto, + description: "DatasetClass value is returned that is removed", + }) + async findByIdAndDelete(@Req() request: Request, @Param("pid") pid: string) { + const foundDataset = await this.datasetsService.findOne({ + where: { pid }, + }); + + await this.checkPermissionsForDatasetExtended( + request, + foundDataset, + Action.DatasetDelete, + ); + + const removedDataset = await this.datasetsService.findByIdAndDelete(pid); + + return removedDataset; + } + + @UseGuards(PoliciesGuard) + @CheckPolicies("datasets", (ability: AppAbility) => + ability.can(Action.DatasetLogbookRead, DatasetClass), + ) + @Get("/:pid/logbook") + @ApiOperation({ + summary: "Retrive logbook associated with dataset.", + description: "It fetches specific logbook based on dataset pid.", + }) + @ApiParam({ + name: "pid", + description: + "Persistent identifier of the dataset for which we would like to delete the datablock specified", + type: String, + }) + @ApiResponse({ + status: HttpStatus.OK, + type: Logbook, + isArray: false, + description: "It returns all messages from specificied Logbook room", + }) + async findLogbookByPid( + @Req() request: Request, + @Param("pid") pid: string, + @Query("filters") filters: string, + ) { + const dataset = await this.checkPermissionsForDatasetExtended( + request, + pid, + Action.DatasetLogbookRead, + ); + + const proposalId = (dataset?.proposalIds || [])[0]; + + if (!proposalId) return null; + + const result = await this.logbooksService.findByName(proposalId, filters); + + return result; + } +} diff --git a/src/datasets/dto/create-dataset-obsolete.dto.ts b/src/datasets/dto/create-dataset-obsolete.dto.ts index a75fe5dfe..6e0af4223 100644 --- a/src/datasets/dto/create-dataset-obsolete.dto.ts +++ b/src/datasets/dto/create-dataset-obsolete.dto.ts @@ -1,7 +1,7 @@ import { IsEnum, IsOptional, IsString } from "class-validator"; import { ApiProperty } from "@nestjs/swagger"; -import { DatasetType } from "../dataset-type.enum"; import { UpdateDatasetObsoleteDto } from "./update-dataset-obsolete.dto"; +import { DatasetType } from "../types/dataset-type.enum"; export class CreateDatasetObsoleteDto extends UpdateDatasetObsoleteDto { @ApiProperty({ diff --git a/src/datasets/dto/create-derived-dataset-obsolete.dto.ts b/src/datasets/dto/create-derived-dataset-obsolete.dto.ts index 2432c379e..4aca47794 100644 --- a/src/datasets/dto/create-derived-dataset-obsolete.dto.ts +++ b/src/datasets/dto/create-derived-dataset-obsolete.dto.ts @@ -1,7 +1,7 @@ import { UpdateDerivedDatasetObsoleteDto } from "./update-derived-dataset-obsolete.dto"; import { ApiProperty } from "@nestjs/swagger"; import { IsEnum, IsOptional, IsString } from "class-validator"; -import { DatasetType } from "../dataset-type.enum"; +import { DatasetType } from "../types/dataset-type.enum"; export class CreateDerivedDatasetObsoleteDto extends UpdateDerivedDatasetObsoleteDto { @ApiProperty({ diff --git a/src/datasets/dto/create-raw-dataset-obsolete.dto.ts b/src/datasets/dto/create-raw-dataset-obsolete.dto.ts index 5de0df978..73c425093 100644 --- a/src/datasets/dto/create-raw-dataset-obsolete.dto.ts +++ b/src/datasets/dto/create-raw-dataset-obsolete.dto.ts @@ -1,7 +1,7 @@ import { UpdateRawDatasetObsoleteDto } from "./update-raw-dataset-obsolete.dto"; import { ApiProperty } from "@nestjs/swagger"; import { IsEnum, IsOptional, IsString } from "class-validator"; -import { DatasetType } from "../dataset-type.enum"; +import { DatasetType } from "../types/dataset-type.enum"; export class CreateRawDatasetObsoleteDto extends UpdateRawDatasetObsoleteDto { @ApiProperty({ diff --git a/src/datasets/dto/output-dataset-obsolete.dto.ts b/src/datasets/dto/output-dataset-obsolete.dto.ts index 68de0887e..5789ddda4 100644 --- a/src/datasets/dto/output-dataset-obsolete.dto.ts +++ b/src/datasets/dto/output-dataset-obsolete.dto.ts @@ -7,12 +7,12 @@ import { IsString, } from "class-validator"; import { ApiProperty, getSchemaPath } from "@nestjs/swagger"; -import { DatasetType } from "../dataset-type.enum"; import { UpdateDatasetObsoleteDto } from "./update-dataset-obsolete.dto"; import { Attachment } from "src/attachments/schemas/attachment.schema"; import { Type } from "class-transformer"; import { OrigDatablock } from "src/origdatablocks/schemas/origdatablock.schema"; import { Datablock } from "src/datablocks/schemas/datablock.schema"; +import { DatasetType } from "../types/dataset-type.enum"; export class OutputDatasetObsoleteDto extends UpdateDatasetObsoleteDto { @ApiProperty({ diff --git a/src/datasets/dto/output-dataset.dto.ts b/src/datasets/dto/output-dataset.dto.ts index 6c070a73c..aaaf618a4 100644 --- a/src/datasets/dto/output-dataset.dto.ts +++ b/src/datasets/dto/output-dataset.dto.ts @@ -38,4 +38,13 @@ export class OutputDatasetDto extends CreateDatasetDto { }) @IsDateString() updatedAt: Date; + + @ApiProperty({ + type: String, + required: true, + description: + "Version of the API used when the dataset was created or last updated. API version is defined in code for each release. Managed by the system.", + }) + @IsString() + version: string; } diff --git a/src/datasets/dto/update-dataset.dto.ts b/src/datasets/dto/update-dataset.dto.ts index 8e58a35fe..a2d59dcb6 100644 --- a/src/datasets/dto/update-dataset.dto.ts +++ b/src/datasets/dto/update-dataset.dto.ts @@ -153,8 +153,8 @@ export class UpdateDatasetDto extends OwnableDto { @ApiProperty({ type: String, - isArray: true, required: false, + isArray: true, description: "Array of tags associated with the meaning or contents of this dataset. Values should ideally come from defined vocabularies, taxonomies, ontologies or knowledge graphs.", }) @@ -213,9 +213,9 @@ export class UpdateDatasetDto extends OwnableDto { readonly isPublished?: boolean; @ApiProperty({ - type: "array", - items: { $ref: getSchemaPath(TechniqueClass) }, + type: TechniqueClass, required: false, + isArray: true, default: [], description: "Stores the metadata information for techniques.", }) @@ -228,8 +228,8 @@ export class UpdateDatasetDto extends OwnableDto { // it needs to be discussed if this fields is managed by the user or by the system @ApiProperty({ type: String, - isArray: true, required: false, + isArray: true, default: [], description: "List of users that the dataset has been shared with.", }) @@ -241,9 +241,9 @@ export class UpdateDatasetDto extends OwnableDto { // it needs to be discussed if this fields is managed by the user or by the system @ApiProperty({ - type: "array", - items: { $ref: getSchemaPath(RelationshipClass) }, + type: RelationshipClass, required: false, + isArray: true, default: [], description: "Stores the relationships with other datasets.", }) @@ -295,9 +295,8 @@ export class UpdateDatasetDto extends OwnableDto { @ApiProperty({ type: String, required: false, + description: "Array of first and last name of principal investigator(s).", isArray: true, - description: - "First and last name of principal investigator(s). Multiple PIs can be provided as separate strings in the array. This field is required if the dataset is a Raw dataset.", }) @IsOptional() @IsString({ each: true }) @@ -345,8 +344,8 @@ export class UpdateDatasetDto extends OwnableDto { @ApiProperty({ type: String, - isArray: true, required: false, + isArray: true, description: "ID of the proposal or proposals which the dataset belongs to.
This dataset might have been acquired under the listed proposals or is derived from datasets acquired from datasets belonging to the listed datasets.", }) @@ -358,8 +357,8 @@ export class UpdateDatasetDto extends OwnableDto { @ApiProperty({ type: String, - isArray: true, required: false, + isArray: true, description: "ID of the sample or samples used when collecting the data included or used in this dataset.", }) @@ -397,8 +396,8 @@ export class UpdateDatasetDto extends OwnableDto { @ApiProperty({ type: String, - isArray: true, required: false, + isArray: true, description: "A list of links to software repositories which uniquely identifies the pieces of software, including versions, used for yielding the derived data.", }) diff --git a/src/datasets/interfaces/dataset-filters.interface.ts b/src/datasets/interfaces/dataset-filters.interface.ts index a33dc1df4..9628aaa01 100644 --- a/src/datasets/interfaces/dataset-filters.interface.ts +++ b/src/datasets/interfaces/dataset-filters.interface.ts @@ -1,4 +1,9 @@ -import { IScientificFilter } from "src/common/interfaces/common.interface"; +import { FilterQuery } from "mongoose"; +import { + ILimitsFilter, + IScientificFilter, +} from "src/common/interfaces/common.interface"; +import { DatasetLookupKeysEnum } from "../types/dataset-lookup"; export interface IDatasetFields { mode?: Record; @@ -20,3 +25,10 @@ export interface IDatasetFields { sharedWith?: string[]; [key: string]: unknown; } + +export interface IDatasetFiltersV4 { + where?: FilterQuery; + include?: DatasetLookupKeysEnum[]; + fields?: Y; + limits?: ILimitsFilter; +} diff --git a/src/datasets/pipes/filter-validation.pipe.ts b/src/datasets/pipes/filter-validation.pipe.ts new file mode 100644 index 000000000..a19fb0dc3 --- /dev/null +++ b/src/datasets/pipes/filter-validation.pipe.ts @@ -0,0 +1,75 @@ +import { PipeTransform, Injectable } from "@nestjs/common"; +import { BadRequestException } from "@nestjs/common/exceptions"; +import { flattenObject } from "src/common/utils"; +import { OutputDatasetDto } from "src/datasets/dto/output-dataset.dto"; + +// Dataset specific keys that are allowed +const ALLOWED_DATASET_KEYS = Object.keys(new OutputDatasetDto()); + +// Allowed keys taken from mongoose QuerySelector. +const ALLOWED_FILTER_KEYS: Record = { + where: [ + "where", + "$in", + "$or", + "$and", + "$nor", + "$match", + "$eq", + "$gt", + "$gte", + "$lt", + "$lte", + "$ne", + "$nin", + "$not", + "$exists", + "$regex", + "$options", + ], + include: ["include"], + limits: ["limits", "limit", "skip", "sort"], + fields: ["fields"], +}; + +@Injectable() +export class FilterValidationPipe implements PipeTransform { + constructor( + private filters: Record = { + where: true, + include: true, + fields: true, + limits: true, + }, + ) {} + transform(inValue: string): string { + const allAllowedKeys: string[] = [...ALLOWED_DATASET_KEYS]; + for (const key in this.filters) { + if (this.filters[key]) { + allAllowedKeys.push(...ALLOWED_FILTER_KEYS[key]); + } + } + const inValueParsed = JSON.parse(inValue ?? "{}"); + const flattenFilterKeys = Object.keys(flattenObject(inValueParsed)); + + /* + * intercept filter and make sure we only allow accepted values + */ + flattenFilterKeys.forEach((key) => { + const keyParts = key.split("."); + const isInAllowedKeys = keyParts.every((part) => + allAllowedKeys.includes(part), + ); + + if (!isInAllowedKeys) { + // TODO: Should we clean the filter or throw bad request error???!!! + // unset(inValueParsed, key); + throw new BadRequestException( + `Property ${key} should not exist in the filter object`, + ); + } + }); + + return JSON.stringify(inValueParsed); + } +} diff --git a/src/datasets/pipes/include-validation.pipe.ts b/src/datasets/pipes/include-validation.pipe.ts new file mode 100644 index 000000000..a2b481ef7 --- /dev/null +++ b/src/datasets/pipes/include-validation.pipe.ts @@ -0,0 +1,34 @@ +import { PipeTransform, Injectable } from "@nestjs/common"; +import { BadRequestException } from "@nestjs/common/exceptions"; +import { isJsonString } from "src/common/utils"; +import { DATASET_LOOKUP_FIELDS } from "src/datasets/types/dataset-lookup"; + +@Injectable() +export class IncludeValidationPipe + implements PipeTransform +{ + transform(inValue: string | string[]): string[] | string { + if (!inValue) { + return inValue; + } + + const isArray = Array.isArray(inValue); + const includeValueParsed: string[] = isArray + ? inValue + : isJsonString(inValue) + ? JSON.parse(inValue ?? "{}").include + : Array(inValue); + + includeValueParsed?.map((field) => { + if (Object.keys(DATASET_LOOKUP_FIELDS).includes(field)) { + return field; + } else { + throw new BadRequestException( + `Provided include field ${JSON.stringify(field)} is not part of the dataset relations`, + ); + } + }); + + return inValue; + } +} diff --git a/src/datasets/pipes/pid-validation.pipe.ts b/src/datasets/pipes/pid-validation.pipe.ts new file mode 100644 index 000000000..ebfd7ddcf --- /dev/null +++ b/src/datasets/pipes/pid-validation.pipe.ts @@ -0,0 +1,38 @@ +import { PipeTransform, Injectable } from "@nestjs/common"; +import { BadRequestException } from "@nestjs/common/exceptions"; +import { ConfigService } from "@nestjs/config"; +import { CreateDatasetDto } from "../dto/create-dataset.dto"; + +@Injectable() +export class PidValidationPipe + implements PipeTransform +{ + constructor(private configService: ConfigService) { + this.datasetCreationValidationEnabled = this.configService.get( + "datasetCreationValidationEnabled", + ); + this.datasetCreationValidationRegex = this.configService.get( + "datasetCreationValidationRegex", + ); + } + + private datasetCreationValidationEnabled; + private datasetCreationValidationRegex; + + transform(dataset: CreateDatasetDto): CreateDatasetDto { + if ( + this.datasetCreationValidationEnabled && + this.datasetCreationValidationRegex && + dataset.pid + ) { + const re = new RegExp(this.datasetCreationValidationRegex); + if (!re.test(dataset.pid)) { + throw new BadRequestException( + "PID is not following required standards", + ); + } + } + + return dataset; + } +} diff --git a/src/datasets/types/dataset-filter-content.ts b/src/datasets/types/dataset-filter-content.ts new file mode 100644 index 000000000..904bde038 --- /dev/null +++ b/src/datasets/types/dataset-filter-content.ts @@ -0,0 +1,87 @@ +import { + ContentObject, + SchemaObject, +} from "@nestjs/swagger/dist/interfaces/open-api-spec.interface"; +import { boolean } from "mathjs"; + +const FILTERS: Record<"limits" | "fields" | "where" | "include", object> = { + where: { + type: "object", + example: { + datasetName: { $regex: "Dataset", $options: "i" }, + }, + }, + include: { + type: "array", + items: { + type: "string", + example: "attachments", + }, + }, + fields: { + type: "array", + items: { + type: "string", + example: "datasetName", + }, + }, + limits: { + type: "object", + properties: { + limit: { + type: "number", + example: 10, + }, + skip: { + type: "number", + example: 0, + }, + sort: { + type: "object", + properties: { + datasetName: { + type: "string", + example: "asc | desc", + }, + }, + }, + }, + }, +}; + +/** + * NOTE: This is disabled only for the official sdk package generation as the schema validation complains about the content field. + * But we want to have it when we run the application as it improves swagger documentation and usage a lot. + * We use "content" property as it is described in the swagger specification: https://swagger.io/docs/specification/v3_0/describing-parameters/#schema-vs-content:~:text=explode%3A%20false-,content,-is%20used%20in + */ +export const getSwaggerDatasetFilterContent = ( + filtersToInclude: Record = { + where: true, + include: true, + fields: true, + limits: true, + }, +): ContentObject | undefined => { + if (boolean(process.env.SDK_PACKAGE_SWAGGER_HELPERS_DISABLED ?? false)) { + return undefined; + } + + const filterContent: Record = { + "application/json": { + schema: { + type: "object", + properties: {}, + }, + }, + }; + + for (const filtersKey in filtersToInclude) { + const key = filtersKey as keyof typeof FILTERS; + + if (filtersToInclude[key] && FILTERS[key]) { + filterContent["application/json"].schema.properties![key] = FILTERS[key]; + } + } + + return filterContent; +}; diff --git a/src/datasets/types/dataset-lookup.ts b/src/datasets/types/dataset-lookup.ts new file mode 100644 index 000000000..30dcea19d --- /dev/null +++ b/src/datasets/types/dataset-lookup.ts @@ -0,0 +1,66 @@ +import { PipelineStage } from "mongoose"; + +export enum DatasetLookupKeysEnum { + instruments = "instruments", + proposals = "proposals", + origdatablocks = "origdatablocks", + datablocks = "datablocks", + attachments = "attachments", + samples = "samples", + all = "all", +} + +export const DATASET_LOOKUP_FIELDS: Record< + DatasetLookupKeysEnum, + PipelineStage.Lookup | undefined +> = { + instruments: { + $lookup: { + from: "Instrument", + localField: "instrumentIds", + foreignField: "pid", + as: "", + }, + }, + proposals: { + $lookup: { + from: "Proposal", + localField: "proposalIds", + foreignField: "proposalId", + as: "", + }, + }, + origdatablocks: { + $lookup: { + from: "OrigDatablock", + localField: "pid", + foreignField: "datasetId", + as: "", + }, + }, + datablocks: { + $lookup: { + from: "Datablock", + localField: "pid", + foreignField: "datasetId", + as: "", + }, + }, + attachments: { + $lookup: { + from: "Attachment", + localField: "pid", + foreignField: "datasetId", + as: "", + }, + }, + samples: { + $lookup: { + from: "Sample", + localField: "sampleIds", + foreignField: "sampleId", + as: "", + }, + }, + all: undefined, +}; diff --git a/src/datasets/dataset-type.enum.ts b/src/datasets/types/dataset-type.enum.ts similarity index 100% rename from src/datasets/dataset-type.enum.ts rename to src/datasets/types/dataset-type.enum.ts diff --git a/src/elastic-search/elastic-search.module.ts b/src/elastic-search/elastic-search.module.ts index 76927b28e..09313fa44 100644 --- a/src/elastic-search/elastic-search.module.ts +++ b/src/elastic-search/elastic-search.module.ts @@ -1,5 +1,4 @@ import { Module, forwardRef } from "@nestjs/common"; -import { ConfigModule, ConfigService } from "@nestjs/config"; import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; import { DatasetsModule } from "src/datasets/datasets.module"; import { ElasticSearchServiceController } from "./elastic-search.controller"; @@ -7,14 +6,9 @@ import { ElasticSearchService } from "./elastic-search.service"; import { SearchQueryService } from "./providers/query-builder.service"; @Module({ - imports: [forwardRef(() => DatasetsModule), ConfigModule], + imports: [forwardRef(() => DatasetsModule)], controllers: [ElasticSearchServiceController], - providers: [ - ElasticSearchService, - SearchQueryService, - ConfigService, - CaslAbilityFactory, - ], + providers: [ElasticSearchService, SearchQueryService, CaslAbilityFactory], exports: [ElasticSearchService, SearchQueryService], }) export class ElasticSearchModule {} diff --git a/src/health/health.module.ts b/src/health/health.module.ts index 594830def..086f689ae 100644 --- a/src/health/health.module.ts +++ b/src/health/health.module.ts @@ -2,13 +2,12 @@ import { Module } from "@nestjs/common"; import { TerminusModule } from "@nestjs/terminus"; import { HealthController } from "./health.controller"; import { MongooseModule } from "@nestjs/mongoose"; -import { ConfigModule, ConfigService } from "@nestjs/config"; +import { ConfigService } from "@nestjs/config"; @Module({ imports: [ TerminusModule, MongooseModule.forRootAsync({ - imports: [ConfigModule], useFactory: async (configService: ConfigService) => ({ uri: configService.get("mongodbUri"), }), diff --git a/src/instruments/instruments.controller.spec.ts b/src/instruments/instruments.controller.spec.ts index dac0c62f8..c8e5a9c81 100644 --- a/src/instruments/instruments.controller.spec.ts +++ b/src/instruments/instruments.controller.spec.ts @@ -1,3 +1,4 @@ +import { ConfigService } from "@nestjs/config"; import { Test, TestingModule } from "@nestjs/testing"; import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; import { InstrumentsController } from "./instruments.controller"; @@ -14,6 +15,7 @@ describe("InstrumentsController", () => { providers: [ CaslAbilityFactory, { provide: InstrumentsService, useClass: InstrumentsServiceMock }, + ConfigService, ], }).compile(); diff --git a/src/instruments/instruments.controller.ts b/src/instruments/instruments.controller.ts index 7ee9a6216..593c29ee8 100644 --- a/src/instruments/instruments.controller.ts +++ b/src/instruments/instruments.controller.ts @@ -9,9 +9,10 @@ import { UseGuards, Query, UseInterceptors, - HttpException, - HttpStatus, + InternalServerErrorException, + ConflictException, } from "@nestjs/common"; +import { MongoError } from "mongodb"; import { InstrumentsService } from "./instruments.service"; import { CreateInstrumentDto } from "./dto/create-instrument.dto"; import { PartialUpdateInstrumentDto } from "./dto/update-instrument.dto"; @@ -57,14 +58,16 @@ export class InstrumentsController { await this.instrumentsService.create(createInstrumentDto); return instrument; } catch (error) { - let message; - if (error instanceof Error) message = error.message; - else message = String(error); - // we'll proceed, but let's report it - throw new HttpException( - `Instrument with the same unique name already exists: ${message}`, - HttpStatus.BAD_REQUEST, - ); + if ((error as MongoError).code === 11000) { + throw new ConflictException( + "Instrument with the same unique name already exists", + ); + } else { + throw new InternalServerErrorException( + "Something went wrong. Please try again later.", + { cause: error }, + ); + } } } @@ -142,12 +145,19 @@ export class InstrumentsController { { _id: id }, updateInstrumentDto, ); + return instrument; - } catch (e) { - throw new HttpException( - "Instrument with the same unique name already exists", - HttpStatus.BAD_REQUEST, - ); + } catch (error) { + if ((error as MongoError).code === 11000) { + throw new ConflictException( + "Instrument with the same unique name already exists", + ); + } else { + throw new InternalServerErrorException( + "Something went wrong. Please try again later.", + { cause: error }, + ); + } } } diff --git a/src/jobs/jobs.controller.spec.ts b/src/jobs/jobs.controller.spec.ts index cc293eecd..051883974 100644 --- a/src/jobs/jobs.controller.spec.ts +++ b/src/jobs/jobs.controller.spec.ts @@ -1,3 +1,4 @@ +import { ConfigService } from "@nestjs/config"; import { EventEmitter2 } from "@nestjs/event-emitter"; import { Test, TestingModule } from "@nestjs/testing"; import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; @@ -22,6 +23,7 @@ describe("JobsController", () => { { provide: DatasetsService, useClass: DatasetsServiceMock }, { provide: OrigDatablocksService, useClass: OrigDatablocksServiceMock }, { provide: EventEmitter2, useClass: EventEmitter2 }, + ConfigService, ], }).compile(); diff --git a/src/jobs/jobs.controller.ts b/src/jobs/jobs.controller.ts index 838fc724c..e8abd3ec3 100644 --- a/src/jobs/jobs.controller.ts +++ b/src/jobs/jobs.controller.ts @@ -26,10 +26,10 @@ import { ApiBearerAuth, ApiQuery, ApiResponse, ApiTags } from "@nestjs/swagger"; import { IFacets, IFilters } from "src/common/interfaces/common.interface"; import { DatasetsService } from "src/datasets/datasets.service"; import { JobType, DatasetState } from "./job-type.enum"; -import configuration from "src/config/configuration"; import { EventEmitter2 } from "@nestjs/event-emitter"; import { OrigDatablocksService } from "src/origdatablocks/origdatablocks.service"; import { AllowAny } from "src/auth/decorators/allow-any.decorator"; +import { ConfigService } from "@nestjs/config"; @ApiBearerAuth() @ApiTags("jobs") @@ -40,10 +40,11 @@ export class JobsController { private readonly datasetsService: DatasetsService, private readonly origDatablocksService: OrigDatablocksService, private eventEmitter: EventEmitter2, + private congigService: ConfigService, ) {} publishJob() { - if (configuration().rabbitMq.enabled) { + if (this.congigService.get("rabbitMq").enabled) { // TODO: This should publish the job to the message broker. // job.publishJob(ctx.instance, "jobqueue"); console.log("Saved Job %s#%s and published to message broker"); diff --git a/src/jobs/jobs.module.ts b/src/jobs/jobs.module.ts index a7de2bf1d..52bbceb1c 100644 --- a/src/jobs/jobs.module.ts +++ b/src/jobs/jobs.module.ts @@ -7,14 +7,12 @@ import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; import { DatasetsModule } from "src/datasets/datasets.module"; import { PoliciesModule } from "src/policies/policies.module"; import { CommonModule } from "src/common/common.module"; -import { ConfigModule } from "@nestjs/config"; import { OrigDatablocksModule } from "src/origdatablocks/origdatablocks.module"; @Module({ controllers: [JobsController], imports: [ CommonModule, - ConfigModule, DatasetsModule, MongooseModule.forFeature([ { diff --git a/src/logbooks/logbooks.module.ts b/src/logbooks/logbooks.module.ts index 38e735fbd..08fe140e7 100644 --- a/src/logbooks/logbooks.module.ts +++ b/src/logbooks/logbooks.module.ts @@ -1,16 +1,14 @@ import { Module } from "@nestjs/common"; import { LogbooksService } from "./logbooks.service"; import { LogbooksController } from "./logbooks.controller"; -import { ConfigModule, ConfigService } from "@nestjs/config"; +import { ConfigService } from "@nestjs/config"; import { HttpModule } from "@nestjs/axios"; import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; import { ProposalsModule } from "src/proposals/proposals.module"; @Module({ imports: [ - ConfigModule, HttpModule.registerAsync({ - imports: [ConfigModule], useFactory: async (configService: ConfigService) => ({ timeout: configService.get("httpTimeOut"), maxRedirects: configService.get("httpMaxRedirects"), diff --git a/src/loggers/logger.module.ts b/src/loggers/logger.module.ts index 9704c367f..634b291a4 100644 --- a/src/loggers/logger.module.ts +++ b/src/loggers/logger.module.ts @@ -1,9 +1,7 @@ import { Module } from "@nestjs/common"; -import { ConfigModule } from "@nestjs/config"; import { ScicatLogger } from "./logger.service"; @Module({ - imports: [ConfigModule], providers: [ScicatLogger], exports: [ScicatLogger], }) diff --git a/src/origdatablocks/dto/update-origdatablock.dto.ts b/src/origdatablocks/dto/update-origdatablock.dto.ts index b5691233b..560e335a6 100644 --- a/src/origdatablocks/dto/update-origdatablock.dto.ts +++ b/src/origdatablocks/dto/update-origdatablock.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty, getSchemaPath, PartialType } from "@nestjs/swagger"; +import { ApiProperty, PartialType } from "@nestjs/swagger"; import { OwnableDto } from "../../common/dto/ownable.dto"; import { ArrayNotEmpty, @@ -34,8 +34,8 @@ export class UpdateOrigDatablockDto extends OwnableDto { readonly chkAlg: string; @ApiProperty({ - type: "array", - items: { $ref: getSchemaPath(DataFile) }, + type: DataFile, + isArray: true, required: true, description: "List of the files contained in this orig datablock.", }) diff --git a/src/origdatablocks/origdatablocks.controller.ts b/src/origdatablocks/origdatablocks.controller.ts index 66b0e5777..ccce96c80 100644 --- a/src/origdatablocks/origdatablocks.controller.ts +++ b/src/origdatablocks/origdatablocks.controller.ts @@ -1,4 +1,3 @@ -/* eslint-disable @/quotes */ import { Controller, Get, diff --git a/src/policies/policies.controller.spec.ts b/src/policies/policies.controller.spec.ts index d8b261922..87a704fda 100644 --- a/src/policies/policies.controller.spec.ts +++ b/src/policies/policies.controller.spec.ts @@ -1,3 +1,4 @@ +import { ConfigService } from "@nestjs/config"; import { Test, TestingModule } from "@nestjs/testing"; import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; import { DatasetsService } from "src/datasets/datasets.service"; @@ -18,6 +19,7 @@ describe("PoliciesController", () => { CaslAbilityFactory, { provide: PoliciesService, useClass: PoliciesServiceMock }, { provide: DatasetsService, useClass: DatasetsServiceMock }, + ConfigService, ], }).compile(); diff --git a/src/policies/policies.controller.ts b/src/policies/policies.controller.ts index 18ed87d7a..2adf34d80 100644 --- a/src/policies/policies.controller.ts +++ b/src/policies/policies.controller.ts @@ -1,4 +1,3 @@ -/* eslint-disable @/quotes */ import { Controller, Get, @@ -32,8 +31,9 @@ import { HistoryInterceptor } from "src/common/interceptors/history.interceptor" import { UpdateWherePolicyDto } from "./dto/update-where-policy.dto"; import { IFilters } from "src/common/interfaces/common.interface"; import { JWTUser } from "src/auth/interfaces/jwt-user.interface"; -import { CountApiResponse, replaceLikeOperator } from "src/common/utils"; +import { replaceLikeOperator } from "src/common/utils"; import { FilterPipe } from "src/common/pipes/filter.pipe"; +import { CountApiResponse } from "src/common/types"; @ApiBearerAuth() @ApiTags("policies") diff --git a/src/policies/policies.module.ts b/src/policies/policies.module.ts index 0782391fe..55bae398b 100644 --- a/src/policies/policies.module.ts +++ b/src/policies/policies.module.ts @@ -1,7 +1,6 @@ import { forwardRef, Module } from "@nestjs/common"; import { PoliciesService } from "./policies.service"; import { PoliciesController } from "./policies.controller"; -import { ConfigModule } from "@nestjs/config"; import { MongooseModule } from "@nestjs/mongoose"; import { Policy, PolicySchema } from "./schemas/policy.schema"; import { AuthModule } from "src/auth/auth.module"; @@ -13,7 +12,6 @@ import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; controllers: [PoliciesController], imports: [ AuthModule, - ConfigModule, forwardRef(() => DatasetsModule), MongooseModule.forFeature([ { diff --git a/src/policies/policies.service.ts b/src/policies/policies.service.ts index 4896c9cb4..ddc862e47 100644 --- a/src/policies/policies.service.ts +++ b/src/policies/policies.service.ts @@ -157,7 +157,7 @@ export class PoliciesService implements OnModuleInit { const ownerGroups = ownerGroupList .split(",") - // eslint-disable-next-line @/quotes + .map((ownerGroup) => ownerGroup.trim().replace(new RegExp('"', "g"), "")); if (!ownerGroups) { throw new InternalServerErrorException( @@ -179,7 +179,7 @@ export class PoliciesService implements OnModuleInit { try { await this.addDefaultPolicy(ownerGroup, [], email, "low"); } catch (error) { - throw new InternalServerErrorException(); + throw new InternalServerErrorException(error); } if (!userIdentity) { @@ -189,7 +189,7 @@ export class PoliciesService implements OnModuleInit { .updateOne({ ownerGroup }, data, {}) .exec(); } catch (error) { - throw new InternalServerErrorException(); + throw new InternalServerErrorException(error); } } else { const hasPermission = await this.validatePermission( @@ -208,7 +208,7 @@ export class PoliciesService implements OnModuleInit { .updateOne({ ownerGroup }, data, {}) .exec(); } catch (error) { - throw new InternalServerErrorException(); + throw new InternalServerErrorException(error); } } }), diff --git a/src/proposals/proposals.controller.ts b/src/proposals/proposals.controller.ts index d95cb1e03..494bc60d9 100644 --- a/src/proposals/proposals.controller.ts +++ b/src/proposals/proposals.controller.ts @@ -54,19 +54,21 @@ import { import { plainToInstance } from "class-transformer"; import { validate, ValidatorOptions } from "class-validator"; import { - CountApiResponse, filterDescription, filterExample, - FullFacetFilters, - FullFacetResponse, fullQueryExampleLimits, - FullQueryFilters, proposalsFullQueryDescriptionFields, proposalsFullQueryExampleFields, } from "src/common/utils"; import { JWTUser } from "src/auth/interfaces/jwt-user.interface"; import { IDatasetFields } from "src/datasets/interfaces/dataset-filters.interface"; import { FindByIdAccessResponse } from "src/samples/samples.controller"; +import { + FullFacetResponse, + FullQueryFilters, + CountApiResponse, + FullFacetFilters, +} from "src/common/types"; @ApiBearerAuth() @ApiTags("proposals") diff --git a/src/proposals/proposals.module.ts b/src/proposals/proposals.module.ts index c684c2e87..7d98b9147 100644 --- a/src/proposals/proposals.module.ts +++ b/src/proposals/proposals.module.ts @@ -6,7 +6,7 @@ import { ProposalClass, ProposalSchema } from "./schemas/proposal.schema"; import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; import { AttachmentsModule } from "src/attachments/attachments.module"; import { DatasetsModule } from "src/datasets/datasets.module"; -import { ConfigModule, ConfigService } from "@nestjs/config"; +import { ConfigService } from "@nestjs/config"; @Module({ imports: [ @@ -15,7 +15,6 @@ import { ConfigModule, ConfigService } from "@nestjs/config"; MongooseModule.forFeatureAsync([ { name: ProposalClass.name, - imports: [ConfigModule], inject: [ConfigService], useFactory: async (configService: ConfigService) => { const proposalTypes = configService.get("proposalTypes") || "{}"; diff --git a/src/published-data/published-data.module.ts b/src/published-data/published-data.module.ts index bfb289cfd..12fd1ec10 100644 --- a/src/published-data/published-data.module.ts +++ b/src/published-data/published-data.module.ts @@ -10,16 +10,14 @@ import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; import { AttachmentsModule } from "src/attachments/attachments.module"; import { DatasetsModule } from "src/datasets/datasets.module"; import { ProposalsModule } from "src/proposals/proposals.module"; -import { ConfigModule, ConfigService } from "@nestjs/config"; +import { ConfigService } from "@nestjs/config"; import { HttpModule } from "@nestjs/axios"; @Module({ imports: [ AttachmentsModule, - ConfigModule, DatasetsModule, HttpModule.registerAsync({ - imports: [ConfigModule], useFactory: async (configService: ConfigService) => ({ timeout: configService.get("httpTimeOut"), maxRedirects: configService.get("httpMaxRedirects"), diff --git a/src/samples/samples.controller.ts b/src/samples/samples.controller.ts index 807b78438..a5b236a14 100644 --- a/src/samples/samples.controller.ts +++ b/src/samples/samples.controller.ts @@ -57,7 +57,6 @@ import { filterDescription, filterExample, fullQueryExampleLimits, - FullQueryFilters, samplesFullQueryExampleFields, } from "src/common/utils"; import { Request } from "express"; @@ -65,6 +64,7 @@ import { JWTUser } from "src/auth/interfaces/jwt-user.interface"; import { IDatasetFields } from "src/datasets/interfaces/dataset-filters.interface"; import { CreateSubAttachmentDto } from "src/attachments/dto/create-sub-attachment.dto"; import { AuthenticatedPoliciesGuard } from "src/casl/guards/auth-check.guard"; +import { FullQueryFilters } from "src/common/types"; export class FindByIdAccessResponse { @ApiProperty({ type: Boolean }) diff --git a/src/samples/samples.module.ts b/src/samples/samples.module.ts index bc0d64b30..105793ac3 100644 --- a/src/samples/samples.module.ts +++ b/src/samples/samples.module.ts @@ -6,12 +6,10 @@ import { DatasetsModule } from "src/datasets/datasets.module"; import { MongooseModule } from "@nestjs/mongoose"; import { SampleClass, SampleSchema } from "./schemas/sample.schema"; import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; -import { ConfigModule } from "@nestjs/config"; @Module({ imports: [ AttachmentsModule, - ConfigModule, DatasetsModule, MongooseModule.forFeatureAsync([ { diff --git a/src/users/user-identities.controller.ts b/src/users/user-identities.controller.ts index bf4492457..43d24ae46 100644 --- a/src/users/user-identities.controller.ts +++ b/src/users/user-identities.controller.ts @@ -25,7 +25,6 @@ import { Request } from "express"; import { JWTUser } from "src/auth/interfaces/jwt-user.interface"; import { User } from "./schemas/user.schema"; import { AuthenticatedPoliciesGuard } from "../casl/guards/auth-check.guard"; -import { boolean } from "mathjs"; import { filterUserIdentityDescription, filterUserIdentityExample, @@ -138,7 +137,7 @@ export class UserIdentitiesController { }) @ApiResponse({ status: 201, - type: boolean, + type: Boolean, description: "Results is true if a registered user exists that have the emailed provided listed as main email", }) diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 2ebc765b6..cc8c4ed7d 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -12,7 +12,7 @@ import { Role, RoleSchema } from "./schemas/role.schema"; import { UserRole, UserRoleSchema } from "./schemas/user-role.schema"; import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; import { JwtModule } from "@nestjs/jwt"; -import { ConfigModule, ConfigService } from "@nestjs/config"; +import { ConfigService } from "@nestjs/config"; import { UserSettings, UserSettingsSchema, @@ -25,14 +25,12 @@ import { accessGroupServiceFactory } from "src/auth/access-group-provider/access @Module({ imports: [ JwtModule.registerAsync({ - imports: [ConfigModule], useFactory: async (configService: ConfigService) => ({ secret: configService.get("jwt.secret"), signOptions: { expiresIn: configService.get("jwt.expiresIn") }, }), inject: [ConfigService], }), - ConfigModule, MongooseModule.forFeature([ { name: UserIdentity.name, diff --git a/src/users/users.service.spec.ts b/src/users/users.service.spec.ts index d69ca6012..0fdd964c5 100644 --- a/src/users/users.service.spec.ts +++ b/src/users/users.service.spec.ts @@ -1,4 +1,3 @@ -import { ConfigModule } from "@nestjs/config"; import { getModelToken } from "@nestjs/mongoose"; import { Test, TestingModule } from "@nestjs/testing"; import { Model } from "mongoose"; @@ -10,6 +9,7 @@ import { JwtService } from "@nestjs/jwt"; import { UserSettings } from "./schemas/user-settings.schema"; import { AccessGroupService } from "src/auth/access-group-provider/access-group.service"; import { AccessGroupFromStaticValuesService } from "src/auth/access-group-provider/access-group-from-static-values.service"; +import { ConfigService } from "@nestjs/config"; class RolesServiceMock {} class JwtServiceMock {} @@ -95,7 +95,6 @@ describe("UsersService", () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - imports: [ConfigModule], providers: [ { provide: RolesService, useClass: RolesServiceMock }, { provide: JwtService, useClass: JwtServiceMock }, @@ -130,6 +129,7 @@ describe("UsersService", () => { }, }, UsersService, + ConfigService, { provide: AccessGroupService, useValue: () => diff --git a/test/DatasetV4.js b/test/DatasetV4.js new file mode 100644 index 000000000..78b21a110 --- /dev/null +++ b/test/DatasetV4.js @@ -0,0 +1,985 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +"use strict"; + +const utils = require("./LoginUtils"); +const { TestData } = require("./TestData"); +const { v4: uuidv4 } = require("uuid"); + +let accessTokenAdminIngestor = null; +let accessTokenArchiveManager = null; +let accessTokenUser1 = null; +let accessTokenUser2 = null; +let derivedDatasetMinPid = null; + +describe("2500: Datasets v4 tests", () => { + before(async () => { + db.collection("Dataset").deleteMany({}); + + accessTokenAdminIngestor = await utils.getToken(appUrl, { + username: "adminIngestor", + password: TestData.Accounts["adminIngestor"]["password"], + }); + + accessTokenUser1 = await utils.getToken(appUrl, { + username: "user1", + password: TestData.Accounts["user1"]["password"], + }); + + accessTokenUser2 = await utils.getToken(appUrl, { + username: "user2", + password: TestData.Accounts["user2"]["password"], + }); + + accessTokenArchiveManager = await utils.getToken(appUrl, { + username: "archiveManager", + password: TestData.Accounts["archiveManager"]["password"], + }); + }); + + async function deleteDataset(item) { + const response = await request(appUrl) + .delete("/api/v4/datasets/" + encodeURIComponent(item.pid)) + .auth(accessTokenArchiveManager, { type: "bearer" }) + .expect(TestData.SuccessfulDeleteStatusCode); + + return response; + } + + async function processArray(array) { + for (const item of array) { + await deleteDataset(item); + } + } + + describe("Datasets validation tests", () => { + it("0100: should not be able to validate dataset if not logged in", async () => { + return request(appUrl) + .post("/api/v4/datasets/isValid") + .send(TestData.DerivedCorrectMinV4) + .expect(TestData.AccessForbiddenStatusCode) + .expect("Content-Type", /json/); + }); + + it("0101: check if minimal derived dataset is valid", async () => { + return request(appUrl) + .post("/api/v4/datasets/isValid") + .send(TestData.DerivedCorrectMinV4) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.EntryValidStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.have.property("valid").and.equal(true); + }); + }); + + it("0102: check if minimal raw dataset is valid", async () => { + return request(appUrl) + .post("/api/v4/datasets/isValid") + .send(TestData.RawCorrectMinV4) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.EntryValidStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.have.property("valid").and.equal(true); + }); + }); + + it("0103: check if custom dataset is valid", async () => { + return request(appUrl) + .post("/api/v4/datasets/isValid") + .send(TestData.CustomDatasetCorrect) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.EntryValidStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.have.property("valid").and.equal(true); + }); + }); + + it("0104: check if invalid derived dataset is valid", async () => { + return request(appUrl) + .post("/api/v4/datasets/isValid") + .send(TestData.DerivedWrongV4) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.EntryValidStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.have.property("valid").and.equal(false); + }); + }); + }); + + describe("Datasets creation tests", () => { + it("0110: should not be able to create dataset if not logged in", async () => { + return request(appUrl) + .post("/api/v4/datasets") + .send(TestData.DerivedCorrectMinV4) + .expect(TestData.AccessForbiddenStatusCode) + .expect("Content-Type", /json/); + }); + + it("0111: adds a new minimal derived dataset", async () => { + return request(appUrl) + .post("/api/v4/datasets") + .send(TestData.DerivedCorrectMinV4) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.EntryCreatedStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.have.property("owner").and.be.a("string"); + res.body.should.have.property("type").and.equal("derived"); + res.body.should.have.property("pid").and.be.a("string"); + derivedDatasetMinPid = res.body.pid; + }); + }); + + it("0112: adds a new minimal raw dataset", async () => { + return request(appUrl) + .post("/api/v4/datasets") + .send(TestData.RawCorrectMinV4) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.EntryCreatedStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.have + .property("owner") + .and.be.string(TestData.RawCorrectMinV4.owner); + res.body.should.have.property("type").and.equal("raw"); + res.body.should.have.property("pid").and.be.a("string"); + }); + }); + + it("0113: adds a new minimal custom dataset", async () => { + return request(appUrl) + .post("/api/v4/datasets") + .send(TestData.CustomDatasetCorrectMin) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.EntryCreatedStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.have.property("owner").and.be.a("string"); + res.body.should.have.property("type").and.equal("custom"); + res.body.should.have.property("pid").and.be.a("string"); + }); + }); + + it("0114: adds a new derived dataset", async () => { + return request(appUrl) + .post("/api/v4/datasets") + .send(TestData.DerivedCorrectV4) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.EntryCreatedStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.have.property("owner").and.be.a("string"); + res.body.should.have.property("type").and.equal("derived"); + res.body.should.have.property("pid").and.be.a("string"); + }); + }); + + it("0115: adds a new raw dataset", async () => { + return request(appUrl) + .post("/api/v4/datasets") + .send(TestData.RawCorrectV4) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.EntryCreatedStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.have.property("owner").and.be.a("string"); + res.body.should.have.property("type").and.equal("raw"); + res.body.should.have.property("pid").and.be.a("string"); + }); + }); + + it("0116: adds a new custom dataset", async () => { + return request(appUrl) + .post("/api/v4/datasets") + .send(TestData.CustomDatasetCorrect) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.EntryCreatedStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.have + .property("owner") + .and.be.equal(TestData.CustomDatasetCorrect.owner); + res.body.should.have.property("type").and.be.equal("custom"); + res.body.should.have.property("pid").and.be.a("string"); + res.body.should.have.property("proposalIds").and.be.a("array"); + res.body.should.have.property("sampleIds").and.be.a("array"); + res.body.should.have.property("instrumentIds").and.be.a("array"); + }); + }); + + it("0120: should be able to add new derived dataset with explicit pid", async () => { + const derivedDatasetWithExplicitPID = { + ...TestData.DerivedCorrectV4, + pid: TestData.PidPrefix + "/" + uuidv4(), + }; + return request(appUrl) + .post("/api/v4/datasets") + .send(derivedDatasetWithExplicitPID) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.EntryCreatedStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.have + .property("owner") + .and.be.equal(derivedDatasetWithExplicitPID.owner); + res.body.should.have.property("type").and.be.equal("derived"); + res.body.should.have + .property("pid") + .and.be.equal(derivedDatasetWithExplicitPID.pid); + }); + }); + + it("0121: should not be able to add new derived dataset with user that is not in create dataset list", async () => { + const derivedDatasetWithExplicitPID = { + ...TestData.DerivedCorrectV4, + pid: TestData.PidPrefix + "/" + uuidv4(), + }; + + return request(appUrl) + .post("/api/v4/datasets") + .send(derivedDatasetWithExplicitPID) + .auth(accessTokenUser1, { type: "bearer" }) + .expect(TestData.CreationForbiddenStatusCode) + .expect("Content-Type", /json/); + }); + + it("0122: should not be able to add new derived dataset with group that is not part of allowed groups", async () => { + const derivedDatasetWithExplicitPID = { + ...TestData.DerivedCorrectV4, + pid: TestData.PidPrefix + "/" + uuidv4(), + ownerGroup: "group1", + }; + return request(appUrl) + .post("/api/v4/datasets") + .send(derivedDatasetWithExplicitPID) + .auth(accessTokenUser2, { type: "bearer" }) + .expect(TestData.CreationForbiddenStatusCode) + .expect("Content-Type", /json/); + }); + + it("0123: should not be able to add new derived dataset with correct group but explicit PID that does not pass validation", async () => { + const derivedDatasetWithExplicitPID = { + ...TestData.DerivedCorrectV4, + ownerGroup: "group2", + pid: "strange-pid", + }; + return request(appUrl) + .post("/api/v4/datasets") + .send(derivedDatasetWithExplicitPID) + .auth(accessTokenUser2, { type: "bearer" }) + .expect(TestData.BadRequestStatusCode) + .expect("Content-Type", /json/); + }); + + it("0124: should be able to add new derived dataset with group that is part of allowed groups and correct explicit PID", async () => { + const derivedDatasetWithExplicitPID = { + ...TestData.DerivedCorrectV4, + ownerGroup: "group2", + pid: TestData.PidPrefix + "/" + uuidv4(), + }; + return request(appUrl) + .post("/api/v4/datasets") + .send(derivedDatasetWithExplicitPID) + .auth(accessTokenUser2, { type: "bearer" }) + .expect(TestData.EntryCreatedStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.have.property("owner").and.be.a("string"); + res.body.should.have.property("type").and.equal("derived"); + res.body.should.have + .property("pid") + .and.equal(derivedDatasetWithExplicitPID.pid); + }); + }); + + it("0125: tries to add an incomplete derived dataset", async () => { + return request(appUrl) + .post("/api/v4/datasets") + .send(TestData.DerivedWrongV4) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.BadRequestStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.statusCode.should.not.be.equal(200); + }); + }); + }); + + describe("Datasets v4 findAll tests", () => { + it("0200: should not be able to fetch datasets if not logged in", async () => { + const filter = { + limits: { + limit: 2, + skip: 0, + sort: { + datasetName: "asc", + }, + }, + }; + + return request(appUrl) + .get("/api/v4/datasets") + .query({ filter: JSON.stringify(filter) }) + .expect(TestData.AccessForbiddenStatusCode) + .expect("Content-Type", /json/); + }); + + it("0201: should fetch several datasets using limits sort filter", async () => { + const filter = { + limits: { + limit: 2, + skip: 0, + sort: { + datasetName: "asc", + }, + }, + }; + + await request(appUrl) + .get(`/api/v4/datasets`) + .query({ filter: JSON.stringify(filter) }) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("array"); + res.body.should.have.length(2); + const [firstDatast, secondDataset] = res.body; + + firstDatast.datasetName.should.satisfy( + () => firstDatast.datasetName <= secondDataset.datasetName, + ); + }); + + filter.limits.limit = 3; + filter.limits.sort.datasetName = "desc"; + + return request(appUrl) + .get(`/api/v4/datasets`) + .query({ filter: JSON.stringify(filter) }) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("array"); + res.body.should.have.length(3); + const [firstDatast, secondDataset, thirdDataset] = res.body; + + firstDatast.datasetName.should.satisfy( + () => + firstDatast.datasetName >= secondDataset.datasetName && + firstDatast.datasetName >= thirdDataset.datasetName && + secondDataset.datasetName >= thirdDataset.datasetName, + ); + }); + }); + + it("0202: should fetch different dataset if skip is used in limits filter", async () => { + let responseBody; + const filter = { + limits: { + limit: 1, + skip: 0, + sort: { + datasetName: "asc", + }, + }, + }; + + await request(appUrl) + .get(`/api/v4/datasets`) + .query({ filter: JSON.stringify(filter) }) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("array"); + res.body.should.have.length(1); + + responseBody = res.body; + }); + + filter.limits.skip = 1; + + return request(appUrl) + .get(`/api/v4/datasets`) + .query({ filter: JSON.stringify(filter) }) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("array"); + res.body.should.have.length(0); + + JSON.stringify(responseBody).should.not.be.equal( + JSON.stringify(res.body), + ); + }); + }); + + it("0203: should fetch specific dataset fields only if fields is provided in the filter", async () => { + const filter = { + fields: ["datasetName", "pid"], + }; + + return request(appUrl) + .get(`/api/v4/datasets`) + .query({ filter: JSON.stringify(filter) }) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("array"); + const [firstDataset] = res.body; + + firstDataset.should.have.property("datasetName"); + firstDataset.should.have.property("pid"); + firstDataset.should.not.have.property("description"); + }); + }); + + it("0204: should fetch dataset relation fields if provided in the filter", async () => { + const filter = { + include: ["instruments", "proposals"], + }; + + return request(appUrl) + .get(`/api/v4/datasets`) + .query({ filter: JSON.stringify(filter) }) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("array"); + const [firstDataset] = res.body; + + firstDataset.should.have.property("pid"); + firstDataset.should.have.property("instruments"); + firstDataset.should.have.property("proposals"); + firstDataset.should.not.have.property("datablocks"); + }); + }); + + it("0205: should fetch all datasets with related items when requested with all relations", async () => { + const filter = { + include: ["all"], + }; + + return request(appUrl) + .get(`/api/v4/datasets`) + .query({ filter: JSON.stringify(filter) }) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("array"); + const [firstDataset] = res.body; + + firstDataset.should.have.property("pid"); + firstDataset.should.have.property("instruments"); + firstDataset.should.have.property("proposals"); + firstDataset.should.have.property("datablocks"); + firstDataset.should.have.property("attachments"); + firstDataset.should.have.property("origdatablocks"); + firstDataset.should.have.property("samples"); + }); + }); + + it("0206: should be able to fetch the datasets providing where filter", async () => { + const filter = { + where: { + datasetName: { + $regex: "Dataset", + $options: "i", + }, + }, + }; + + return request(appUrl) + .get(`/api/v4/datasets`) + .query({ filter: JSON.stringify(filter) }) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("array"); + + res.body.forEach((dataset) => { + dataset.datasetName.should.match(/Dataset/i); + }); + }); + }); + + it("0207: should be able to fetch the datasets providing all allowed filters together", async () => { + const filter = { + where: { + datasetName: { + $regex: "Dataset", + $options: "i", + }, + }, + include: ["all"], + fields: ["datasetName", "pid"], + limits: { + limit: 2, + skip: 0, + sort: { + datasetName: "asc", + }, + }, + }; + + return request(appUrl) + .get(`/api/v4/datasets`) + .query({ filter: JSON.stringify(filter) }) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("array"); + res.body.should.have.length(2); + + res.body.forEach((dataset) => { + dataset.should.have.property("datasetName"); + dataset.should.have.property("pid"); + dataset.should.not.have.property("description"); + + dataset.should.have.property("pid"); + dataset.should.have.property("instruments"); + dataset.should.have.property("proposals"); + dataset.should.have.property("datablocks"); + dataset.should.have.property("attachments"); + dataset.should.have.property("origdatablocks"); + dataset.should.have.property("samples"); + + dataset.datasetName.should.match(/Dataset/i); + }); + }); + }); + + it("0208: should not be able to provide filters that are not allowed", async () => { + const filter = { + customField: { datasetName: "test" }, + }; + + return request(appUrl) + .get(`/api/v4/datasets`) + .query({ filter: JSON.stringify(filter) }) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.BadRequestStatusCode) + .expect("Content-Type", /json/); + }); + }); + + describe("Datasets v4 findOne tests", () => { + it("0300: should not be able to fetch dataset if not logged in", async () => { + const filter = { + limits: { + skip: 0, + sort: { + datasetName: "asc", + }, + }, + }; + + return request(appUrl) + .get("/api/v4/datasets/findOne") + .query({ filter: JSON.stringify(filter) }) + .expect(TestData.AccessForbiddenStatusCode) + .expect("Content-Type", /json/); + }); + + it("0301: should fetch different dataset if skip is used in limits filter", async () => { + let responseBody; + const filter = { + limits: { + skip: 0, + sort: { + datasetName: "asc", + }, + }, + }; + + await request(appUrl) + .get(`/api/v4/datasets/findOne`) + .query({ filter: JSON.stringify(filter) }) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + + responseBody = res.body; + }); + + filter.limits.skip = 1; + + return request(appUrl) + .get(`/api/v4/datasets/findOne`) + .query({ filter: JSON.stringify(filter) }) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + + JSON.stringify(responseBody).should.not.be.equal( + JSON.stringify(res.body), + ); + }); + }); + + it("0302: should fetch specific dataset fields only if fields is provided in the filter", async () => { + const filter = { + fields: ["datasetName", "pid"], + }; + + return request(appUrl) + .get(`/api/v4/datasets/findOne`) + .query({ filter: JSON.stringify(filter) }) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + + res.body.should.have.property("datasetName"); + res.body.should.have.property("pid"); + res.body.should.not.have.property("description"); + }); + }); + + it("0303: should fetch dataset relation fields if provided in the filter", async () => { + const filter = { + include: ["instruments", "proposals"], + }; + + return request(appUrl) + .get(`/api/v4/datasets/findOne`) + .query({ filter: JSON.stringify(filter) }) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + + res.body.should.have.property("pid"); + res.body.should.have.property("instruments"); + res.body.should.have.property("proposals"); + res.body.should.not.have.property("datablocks"); + }); + }); + + it("0304: should fetch all dataset relation fields if provided in the filter", async () => { + const filter = { + include: ["all"], + }; + + return request(appUrl) + .get(`/api/v4/datasets/findOne`) + .query({ filter: JSON.stringify(filter) }) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + + res.body.should.have.property("pid"); + res.body.should.have.property("instruments"); + res.body.should.have.property("proposals"); + res.body.should.have.property("datablocks"); + res.body.should.have.property("attachments"); + res.body.should.have.property("origdatablocks"); + res.body.should.have.property("samples"); + }); + }); + + it("0305: should be able to fetch the dataset providing where filter", async () => { + const filter = { + where: { + datasetName: { + $regex: "Dataset", + $options: "i", + }, + }, + }; + + return request(appUrl) + .get(`/api/v4/datasets/findOne`) + .query({ filter: JSON.stringify(filter) }) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + res.body.datasetName.should.match(/Dataset/i); + }); + }); + + it("0306: should be able to fetch a dataset providing all allowed filters together", async () => { + const filter = { + where: { + datasetName: { + $regex: "Dataset", + $options: "i", + }, + }, + include: ["all"], + fields: ["datasetName", "pid"], + limits: { + skip: 0, + sort: { + datasetName: "asc", + }, + }, + }; + + return request(appUrl) + .get(`/api/v4/datasets/findOne`) + .query({ filter: JSON.stringify(filter) }) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + + res.body.should.have.property("datasetName"); + res.body.should.have.property("pid"); + res.body.should.not.have.property("description"); + + res.body.should.have.property("pid"); + res.body.should.have.property("instruments"); + res.body.should.have.property("proposals"); + res.body.should.have.property("datablocks"); + res.body.should.have.property("attachments"); + res.body.should.have.property("origdatablocks"); + res.body.should.have.property("samples"); + + res.body.datasetName.should.match(/Dataset/i); + }); + }); + + it("0307: should not be able to provide filters that are not allowed", async () => { + const filter = { + customField: { datasetName: "test" }, + }; + + return request(appUrl) + .get(`/api/v4/datasets/findOne`) + .query({ filter: JSON.stringify(filter) }) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.BadRequestStatusCode) + .expect("Content-Type", /json/); + }); + }); + + describe("Datasets v4 count tests", () => { + it("0400: should not be able to fetch datasets count if not logged in", async () => { + const filter = { + limits: { + skip: 0, + sort: { + datasetName: "asc", + }, + }, + }; + + return request(appUrl) + .get("/api/v4/datasets/count") + .query({ filter: JSON.stringify(filter) }) + .expect(TestData.AccessForbiddenStatusCode) + .expect("Content-Type", /json/); + }); + + it("0401: should be able to fetch the datasets count providing where filter", async () => { + const filter = { + where: { + datasetName: { + $regex: "Dataset", + $options: "i", + }, + }, + }; + + return request(appUrl) + .get(`/api/v4/datasets/count`) + .query({ filter: JSON.stringify(filter) }) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + res.body.should.have.property("count"); + res.body.count.should.be.a("number"); + res.body.count.should.be.greaterThan(0); + }); + }); + }); + + describe("Datasets v4 findById tests", () => { + it("0500: should not be able to fetch dataset by id if not logged in", () => { + return request(appUrl) + .get(`/api/v4/datasets/${encodeURIComponent(derivedDatasetMinPid)}`) + .expect(TestData.AccessForbiddenStatusCode) + .expect("Content-Type", /json/); + }); + + it("0501: should fetch dataset by id", () => { + return request(appUrl) + .get(`/api/v4/datasets/${encodeURIComponent(derivedDatasetMinPid)}`) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + + res.body.pid.should.be.eq(derivedDatasetMinPid); + }); + }); + + it("0502: should fetch dataset relation fields if provided in the filter", () => { + return request(appUrl) + .get( + `/api/v4/datasets/${encodeURIComponent(derivedDatasetMinPid)}?include=instruments&include=proposals`, + ) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + + res.body.should.have.property("pid"); + res.body.should.have.property("instruments"); + res.body.should.have.property("proposals"); + res.body.should.not.have.property("datablocks"); + }); + }); + + it("0503: should fetch all dataset relation fields if provided in the filter", () => { + return request(appUrl) + .get( + `/api/v4/datasets/${encodeURIComponent(derivedDatasetMinPid)}?include=all`, + ) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + + res.body.should.have.property("pid"); + res.body.should.have.property("instruments"); + res.body.should.have.property("proposals"); + res.body.should.have.property("datablocks"); + res.body.should.have.property("attachments"); + res.body.should.have.property("origdatablocks"); + res.body.should.have.property("samples"); + }); + }); + }); + + describe("Datasets v4 update tests", () => { + it("0600: should not be able to update dataset if not logged in", () => { + const updatedDataset = { + ...TestData.DerivedCorrectMinV4, + datasetName: "Updated dataset name", + }; + + return request(appUrl) + .put(`/api/v4/datasets/${encodeURIComponent(derivedDatasetMinPid)}`) + .send(updatedDataset) + .expect(TestData.AccessForbiddenStatusCode) + .expect("Content-Type", /json/); + }); + + it("0601: should be able to update dataset", () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { type, ...updatedDataset } = { + ...TestData.DerivedCorrectMinV4, + datasetName: "Updated dataset name", + }; + + return request(appUrl) + .put(`/api/v4/datasets/${encodeURIComponent(derivedDatasetMinPid)}`) + .send(updatedDataset) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.SuccessfulPatchStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + + res.body.should.have.property("pid"); + res.body.should.have.property("datasetName"); + res.body.datasetName.should.be.eq(updatedDataset.datasetName); + }); + }); + + it("0600: should not be able to partially update dataset if not logged in", () => { + const updatedDataset = { + datasetName: "Updated dataset name", + }; + + return request(appUrl) + .patch(`/api/v4/datasets/${encodeURIComponent(derivedDatasetMinPid)}`) + .send(updatedDataset) + .expect(TestData.AccessForbiddenStatusCode) + .expect("Content-Type", /json/); + }); + + it("0601: should be able to partially update dataset", () => { + const updatedDataset = { + datasetName: "Updated dataset name", + }; + + return request(appUrl) + .patch(`/api/v4/datasets/${encodeURIComponent(derivedDatasetMinPid)}`) + .send(updatedDataset) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.SuccessfulPatchStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + + res.body.should.have.property("pid"); + res.body.should.have.property("datasetName"); + res.body.datasetName.should.be.eq(updatedDataset.datasetName); + }); + }); + }); + + describe("Datasets v4 delete tests", () => { + it("0700: should not be able to delete dataset if not logged in", () => { + return request(appUrl) + .delete(`/api/v4/datasets/${encodeURIComponent(derivedDatasetMinPid)}`) + .expect(TestData.AccessForbiddenStatusCode) + .expect("Content-Type", /json/); + }); + + it("0701: should be able to delete dataset", () => { + return request(appUrl) + .delete(`/api/v4/datasets/${encodeURIComponent(derivedDatasetMinPid)}`) + .auth(accessTokenArchiveManager, { type: "bearer" }) + .expect(TestData.SuccessfulDeleteStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + + res.body.should.have.property("pid"); + res.body.should.have.property("datasetName"); + }); + }); + + it("0702: delete all dataset as archivemanager", async () => { + return await request(appUrl) + .get("/api/v4/datasets") + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.SuccessfulDeleteStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + return processArray(res.body); + }); + }); + }); +}); diff --git a/test/DatasetV4Access.js b/test/DatasetV4Access.js new file mode 100644 index 000000000..2bd53e4e6 --- /dev/null +++ b/test/DatasetV4Access.js @@ -0,0 +1,488 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +"use strict"; + +const utils = require("./LoginUtils"); +const { TestData } = require("./TestData"); + +let user1Token = null; +let user2Token = null; +let user3Token = null; +let accessTokenArchiveManager = null; +let accessTokenAdminIngestor = null; +let derivedDatasetMinPid = null; +let proposalId = null; +let instrumentId = null; +let sampleId = null; +let origDatablockId1 = null; +let origDatablockData1 = { + ...TestData.OrigDataBlockCorrect1, + datasetId: null, +}; + +describe("2700: Datasets v4 access tests", () => { + before(async () => { + db.collection("Dataset").deleteMany({}); + db.collection("Proposal").deleteMany({}); + db.collection("Instrument").deleteMany({}); + db.collection("Sample").deleteMany({}); + + accessTokenAdminIngestor = await utils.getToken(appUrl, { + username: "adminIngestor", + password: TestData.Accounts["adminIngestor"]["password"], + }); + accessTokenArchiveManager = await utils.getToken(appUrl, { + username: "archiveManager", + password: TestData.Accounts["archiveManager"]["password"], + }); + user1Token = await utils.getToken(appUrl, { + username: "user1", + password: TestData.Accounts["user1"]["password"], + }); + user2Token = await utils.getToken(appUrl, { + username: "user2", + password: TestData.Accounts["user2"]["password"], + }); + user3Token = await utils.getToken(appUrl, { + username: "user3", + password: TestData.Accounts["user3"]["password"], + }); + + await request(appUrl) + .post("/api/v3/Proposals") + .send({ + ...TestData.ProposalCorrectMin, + ownerGroup: TestData.Accounts.user1.role, + accessGroups: [TestData.Accounts.user3.role], + }) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.EntryCreatedStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + proposalId = res.body.proposalId; + }); + + await request(appUrl) + .post("/api/v3/Instruments") + .send(TestData.InstrumentCorrect1) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.EntryCreatedStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + instrumentId = res.body.pid; + }); + + await request(appUrl) + .post("/api/v3/Samples") + .send({ + ...TestData.SampleCorrect, + ownerGroup: TestData.Accounts.user1.role, + }) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.EntryCreatedStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + sampleId = res.body.sampleId; + }); + + await request(appUrl) + .post("/api/v4/datasets") + .send({ + ...TestData.DerivedCorrectV4, + ownerGroup: TestData.Accounts.user1.role, + accessGroups: [TestData.Accounts.user3.role], + proposalIds: [proposalId], + instrumentIds: [instrumentId], + sampleIds: [sampleId], + }) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.EntryCreatedStatusCode) + .then((res) => { + derivedDatasetMinPid = res.body.pid; + origDatablockData1.datasetId = derivedDatasetMinPid; + }); + + origDatablockData1.datasetId = derivedDatasetMinPid; + + await request(appUrl) + .post(`/api/v3/origDatablocks`) + .send(origDatablockData1) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.EntryCreatedStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + origDatablockId1 = res.body.id; + }); + + await request(appUrl) + .post("/api/v4/datasets") + .send({ + ...TestData.RawCorrectV4, + }) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.EntryCreatedStatusCode); + + await request(appUrl) + .post("/api/v4/datasets") + .send({ ...TestData.CustomDatasetCorrect }) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.EntryCreatedStatusCode); + }); + + async function deleteDataset(item) { + const response = await request(appUrl) + .delete("/api/v4/datasets/" + encodeURIComponent(item.pid)) + .auth(accessTokenArchiveManager, { type: "bearer" }) + .expect(TestData.SuccessfulDeleteStatusCode); + + return response; + } + + async function processArray(array) { + for (const item of array) { + await deleteDataset(item); + } + } + + describe("Fetching v4 all datasets access", () => { + it("0100: should fetch dataset relation fields with correct data included if provided in the filter and have the correct rights", async () => { + const filter = { + include: [ + "instruments", + "proposals", + "samples", + "origdatablocks", + "datablocks", + ], + }; + + return request(appUrl) + .get(`/api/v4/datasets`) + .query({ filter: JSON.stringify(filter) }) + .auth(user1Token, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("array"); + const [firstDataset] = res.body; + + firstDataset.should.have.property("pid"); + firstDataset.should.have.property("instruments"); + firstDataset.instruments.should.be.a("array"); + firstDataset.instruments.should.have.length(1); + const [instrument] = firstDataset.instruments; + instrument.should.have.property("pid"); + instrument.pid.should.be.eq(instrumentId); + + firstDataset.should.have.property("proposals"); + firstDataset.proposals.should.be.a("array"); + firstDataset.proposals.should.have.length(1); + const [proposal] = firstDataset.proposals; + proposal.should.have.property("proposalId"); + proposal.proposalId.should.be.eq(proposalId); + + firstDataset.should.have.property("samples"); + firstDataset.samples.should.be.a("array"); + firstDataset.samples.should.have.length(1); + const [sample] = firstDataset.samples; + sample.should.have.property("sampleId"); + sample.sampleId.should.be.eq(sampleId); + + firstDataset.should.have.property("origdatablocks"); + firstDataset.origdatablocks.should.be.a("array"); + firstDataset.origdatablocks.should.have.length(1); + const [origdatablock] = firstDataset.origdatablocks; + origdatablock.should.have.property("_id"); + origdatablock._id.should.be.eq(origDatablockId1); + }); + }); + + it("0101: should not be able to fetch dataset if do not have the correct access rights", async () => { + const filter = {}; + + return request(appUrl) + .get(`/api/v4/datasets`) + .query({ filter: JSON.stringify(filter) }) + .auth(user2Token, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .then((res) => { + res.body.should.be.a("array"); + res.body.should.have.length(0); + }); + }); + + it("0102: should fetch dataset relation fields with correct data included if provided in the filter and have the correct rights", async () => { + const filter = { + include: [ + "instruments", + "proposals", + "samples", + "origdatablocks", + "datablocks", + ], + }; + + return request(appUrl) + .get(`/api/v4/datasets`) + .query({ filter: JSON.stringify(filter) }) + .auth(user3Token, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("array"); + const [firstDataset] = res.body; + + firstDataset.should.have.property("pid"); + firstDataset.should.have.property("instruments"); + firstDataset.instruments.should.be.a("array"); + firstDataset.instruments.should.have.length(1); + const [instrument] = firstDataset.instruments; + instrument.should.have.property("pid"); + instrument.pid.should.be.eq(instrumentId); + + firstDataset.should.have.property("proposals"); + firstDataset.proposals.should.be.a("array"); + firstDataset.proposals.should.have.length(1); + const [proposal] = firstDataset.proposals; + proposal.should.have.property("proposalId"); + proposal.proposalId.should.be.eq(proposalId); + + firstDataset.should.have.property("samples"); + firstDataset.samples.should.be.a("array"); + firstDataset.samples.should.have.length(0); + + firstDataset.should.have.property("origdatablocks"); + firstDataset.origdatablocks.should.be.a("array"); + firstDataset.origdatablocks.should.have.length(1); + const [origdatablock] = firstDataset.origdatablocks; + origdatablock.should.have.property("_id"); + origdatablock._id.should.be.eq(origDatablockId1); + }); + }); + }); + + describe("Datasets v4 findOne access tests", () => { + it("0200: should fetch dataset relation fields with correct data included if provided in the filter and have the correct rights", async () => { + const filter = { + include: [ + "instruments", + "proposals", + "samples", + "origdatablocks", + "datablocks", + ], + }; + + return request(appUrl) + .get(`/api/v4/datasets/findOne`) + .query({ filter: JSON.stringify(filter) }) + .auth(user1Token, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + const firstDataset = res.body; + + firstDataset.should.have.property("pid"); + firstDataset.should.have.property("instruments"); + firstDataset.instruments.should.be.a("array"); + firstDataset.instruments.should.have.length(1); + const [instrument] = firstDataset.instruments; + instrument.should.have.property("pid"); + instrument.pid.should.be.eq(instrumentId); + + firstDataset.should.have.property("proposals"); + firstDataset.proposals.should.be.a("array"); + firstDataset.proposals.should.have.length(1); + const [proposal] = firstDataset.proposals; + proposal.should.have.property("proposalId"); + proposal.proposalId.should.be.eq(proposalId); + + firstDataset.should.have.property("samples"); + firstDataset.samples.should.be.a("array"); + firstDataset.samples.should.have.length(1); + const [sample] = firstDataset.samples; + sample.should.have.property("sampleId"); + sample.sampleId.should.be.eq(sampleId); + + firstDataset.should.have.property("origdatablocks"); + firstDataset.origdatablocks.should.be.a("array"); + firstDataset.origdatablocks.should.have.length(1); + const [origdatablock] = firstDataset.origdatablocks; + origdatablock.should.have.property("_id"); + origdatablock._id.should.be.eq(origDatablockId1); + }); + }); + + it("0201: should not be able to fetch dataset if do not have the correct access rights", async () => { + const filter = {}; + + return request(appUrl) + .get(`/api/v4/datasets/findOne`) + .query({ filter: JSON.stringify(filter) }) + .auth(user2Token, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .then((res) => { + res.body.should.be.a("object").and.to.be.deep.equal({}); + }); + }); + + it("0202: should fetch dataset relation fields with correct data included if provided in the filter and have the correct rights", async () => { + const filter = { + include: [ + "instruments", + "proposals", + "samples", + "origdatablocks", + "datablocks", + ], + }; + + return request(appUrl) + .get(`/api/v4/datasets/findOne`) + .query({ filter: JSON.stringify(filter) }) + .auth(user3Token, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + const firstDataset = res.body; + + firstDataset.should.have.property("pid"); + firstDataset.should.have.property("instruments"); + firstDataset.instruments.should.be.a("array"); + firstDataset.instruments.should.have.length(1); + const [instrument] = firstDataset.instruments; + instrument.should.have.property("pid"); + instrument.pid.should.be.eq(instrumentId); + + firstDataset.should.have.property("proposals"); + firstDataset.proposals.should.be.a("array"); + firstDataset.proposals.should.have.length(1); + const [proposal] = firstDataset.proposals; + proposal.should.have.property("proposalId"); + proposal.proposalId.should.be.eq(proposalId); + + firstDataset.should.have.property("samples"); + firstDataset.samples.should.be.a("array"); + firstDataset.samples.should.have.length(0); + + firstDataset.should.have.property("origdatablocks"); + firstDataset.origdatablocks.should.be.a("array"); + firstDataset.origdatablocks.should.have.length(1); + const [origdatablock] = firstDataset.origdatablocks; + origdatablock.should.have.property("_id"); + origdatablock._id.should.be.eq(origDatablockId1); + }); + }); + }); + + describe("Datasets v4 findById access tests", () => { + it("0300: should fetch dataset relation fields with correct data included if provided in the filter and have the correct rights", () => { + return request(appUrl) + .get( + `/api/v4/datasets/${encodeURIComponent(derivedDatasetMinPid)}?include=instruments&include=proposals&include=samples&include=origdatablocks&include=datablocks`, + ) + .auth(user1Token, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + const firstDataset = res.body; + + firstDataset.should.have.property("pid"); + firstDataset.should.have.property("instruments"); + firstDataset.instruments.should.be.a("array"); + firstDataset.instruments.should.have.length(1); + const [instrument] = firstDataset.instruments; + instrument.should.have.property("pid"); + instrument.pid.should.be.eq(instrumentId); + + firstDataset.should.have.property("proposals"); + firstDataset.proposals.should.be.a("array"); + firstDataset.proposals.should.have.length(1); + const [proposal] = firstDataset.proposals; + proposal.should.have.property("proposalId"); + proposal.proposalId.should.be.eq(proposalId); + + firstDataset.should.have.property("samples"); + firstDataset.samples.should.be.a("array"); + firstDataset.samples.should.have.length(1); + const [sample] = firstDataset.samples; + sample.should.have.property("sampleId"); + sample.sampleId.should.be.eq(sampleId); + + firstDataset.should.have.property("origdatablocks"); + firstDataset.origdatablocks.should.be.a("array"); + firstDataset.origdatablocks.should.have.length(1); + const [origdatablock] = firstDataset.origdatablocks; + origdatablock.should.have.property("_id"); + origdatablock._id.should.be.eq(origDatablockId1); + }); + }); + + it("0301: should not be able to fetch dataset if do not have the correct access rights", () => { + return request(appUrl) + .get( + `/api/v4/datasets/public/${encodeURIComponent(derivedDatasetMinPid)}`, + ) + .auth(user2Token, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .then((res) => { + res.body.should.be.a("object").and.to.be.deep.equal({}); + }); + }); + + it("0302: should fetch dataset relation fields with correct data included if provided in the filter and have the correct rights", () => { + return request(appUrl) + .get( + `/api/v4/datasets/${encodeURIComponent(derivedDatasetMinPid)}?include=instruments&include=proposals&include=samples&include=origdatablocks&include=datablocks`, + ) + .auth(user3Token, { type: "bearer" }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + const firstDataset = res.body; + + firstDataset.should.have.property("pid"); + firstDataset.should.have.property("instruments"); + firstDataset.instruments.should.be.a("array"); + firstDataset.instruments.should.have.length(1); + const [instrument] = firstDataset.instruments; + instrument.should.have.property("pid"); + instrument.pid.should.be.eq(instrumentId); + + firstDataset.should.have.property("proposals"); + firstDataset.proposals.should.be.a("array"); + firstDataset.proposals.should.have.length(1); + const [proposal] = firstDataset.proposals; + proposal.should.have.property("proposalId"); + proposal.proposalId.should.be.eq(proposalId); + + firstDataset.should.have.property("samples"); + firstDataset.samples.should.be.a("array"); + firstDataset.samples.should.have.length(0); + + firstDataset.should.have.property("origdatablocks"); + firstDataset.origdatablocks.should.be.a("array"); + firstDataset.origdatablocks.should.have.length(1); + const [origdatablock] = firstDataset.origdatablocks; + origdatablock.should.have.property("_id"); + origdatablock._id.should.be.eq(origDatablockId1); + }); + }); + }); + + describe("Cleanup datasets after the tests", () => { + it("0400: delete all dataset as archivemanager", async () => { + return await request(appUrl) + .get("/api/v4/datasets") + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.SuccessfulDeleteStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + return processArray(res.body); + }); + }); + }); +}); diff --git a/test/DatasetV4Public.js b/test/DatasetV4Public.js new file mode 100644 index 000000000..5adcf23d8 --- /dev/null +++ b/test/DatasetV4Public.js @@ -0,0 +1,567 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +"use strict"; + +const utils = require("./LoginUtils"); +const { TestData } = require("./TestData"); + +let derivedDatasetMinPid = null; +let accessTokenArchiveManager = null; +let accessTokenAdminIngestor = null; + +describe("2600: Datasets v4 public endpoints tests", () => { + before(async () => { + db.collection("Dataset").deleteMany({}); + + accessTokenAdminIngestor = await utils.getToken(appUrl, { + username: "adminIngestor", + password: TestData.Accounts["adminIngestor"]["password"], + }); + + accessTokenArchiveManager = await utils.getToken(appUrl, { + username: "archiveManager", + password: TestData.Accounts["archiveManager"]["password"], + }); + + await request(appUrl) + .post("/api/v4/datasets") + .send({ ...TestData.DerivedCorrectV4, isPublished: true }) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.EntryCreatedStatusCode) + .then((res) => { + derivedDatasetMinPid = res.body.pid; + }); + + await request(appUrl) + .post("/api/v4/datasets") + .send({ ...TestData.RawCorrectV4, isPublished: true }) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.EntryCreatedStatusCode); + + await request(appUrl) + .post("/api/v4/datasets") + .send({ ...TestData.CustomDatasetCorrect, isPublished: true }) + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.EntryCreatedStatusCode); + }); + + async function deleteDataset(item) { + const response = await request(appUrl) + .delete("/api/v4/datasets/" + encodeURIComponent(item.pid)) + .auth(accessTokenArchiveManager, { type: "bearer" }) + .expect(TestData.SuccessfulDeleteStatusCode); + + return response; + } + + async function processArray(array) { + for (const item of array) { + await deleteDataset(item); + } + } + + describe("Fetching v4 public datasets", () => { + it("0200: should fetch several public datasets using limits sort filter", async () => { + const filter = { + limits: { + limit: 2, + skip: 0, + sort: { + datasetName: "asc", + }, + }, + }; + + await request(appUrl) + .get("/api/v4/datasets/public") + .query({ filter: JSON.stringify(filter) }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("array"); + res.body.should.have.length(2); + const [firstDatast, secondDataset] = res.body; + + firstDatast.datasetName.should.satisfy( + () => firstDatast.datasetName <= secondDataset.datasetName, + ); + }); + + filter.limits.limit = 3; + filter.limits.sort.datasetName = "desc"; + + return request(appUrl) + .get("/api/v4/datasets/public") + .query({ filter: JSON.stringify(filter) }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("array"); + res.body.should.have.length(3); + const [firstDatast, secondDataset, thirdDataset] = res.body; + + firstDatast.datasetName.should.satisfy( + () => + firstDatast.datasetName >= secondDataset.datasetName && + firstDatast.datasetName >= thirdDataset.datasetName && + secondDataset.datasetName >= thirdDataset.datasetName, + ); + }); + }); + + it("0202: should fetch different public datasets if skip is used in limits filter", async () => { + let responseBody; + const filter = { + limits: { + limit: 1, + skip: 0, + sort: { + datasetName: "asc", + }, + }, + }; + + await request(appUrl) + .get(`/api/v4/datasets/public`) + .query({ filter: JSON.stringify(filter) }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("array"); + res.body.should.have.length(1); + + responseBody = res.body; + }); + + filter.limits.skip = 1; + + return request(appUrl) + .get(`/api/v4/datasets/public`) + .query({ filter: JSON.stringify(filter) }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("array"); + res.body.should.have.length(0); + + JSON.stringify(responseBody).should.not.be.equal( + JSON.stringify(res.body), + ); + }); + }); + + it("0203: should fetch specific dataset fields only if fields is provided in the filter", async () => { + const filter = { + fields: ["datasetName", "pid"], + }; + + return request(appUrl) + .get(`/api/v4/datasets/public`) + .query({ filter: JSON.stringify(filter) }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("array"); + const [firstDataset] = res.body; + + firstDataset.should.have.property("datasetName"); + firstDataset.should.have.property("pid"); + firstDataset.should.not.have.property("description"); + }); + }); + + it("0204: should fetch dataset relation fields if provided in the filter", async () => { + const filter = { + include: ["instruments", "proposals"], + }; + + return request(appUrl) + .get(`/api/v4/datasets/public`) + .query({ filter: JSON.stringify(filter) }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("array"); + const [firstDataset] = res.body; + + firstDataset.should.have.property("pid"); + firstDataset.should.have.property("instruments"); + firstDataset.should.have.property("proposals"); + firstDataset.should.not.have.property("datablocks"); + }); + }); + + it("0205: should fetch all dataset relation fields if provided in the filter", async () => { + const filter = { + include: ["all"], + }; + + return request(appUrl) + .get(`/api/v4/datasets/public`) + .query({ filter: JSON.stringify(filter) }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("array"); + const [firstDataset] = res.body; + + firstDataset.should.have.property("pid"); + firstDataset.should.have.property("instruments"); + firstDataset.should.have.property("proposals"); + firstDataset.should.have.property("datablocks"); + firstDataset.should.have.property("attachments"); + firstDataset.should.have.property("origdatablocks"); + firstDataset.should.have.property("samples"); + }); + }); + + it("0206: should be able to fetch the datasets providing where filter", async () => { + const filter = { + where: { + datasetName: { + $regex: "Dataset", + $options: "i", + }, + }, + }; + + return request(appUrl) + .get(`/api/v4/datasets/public`) + .query({ filter: JSON.stringify(filter) }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("array"); + + res.body.forEach((dataset) => { + dataset.datasetName.should.match(/Dataset/i); + }); + }); + }); + + it("0207: should be able to fetch the datasets providing all allowed filters together", async () => { + const filter = { + where: { + datasetName: { + $regex: "Dataset", + $options: "i", + }, + }, + include: ["all"], + fields: ["datasetName", "pid"], + limits: { + limit: 2, + skip: 0, + sort: { + datasetName: "asc", + }, + }, + }; + + return request(appUrl) + .get(`/api/v4/datasets/public`) + .query({ filter: JSON.stringify(filter) }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("array"); + res.body.should.have.length(2); + + res.body.forEach((dataset) => { + dataset.should.have.property("datasetName"); + dataset.should.have.property("pid"); + dataset.should.not.have.property("description"); + + dataset.should.have.property("pid"); + dataset.should.have.property("instruments"); + dataset.should.have.property("proposals"); + dataset.should.have.property("datablocks"); + dataset.should.have.property("attachments"); + dataset.should.have.property("origdatablocks"); + dataset.should.have.property("samples"); + + dataset.datasetName.should.match(/Dataset/i); + }); + }); + }); + + it("0208: should not be able to provide filters that are not allowed", async () => { + const filter = { + customField: { datasetName: "test" }, + }; + + return request(appUrl) + .get(`/api/v4/datasets/public`) + .query({ filter: JSON.stringify(filter) }) + .expect(TestData.BadRequestStatusCode) + .expect("Content-Type", /json/); + }); + }); + + describe("Datasets v4 findOne public tests", () => { + it("0301: should fetch different public dataset if skip is used in limits filter", async () => { + let responseBody; + const filter = { + limits: { + skip: 0, + sort: { + datasetName: "asc", + }, + }, + }; + + await request(appUrl) + .get(`/api/v4/datasets/public/findOne`) + .query({ filter: JSON.stringify(filter) }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + + responseBody = res.body; + }); + + filter.limits.skip = 1; + + return request(appUrl) + .get(`/api/v4/datasets/public/findOne`) + .query({ filter: JSON.stringify(filter) }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + + JSON.stringify(responseBody).should.not.be.equal( + JSON.stringify(res.body), + ); + }); + }); + + it("0302: should fetch specific dataset fields only if fields is provided in the filter", async () => { + const filter = { + fields: ["datasetName", "pid"], + }; + + return request(appUrl) + .get(`/api/v4/datasets/public/findOne`) + .query({ filter: JSON.stringify(filter) }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + + res.body.should.have.property("datasetName"); + res.body.should.have.property("pid"); + res.body.should.not.have.property("description"); + }); + }); + + it("0303: should fetch dataset relation fields if provided in the filter", async () => { + const filter = { + include: ["instruments", "proposals"], + }; + + return request(appUrl) + .get(`/api/v4/datasets/public/findOne`) + .query({ filter: JSON.stringify(filter) }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + + res.body.should.have.property("pid"); + res.body.should.have.property("instruments"); + res.body.should.have.property("proposals"); + res.body.should.not.have.property("datablocks"); + }); + }); + + it("0304: should fetch all dataset relation fields if provided in the filter", async () => { + const filter = { + include: ["all"], + }; + + return request(appUrl) + .get(`/api/v4/datasets/public/findOne`) + .query({ filter: JSON.stringify(filter) }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + + res.body.should.have.property("pid"); + res.body.should.have.property("instruments"); + res.body.should.have.property("proposals"); + res.body.should.have.property("datablocks"); + res.body.should.have.property("attachments"); + res.body.should.have.property("origdatablocks"); + res.body.should.have.property("samples"); + }); + }); + + it("0305: should be able to fetch the dataset providing where filter", async () => { + const filter = { + where: { + datasetName: { + $regex: "Dataset", + $options: "i", + }, + }, + }; + + return request(appUrl) + .get(`/api/v4/datasets/public/findOne`) + .query({ filter: JSON.stringify(filter) }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + res.body.datasetName.should.match(/Dataset/i); + }); + }); + + it("0306: should be able to fetch a dataset providing all allowed filters together", async () => { + const filter = { + where: { + datasetName: { + $regex: "Dataset", + $options: "i", + }, + }, + include: ["all"], + fields: ["datasetName", "pid"], + limits: { + skip: 0, + sort: { + datasetName: "asc", + }, + }, + }; + + return request(appUrl) + .get(`/api/v4/datasets/public/findOne`) + .query({ filter: JSON.stringify(filter) }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + + res.body.should.have.property("datasetName"); + res.body.should.have.property("pid"); + res.body.should.not.have.property("description"); + + res.body.should.have.property("pid"); + res.body.should.have.property("instruments"); + res.body.should.have.property("proposals"); + res.body.should.have.property("datablocks"); + res.body.should.have.property("attachments"); + res.body.should.have.property("origdatablocks"); + res.body.should.have.property("samples"); + + res.body.datasetName.should.match(/Dataset/i); + }); + }); + + it("0307: should not be able to provide filters that are not allowed", async () => { + const filter = { + customField: { datasetName: "test" }, + }; + + return request(appUrl) + .get(`/api/v4/datasets/public/findOne`) + .query({ filter: JSON.stringify(filter) }) + .expect(TestData.BadRequestStatusCode) + .expect("Content-Type", /json/); + }); + }); + + describe("Datasets v4 public count tests", () => { + it("0401: should be able to fetch the datasets count providing where filter", async () => { + const filter = { + where: { + datasetName: { + $regex: "Dataset", + $options: "i", + }, + }, + }; + + return request(appUrl) + .get("/api/v4/datasets/public/count") + .query({ filter: JSON.stringify(filter) }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + res.body.should.have.property("count"); + res.body.count.should.be.a("number"); + res.body.count.should.be.greaterThan(0); + }); + }); + }); + + describe("Datasets v4 public findById tests", () => { + it("0501: should fetch dataset by id", () => { + return request(appUrl) + .get( + `/api/v4/datasets/public/${encodeURIComponent(derivedDatasetMinPid)}`, + ) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + + res.body.pid.should.be.eq(derivedDatasetMinPid); + }); + }); + + it("0502: should fetch dataset relation fields if provided in the filter", () => { + return request(appUrl) + .get( + `/api/v4/datasets/public/${encodeURIComponent(derivedDatasetMinPid)}?include=instruments&include=proposals`, + ) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + + res.body.should.have.property("pid"); + res.body.should.have.property("instruments"); + res.body.should.have.property("proposals"); + res.body.should.not.have.property("datablocks"); + }); + }); + + it("0503: should fetch all dataset relation fields if provided in the filter", () => { + return request(appUrl) + .get( + `/api/v4/datasets/public/${encodeURIComponent(derivedDatasetMinPid)}?include=all`, + ) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.be.a("object"); + + res.body.should.have.property("pid"); + res.body.should.have.property("instruments"); + res.body.should.have.property("proposals"); + res.body.should.have.property("datablocks"); + res.body.should.have.property("attachments"); + res.body.should.have.property("origdatablocks"); + res.body.should.have.property("samples"); + }); + }); + }); + + describe("Cleanup datasets after the tests", () => { + it("0600: delete all dataset as archivemanager", async () => { + return await request(appUrl) + .get("/api/v4/datasets") + .auth(accessTokenAdminIngestor, { type: "bearer" }) + .expect(TestData.SuccessfulDeleteStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + return processArray(res.body); + }); + }); + }); +}); diff --git a/test/Instrument.js b/test/Instrument.js index 385f5ebe9..76185f4ef 100644 --- a/test/Instrument.js +++ b/test/Instrument.js @@ -20,7 +20,7 @@ describe("0900: Instrument: instrument management, creation, update, deletion an before(() => { db.collection("Instrument").deleteMany({}); }); - beforeEach(async() => { + beforeEach(async () => { accessTokenAdminIngestor = await utils.getToken(appUrl, { username: "adminIngestor", password: TestData.Accounts["adminIngestor"]["password"], @@ -97,7 +97,7 @@ describe("0900: Instrument: instrument management, creation, update, deletion an .send(TestData.InstrumentCorrect2) .set("Accept", "application/json") .set({ Authorization: `Bearer ${accessTokenAdminIngestor}` }) - .expect(TestData.BadRequestStatusCode); + .expect(TestData.ConflictStatusCode); }); it("0050: adds invalid instrument as ingestor, which should fail because it is missing the uniqeName", async () => { diff --git a/test/TestData.js b/test/TestData.js index f16142a96..237f9e0ca 100644 --- a/test/TestData.js +++ b/test/TestData.js @@ -151,7 +151,18 @@ const TestData = { sourceFolder: faker.system.directoryPath(), owner: faker.internet.username(), contactEmail: faker.internet.email(), - datasetName: faker.string.sample(), + datasetName: `${faker.string.alphanumeric(20)} ${faker.string.sample()}`, + }, + + RawCorrectMinV4: { + ownerGroup: faker.company.name(), + creationLocation: faker.location.city(), + type: "raw", + creationTime: faker.date.past(), + sourceFolder: faker.system.directoryPath(), + owner: faker.internet.username(), + contactEmail: faker.internet.email(), + datasetName: `${faker.string.alphanumeric(20)} ${faker.string.sample()}`, }, RawCorrect: { @@ -240,6 +251,92 @@ const TestData = { keywords: ["sls", "protein"], }, + RawCorrectV4: { + principalInvestigators: ["scicatingestor@your.site"], + startTime: "2011-09-14T05:29:11.000Z", + endTime: "2011-09-14T06:31:25.000Z", + creationLocation: "/SU/XQX/RAMJET", + dataFormat: "Upchuck pre 2017", + scientificMetadata: { + approx_file_size_mb: { + value: 8500, + unit: "", + }, + beamlineParameters: { + Monostripe: "Ru/C", + "Ring current": { + v: 0.402246, + u: "A", + }, + "Beam energy": { + v: 22595, + u: "eV", + }, + }, + detectorParameters: { + Objective: 20, + Scintillator: "LAG 20um", + "Exposure time": { + v: 0.4, + u: "s", + }, + }, + scanParameters: { + "Number of projections": 1801, + "Rot Y min position": { + v: 0, + u: "deg", + }, + "Inner scan flag": 0, + "File Prefix": "817b_B2_", + "Sample In": { + v: 0, + u: "m", + }, + "Sample folder": "/ramjet/817b_B2_", + "Number of darks": 10, + "Rot Y max position": { + v: 180, + u: "deg", + }, + "Angular step": { + v: 0.1, + u: "deg", + }, + "Number of flats": 120, + "Sample Out": { + v: -0.005, + u: "m", + }, + "Flat frequency": 0, + "Number of inter-flats": 0, + }, + }, + owner: "Bertram Astor first", + ownerEmail: "scicatingestor@your.site", + orcidOfOwner: "unknown", + contactEmail: "scicatingestor@your.site", + sourceFolder: "/iramjet/tif/", + size: 0, + packedSize: 0, + numberOfFiles: 0, + numberOfFilesArchived: 0, + creationTime: "2011-09-14T06:08:25.000Z", + description: "None, The ultimate test", + datasetName: "Test raw dataset", + classification: "AV=medium,CO=low", + license: "CC BY-SA 4.0", + isPublished: false, + ownerGroup: "p13388", + accessGroups: [], + proposalIds: [""], + runNumber: "123456", + instrumentIds: ["1f016ec4-7a73-11ef-ae3e-439013069377"], + sampleIds: ["20c32b4e-7a73-11ef-9aec-5b9688aa3791i"], + type: "raw", + keywords: ["sls", "protein"], + }, + RawCorrectRandom: { principalInvestigator: faker.internet.email(), startTime: DatasetDates[0], @@ -428,7 +525,19 @@ const TestData = { sourceFolder: faker.system.directoryPath(), creationTime: faker.date.past(), ownerGroup: faker.string.alphanumeric(6), - datasetName: faker.string.sample(), + datasetName: `${faker.string.alphanumeric(20)} ${faker.string.sample()}`, + type: "derived", + }, + + DerivedCorrectMinV4: { + inputDatasets: [faker.string.uuid()], + usedSoftware: [faker.internet.url()], + owner: faker.internet.username(), + contactEmail: faker.internet.email(), + sourceFolder: faker.system.directoryPath(), + creationTime: faker.date.past(), + ownerGroup: faker.string.alphanumeric(6), + datasetName: `${faker.string.alphanumeric(20)} ${faker.string.sample()}`, type: "derived", }, @@ -451,7 +560,7 @@ const TestData = { creationTime: "2017-01-31T09:20:19.562Z", keywords: ["Test", "Derived", "Science", "Math"], description: "Some fancy description", - datasetName: "Test derviced dataset", + datasetName: "Test derived dataset", isPublished: false, ownerGroup: "p34123", accessGroups: [], @@ -462,6 +571,36 @@ const TestData = { //sampleId: "20c32b4e-7a73-11ef-9aec-5b9688aa3791i", }, + DerivedCorrectV4: { + principalInvestigators: ["egon.meier@web.de"], + inputDatasets: ["/data/input/file1.dat"], + usedSoftware: [ + "https://gitlab.psi.ch/ANALYSIS/csaxs/commit/7d5888bfffc440bb613bc7fa50adc0097853446c", + ], + jobParameters: { + nscans: 10, + }, + jobLogData: "Output of log file...", + owner: "Egon Meier", + ownerEmail: "egon.meier@web.de", + contactEmail: "egon.meier@web.de", + sourceFolder: "/data/example/2017", + size: 0, + numberOfFiles: 0, + creationTime: "2017-01-31T09:20:19.562Z", + keywords: ["Test", "Derived", "Science", "Math"], + description: "Some fancy description", + datasetName: "Test derived dataset", + isPublished: false, + ownerGroup: "p34123", + accessGroups: [], + type: "derived", + proposalIds: ["10.540.16635/20110123"], + runNumber: "654321", + //instrumentId: "1f016ec4-7a73-11ef-ae3e-439013069377", + //sampleId: "20c32b4e-7a73-11ef-9aec-5b9688aa3791i", + }, + DerivedWrong: { investigator: "egon.meier@web.de", jobParameters: { @@ -480,6 +619,25 @@ const TestData = { type: "derived", }, + DerivedWrongV4: { + principalInvestigators: ["egon.meier@web.de"], + jobParameters: { + nscans: 10, + }, + jobLogData: "Output of log file...", + owner: "Egon Meier", + ownerEmail: "egon.meier@web.de", + contactEmail: "egon.meier@web.de", + sourceFolder: "/data/example/2017", + creationTime: "2017-01-31T09:20:19.562Z", + keywords: ["Test", "Custom", "Science", "Math"], + description: "Some fancy description", + isPublished: false, + ownerGroup: "p34123", + proposalId: "abcdefg", // should be proposalIds for custom types + type: "custom", + }, + CustomDatasetCorrectMin: { principalInvestigators: [faker.internet.email()], owner: faker.internet.username(), @@ -487,7 +645,7 @@ const TestData = { sourceFolder: faker.system.directoryPath(), creationTime: faker.date.past(), ownerGroup: faker.string.alphanumeric(6), - datasetName: faker.string.sample(), + datasetName: `${faker.string.alphanumeric(20)} ${faker.string.sample()}`, type: "custom", }, @@ -510,7 +668,7 @@ const TestData = { creationTime: "2017-01-31T09:20:19.562Z", keywords: ["Test", "Derived", "Science", "Math"], description: "Some fancy description", - datasetName: "Test derviced dataset", + datasetName: "Test derived dataset", isPublished: false, ownerGroup: "p34123", accessGroups: [],