From 38cdd90095117536882ef3c63fcf7a2b567dd8b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rard=20Dethier?= Date: Tue, 14 May 2024 09:02:05 +0200 Subject: [PATCH] feat: add recoverable secrets. logion-network/logion-internal#1251 --- resources/mail/recoverable-secret-added.pug | 12 + resources/schemas.json | 20 + .../controllers/adapters/locrequestadapter.ts | 7 +- .../controllers/collection.controller.ts | 2 +- src/logion/controllers/components.ts | 8 + .../controllers/locrequest.controller.ts | 102 +++- .../migration/1715669632901-AddSecrets.ts | 16 + src/logion/model/idenfy.ts | 20 + src/logion/model/loc_fees.ts | 48 ++ src/logion/model/loc_items.ts | 77 +++ src/logion/model/loc_lifecycle.ts | 187 +++++++ src/logion/model/loc_seal.ts | 36 ++ src/logion/model/loc_void.ts | 16 + src/logion/model/loc_vos.ts | 42 ++ src/logion/model/locrequest.model.ts | 529 +++++------------- src/logion/services/idenfy/idenfy.service.ts | 2 +- .../services/locsynchronization.service.ts | 6 +- src/logion/services/notification.service.ts | 1 + test/integration/migration/migration.spec.ts | 2 +- .../model/locrequest.model.spec.ts | 5 +- .../controllers/collection.controller.spec.ts | 3 +- .../locrequest.controller.creation.spec.ts | 2 +- .../locrequest.controller.fetch.spec.ts | 6 +- .../locrequest.controller.items.spec.ts | 4 +- .../locrequest.controller.secrets.spec.ts | 77 +++ .../locrequest.controller.shared.ts | 17 +- .../protectionrequest.controller.spec.ts | 3 +- .../vaulttransferrequest.controller.spec.ts | 3 +- test/unit/model/locrequest.model.spec.ts | 81 ++- .../services/idenfy/idenfy.service.spec.ts | 4 +- .../locsynchronization.service.spec.ts | 7 +- test/unit/services/notification-test-data.ts | 2 +- 32 files changed, 891 insertions(+), 456 deletions(-) create mode 100644 resources/mail/recoverable-secret-added.pug create mode 100644 src/logion/migration/1715669632901-AddSecrets.ts create mode 100644 src/logion/model/idenfy.ts create mode 100644 src/logion/model/loc_fees.ts create mode 100644 src/logion/model/loc_items.ts create mode 100644 src/logion/model/loc_lifecycle.ts create mode 100644 src/logion/model/loc_seal.ts create mode 100644 src/logion/model/loc_void.ts create mode 100644 src/logion/model/loc_vos.ts create mode 100644 test/unit/controllers/locrequest.controller.secrets.spec.ts diff --git a/resources/mail/recoverable-secret-added.pug b/resources/mail/recoverable-secret-added.pug new file mode 100644 index 0000000..ded3eed --- /dev/null +++ b/resources/mail/recoverable-secret-added.pug @@ -0,0 +1,12 @@ +| logion notification - Recoverable secret added +| Dear #{walletUser.firstName} #{walletUser.lastName}, +| +| You receive this message because you just added a recoverable secret with name #{secretName} to your Identity LOC #{loc.id}. +| +| Please note, you can't answer this email. The logion network will never ask you to provide any kind of information or access to a web address through its email notifications. +| +| As a reminder, find below all details of the Legal Officer you'll have to contact in order to recover the secret's value: +| +include /legal-officer-details.pug +| +include /footer.pug diff --git a/resources/schemas.json b/resources/schemas.json index 0abcf68..4e7cc76 100644 --- a/resources/schemas.json +++ b/resources/schemas.json @@ -7,6 +7,19 @@ "paths": {}, "components": { "schemas": { + "AddSecretView": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Recoverable secret's name" + }, + "value": { + "type": "string", + "description": "Recoverable secret" + } + } + }, "Submittable": { "type": "object", "properties": { @@ -1036,6 +1049,13 @@ }, "collectionParams": { "$ref": "#/components/schemas/CollectionParamsView" + }, + "secrets": { + "type": "array", + "description": "The recoverable secrets attached to this LOC", + "items": { + "$ref": "#/components/schemas/AddSecretView" + } } }, "title": "LocRequestView", diff --git a/src/logion/controllers/adapters/locrequestadapter.ts b/src/logion/controllers/adapters/locrequestadapter.ts index 04170f6..a7908f2 100644 --- a/src/logion/controllers/adapters/locrequestadapter.ts +++ b/src/logion/controllers/adapters/locrequestadapter.ts @@ -4,9 +4,6 @@ import { IdenfyService } from "../../services/idenfy/idenfy.service.js"; import { LocRequestAggregateRoot, LocRequestRepository, - ItemLifecycle, - LocFees, - CollectionParams } from "../../model/locrequest.model.js"; import { PostalAddress } from "../../model/postaladdress.js"; import { UserIdentity } from "../../model/useridentity.js"; @@ -14,6 +11,9 @@ import { components } from "../components.js"; import { VoteRepository, VoteAggregateRoot } from "../../model/vote.model.js"; import { VerifiedIssuerAggregateRoot, VerifiedIssuerSelectionRepository } from "../../model/verifiedissuerselection.model.js"; import { Fees, ValidAccountId, AccountId } from "@logion/node-api"; +import { ItemLifecycle } from "src/logion/model/loc_lifecycle.js"; +import { LocFees } from "src/logion/model/loc_fees.js"; +import { CollectionParams } from "src/logion/model/loc_vos.js"; export type UserPrivateData = { identityLocId: string | undefined, @@ -122,6 +122,7 @@ export class LocRequestAdapter { sponsorshipId: locDescription.sponsorshipId?.toString(), fees: this.toLocFeesView(locDescription.fees), collectionParams: this.toCollectionParamsView(locDescription.collectionParams), + secrets: request.getSecrets(viewer), }; const voidInfo = request.getVoidInfo(); if(voidInfo !== null) { diff --git a/src/logion/controllers/collection.controller.ts b/src/logion/controllers/collection.controller.ts index e848855..799c186 100644 --- a/src/logion/controllers/collection.controller.ts +++ b/src/logion/controllers/collection.controller.ts @@ -17,7 +17,6 @@ import { OpenAPIV3 } from "express-oas-generator"; import moment from "moment"; import { LocRequestRepository, - FileDescription, LocRequestAggregateRoot, LocFileDelivered } from "../model/locrequest.model.js"; @@ -49,6 +48,7 @@ import { OwnershipCheckService } from "../services/ownershipcheck.service.js"; import { RestrictedDeliveryService } from "../services/restricteddelivery.service.js"; import { downloadAndClean } from "../lib/http.js"; import { LocRequestService } from "../services/locrequest.service.js"; +import { FileDescription } from "../model/loc_items.js"; type CollectionItemView = components["schemas"]["CollectionItemView"]; type CollectionItemsView = components["schemas"]["CollectionItemsView"]; diff --git a/src/logion/controllers/components.ts b/src/logion/controllers/components.ts index 8ed718b..126d219 100644 --- a/src/logion/controllers/components.ts +++ b/src/logion/controllers/components.ts @@ -13,6 +13,12 @@ export type webhooks = Record; export interface components { schemas: { + AddSecretView: { + /** @description Recoverable secret's name */ + name?: string; + /** @description Recoverable secret */ + value?: string; + }; Submittable: { /** @description The address of the submitter */ submitter?: components["schemas"]["SupportedAccountId"]; @@ -555,6 +561,8 @@ export interface components { sponsorshipId?: string; fees?: components["schemas"]["LocFeesView"]; collectionParams?: components["schemas"]["CollectionParamsView"]; + /** @description The recoverable secrets attached to this LOC */ + secrets?: components["schemas"]["AddSecretView"][]; }; /** * LocPublicView diff --git a/src/logion/controllers/locrequest.controller.ts b/src/logion/controllers/locrequest.controller.ts index aa1d49e..13db321 100644 --- a/src/logion/controllers/locrequest.controller.ts +++ b/src/logion/controllers/locrequest.controller.ts @@ -8,18 +8,8 @@ import { components } from "./components.js"; import { LocRequestRepository, LocRequestFactory, - LocRequestDescription, LocRequestAggregateRoot, FetchLocRequestsSpecification, - LocRequestDecision, - FileDescription, - MetadataItemParams, - FileParams, - StoredFile, - LinkParams, - SubmissionType, - LocFees, - CollectionParams } from "../model/locrequest.model.js"; import { getRequestBody, @@ -56,6 +46,10 @@ import { SponsorshipService } from "../services/sponsorship.service.js"; import { Hash } from "../lib/crypto/hashing.js"; import { toBigInt } from "../lib/convert.js"; import { LocalsObject } from "pug"; +import { SubmissionType } from "../model/loc_lifecycle.js"; +import { CollectionParams, LocRequestDecision, LocRequestDescription } from "../model/loc_vos.js"; +import { LocFees } from "../model/loc_fees.js"; +import { FileDescription, FileParams, LinkParams, MetadataItemParams, StoredFile } from "../model/loc_items.js"; const { logger } = Log; @@ -110,6 +104,8 @@ export function fillInSpec(spec: OpenAPIV3.Document): void { LocRequestController.submitLocRequest(spec); LocRequestController.cancelLocRequest(spec); LocRequestController.reworkLocRequest(spec); + LocRequestController.addSecret(spec); + LocRequestController.removeSecret(spec); } type CreateLocRequestView = components["schemas"]["CreateLocRequestView"]; @@ -131,6 +127,7 @@ type CloseView = components["schemas"]["CloseView"]; type OpenView = components["schemas"]["OpenView"]; type LocFeesView = components["schemas"]["LocFeesView"]; type CollectionParamsView = components["schemas"]["CollectionParamsView"]; +type AddSecretView = components["schemas"]["AddSecretView"]; @injectable() @Controller('/loc-request') @@ -1567,4 +1564,89 @@ export class LocRequestController extends ApiController { } } } + + static addSecret(spec: OpenAPIV3.Document) { + const operationObject = spec.paths["/api/loc-request/{requestId}/secrets"].post!; + operationObject.summary = "Creates a new recoverable secret"; + operationObject.description = "The authenticated user must be the LOC requester"; + operationObject.requestBody = getRequestBody({ + description: "Secret creation data", + view: "AddSecretView", + }); + operationObject.responses = getDefaultResponsesNoContent(); + setPathParameters(operationObject, { + 'requestId': "The ID of the LOC", + }); + } + + @Async() + @HttpPost('/:requestId/secrets') + @SendsResponse() + async addSecret(body: AddSecretView, requestId: string) { + const authenticatedUser = await this.authenticationService.authenticatedUser(this.request); + + const name = requireDefined(body.name, () => badRequest("Missing recoverable secret name")); + const value = requireDefined(body.value, () => badRequest("Missing recoverable secret value")); + const request = await this.locRequestService.update(requestId, async request => { + if(!request.isRequester(authenticatedUser.validAccountId)) { + throw unauthorized("Only requester can add recoverable secrets"); + } + try { + request.addSecret(name, value); + } catch(e) { + if(e instanceof Error) { + throw badRequest(e.message); + } else { + throw e; + } + } + }); + + const description = request.getDescription(); + const userIdentity = requireDefined(description.userIdentity); + this.getNotificationInfo(description, userIdentity) + .then(info => this.notificationService.notify(info.legalOfficerEMail, "recoverable-secret-added", { + ...info.data, + secretName: name, + })) + .catch(error => logger.error(error)); + + this.response.sendStatus(204); + } + + static removeSecret(spec: OpenAPIV3.Document) { + const operationObject = spec.paths["/api/loc-request/{requestId}/secrets/{secretName}"].delete!; + operationObject.summary = "Removes an existing recoverable secret"; + operationObject.description = "The authenticated user must be the LOC requester"; + operationObject.responses = getDefaultResponsesNoContent(); + setPathParameters(operationObject, { + 'requestId': "The ID of the LOC", + 'secretName': "The secret's name" + }); + } + + @Async() + @HttpDelete('/:requestId/secrets/:secretName') + @SendsResponse() + async removeSecret(_body: never, requestId: string, secretName: string) { + const authenticatedUser = await this.authenticationService.authenticatedUser(this.request); + + const decodedSecretName = decodeURIComponent(secretName); + await this.locRequestService.update(requestId, async request => { + if(!request.isRequester(authenticatedUser.validAccountId)) { + throw unauthorized("Only requester can add recoverable secrets"); + } + try { + request.removeSecret(decodedSecretName); + } catch(e) { + if(e instanceof Error) { + throw badRequest(e.message); + } else { + throw e; + } + } + }); + + this.response.sendStatus(204); + } } diff --git a/src/logion/migration/1715669632901-AddSecrets.ts b/src/logion/migration/1715669632901-AddSecrets.ts new file mode 100644 index 0000000..9720226 --- /dev/null +++ b/src/logion/migration/1715669632901-AddSecrets.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddSecrets1715669632901 implements MigrationInterface { + name = 'AddSecrets1715669632901' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "loc_secret" ("request_id" uuid NOT NULL, "name" character varying(255) NOT NULL, "value" character varying(4096) NOT NULL, CONSTRAINT "UQ_loc_secret_request_id_name" UNIQUE ("request_id", "name"), CONSTRAINT "PK_loc_secret" PRIMARY KEY ("request_id", "name"))`); + await queryRunner.query(`ALTER TABLE "loc_secret" ADD CONSTRAINT "FK_loc_secret_request_id" FOREIGN KEY ("request_id") REFERENCES "loc_request"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "loc_secret" DROP CONSTRAINT "FK_loc_secret_request_id"`); + await queryRunner.query(`DROP TABLE "loc_secret"`); + } + +} diff --git a/src/logion/model/idenfy.ts b/src/logion/model/idenfy.ts new file mode 100644 index 0000000..e6f8789 --- /dev/null +++ b/src/logion/model/idenfy.ts @@ -0,0 +1,20 @@ +import { Column } from "typeorm"; + +import { IdenfyVerificationStatus } from "../services/idenfy/idenfy.types"; + +export type EmbeddableIdenfyVerificationStatus = 'PENDING' | IdenfyVerificationStatus; + +export class EmbeddableIdenfyVerification { + + @Column("varchar", { name: "idenfy_auth_token", length: 40, nullable: true }) + authToken?: string | null; + + @Column("varchar", { name: "idenfy_scan_ref", length: 40, nullable: true }) + scanRef?: string | null; + + @Column("varchar", { name: "idenfy_status", length: 255, nullable: true }) + status?: EmbeddableIdenfyVerificationStatus | null; + + @Column("text", { name: "idenfy_callback_payload", nullable: true }) + callbackPayload?: string | null; +} diff --git a/src/logion/model/loc_fees.ts b/src/logion/model/loc_fees.ts new file mode 100644 index 0000000..fdc45b9 --- /dev/null +++ b/src/logion/model/loc_fees.ts @@ -0,0 +1,48 @@ +import { Column } from "typeorm"; + +import { AMOUNT_PRECISION } from "./fees.js"; +import { toBigInt } from "../lib/convert.js"; + +export interface LocFees { + readonly valueFee?: bigint; + readonly legalFee?: bigint; + readonly collectionItemFee?: bigint; + readonly tokensRecordFee?: bigint; +} + +export class EmbeddableLocFees { + + @Column("numeric", { name: "value_fee", precision: AMOUNT_PRECISION, nullable: true }) + valueFee?: string | null; + + @Column("numeric", { name: "legal_fee", precision: AMOUNT_PRECISION, nullable: true }) + legalFee?: string | null; + + @Column("numeric", { name: "collection_item_fee", precision: AMOUNT_PRECISION, nullable: true }) + collectionItemFee?: string | null; + + @Column("numeric", { name: "tokens_record_fee", precision: AMOUNT_PRECISION, nullable: true }) + tokensRecordFee?: string | null; + + static from(fees: LocFees | undefined): EmbeddableLocFees | undefined { + if(fees) { + const embeddable = new EmbeddableLocFees(); + embeddable.valueFee = fees.valueFee?.toString(); + embeddable.legalFee = fees.legalFee?.toString(); + embeddable.collectionItemFee = fees.collectionItemFee?.toString(); + embeddable.tokensRecordFee = fees.tokensRecordFee?.toString(); + return embeddable; + } else { + return undefined; + } + } + + to(): LocFees { + return { + valueFee: toBigInt(this.valueFee), + legalFee: toBigInt(this.legalFee), + collectionItemFee: toBigInt(this.collectionItemFee), + tokensRecordFee: toBigInt(this.tokensRecordFee), + } + } +} diff --git a/src/logion/model/loc_items.ts b/src/logion/model/loc_items.ts new file mode 100644 index 0000000..c43b86d --- /dev/null +++ b/src/logion/model/loc_items.ts @@ -0,0 +1,77 @@ +import { Fees, Hash, UUID, ValidAccountId } from "@logion/node-api"; + +import { EmbeddableLifecycle, ItemLifecycle } from "./loc_lifecycle.js"; +import { EmbeddableAccountId } from "./supportedaccountid.model.js"; + +interface FileDescriptionMandatoryFields { + readonly name: string; + readonly hash: Hash; + readonly submitter: ValidAccountId; + readonly restrictedDelivery: boolean; + readonly size: number; +} + +export interface FileParams extends FileDescriptionMandatoryFields { + readonly cid?: string; + readonly contentType?: string; + readonly nature: string; +} + +export interface FileDescription extends FileDescriptionMandatoryFields, ItemLifecycle { + readonly oid?: number; + readonly cid?: string; + readonly contentType?: string; + readonly nature?: string; + readonly fees?: Fees; + readonly storageFeePaidBy?: string; +} + +export interface StoredFile { + readonly name: string; + readonly size: number; + readonly cid?: string; + readonly contentType?: string; +} + +export interface Submitted { + submitter?: EmbeddableAccountId; + lifecycle?: EmbeddableLifecycle; +} + +interface MetadataItemDescriptionMandatoryFields { + readonly submitter: ValidAccountId; +} + +export interface MetadataItemParams extends MetadataItemDescriptionMandatoryFields { + readonly name: string; + readonly value: string; +} + +export interface MetadataItemDescription extends MetadataItemDescriptionMandatoryFields, ItemLifecycle { + readonly name?: string; + readonly nameHash: Hash; + readonly value?: string; + readonly fees?: Fees; +} + +export interface LinkParams { + readonly target: string; + readonly nature: string; + readonly submitter: ValidAccountId; +} + +export interface LinkDescription extends LinkParams, ItemLifecycle { + readonly fees?: Fees; +} + +export interface LocItems { + metadataNameHashes: Hash[]; + fileHashes: Hash[]; + linkTargets: UUID[]; +} + +export const EMPTY_ITEMS: LocItems = { + fileHashes: [], + linkTargets: [], + metadataNameHashes: [], +}; diff --git a/src/logion/model/loc_lifecycle.ts b/src/logion/model/loc_lifecycle.ts new file mode 100644 index 0000000..47a3de1 --- /dev/null +++ b/src/logion/model/loc_lifecycle.ts @@ -0,0 +1,187 @@ +import { Log, badRequest } from "@logion/rest-api-core"; +import { Column } from "typeorm"; +import moment, { Moment } from "moment"; + +import { components } from "../controllers/components.js"; +import { isTruthy } from "../lib/db/collections.js"; + +const { logger } = Log; + +export class EmbeddableLifecycle { + + requestReview() { + if (this.status !== "DRAFT") { + throw badRequest(`Cannot request a review on item with status ${ this.status }`); + } + this.status = "REVIEW_PENDING"; + } + + accept() { + if (this.status !== "REVIEW_PENDING") { + throw badRequest(`Cannot accept an item with status ${ this.status }`); + } + this.status = "REVIEW_ACCEPTED"; + this.reviewedOn = moment().toDate(); + } + + reject(reason: string) { + if (this.status !== "REVIEW_PENDING") { + throw badRequest(`Cannot reject an item with status ${ this.status }`); + } + this.status = "REVIEW_REJECTED"; + this.rejectReason = reason; + this.reviewedOn = moment().toDate(); + } + + prePublishOrAcknowledge(isAcknowledged: boolean, addedOn?: Moment) { + const acknowledgedOrPublished = isAcknowledged ? "ACKNOWLEDGED" : "PUBLISHED"; + if (this.status !== "REVIEW_ACCEPTED" && this.status !== acknowledgedOrPublished) { + throw badRequest(`Cannot pre-publish/-acknowledge item with status ${ this.status }`); + } + this.status = acknowledgedOrPublished; + this.addedOn = addedOn?.toDate(); + } + + cancelPrePublishOrAcknowledge(isAcknowledged: boolean) { + const acknowledgedOrPublished = isAcknowledged ? "ACKNOWLEDGED" : "PUBLISHED"; + if (this.status !== acknowledgedOrPublished) { + throw badRequest(`Cannot cancel pre-publish/-acknowledge of item with status ${ this.status }`); + } + if(this.addedOn) { + throw badRequest(`Cannot cancel, published/acknowledged`); + } + this.status = "REVIEW_ACCEPTED"; + } + + preAcknowledge(expectVerifiedIssuer: boolean, byVerifiedIssuer: boolean, acknowledgedOn?: Moment) { + if (this.status !== "PUBLISHED" && this.status !== "ACKNOWLEDGED") { + throw badRequest(`Cannot confirm-acknowledge item with status ${ this.status }`); + } + if(byVerifiedIssuer) { + this.acknowledgedByVerifiedIssuer = true; + this.acknowledgedByVerifiedIssuerOn = acknowledgedOn ? acknowledgedOn.toDate() : undefined; + } else { + this.acknowledgedByOwner = true; + this.acknowledgedByOwnerOn = acknowledgedOn ? acknowledgedOn.toDate() : undefined; + } + if( + (this.acknowledgedByOwner && this.acknowledgedByVerifiedIssuer) + || (!expectVerifiedIssuer && this.acknowledgedByOwner) + ) { + this.status = "ACKNOWLEDGED"; + } + } + + cancelPreAcknowledge(byVerifiedIssuer: boolean) { + if (this.status !== "ACKNOWLEDGED") { + throw badRequest(`Cannot confirm-acknowledge item with status ${ this.status }`); + } + if(this.acknowledgedByVerifiedIssuerOn || this.acknowledgedByOwnerOn) { + throw badRequest(`Cannot cancel, acknowledged`); + } + if(byVerifiedIssuer) { + this.acknowledgedByVerifiedIssuer = false; + } else { + this.acknowledgedByOwner = false; + } + if(this.status === "ACKNOWLEDGED") { + this.status = "PUBLISHED"; + } + } + + isAcknowledged(): boolean { + return this.status === "ACKNOWLEDGED"; + } + + isAcknowledgedOnChain(expectVerifiedIssuer: boolean): boolean { + return this.isAcknowledged() && ( + (expectVerifiedIssuer && isTruthy(this.acknowledgedByOwnerOn) && isTruthy(this.acknowledgedByVerifiedIssuerOn)) + || (!expectVerifiedIssuer && isTruthy(this.acknowledgedByOwnerOn)) + ); + } + + isPublished(): boolean { + return this.status === "PUBLISHED"; + } + + isPublishedOnChain(): boolean { + return this.isPublished() && isTruthy(this.addedOn); + } + + setAddedOn(addedOn: Moment) { + if (this.addedOn) { + logger.warn("Item added on date is already set"); + } + this.addedOn = addedOn.toDate(); + if(this.status === "REVIEW_ACCEPTED") { + this.status = "PUBLISHED"; + } + } + + getDescription(): ItemLifecycle { + return { + status: this.status!, + rejectReason: this.status === "REVIEW_REJECTED" ? this.rejectReason! : undefined, + reviewedOn: this.reviewedOn ? moment(this.reviewedOn) : undefined, + addedOn: this.addedOn ? moment(this.addedOn) : undefined, + acknowledgedByOwnerOn: this.acknowledgedByOwnerOn ? moment(this.acknowledgedByOwnerOn) : undefined, + acknowledgedByVerifiedIssuerOn: this.acknowledgedByVerifiedIssuerOn ? moment(this.acknowledgedByVerifiedIssuerOn) : undefined, + } + } + + static fromSubmissionType(submissionType: SubmissionType) { + const lifecycle = new EmbeddableLifecycle(); + lifecycle.status = + submissionType === "DIRECT_BY_REQUESTER" ? "PUBLISHED" : + submissionType === "MANUAL_BY_OWNER" ? "REVIEW_ACCEPTED" : "DRAFT"; + lifecycle.acknowledgedByOwner = submissionType === "MANUAL_BY_OWNER"; + lifecycle.acknowledgedByOwnerOn = submissionType === "MANUAL_BY_OWNER" ? moment().toDate() : undefined; + lifecycle.acknowledgedByVerifiedIssuer = false; + return lifecycle; + } + + static default(status: ItemStatus | undefined) { + const lifecycle = new EmbeddableLifecycle(); + lifecycle.status = status; + lifecycle.acknowledgedByOwner = false; + lifecycle.acknowledgedByVerifiedIssuer = false; + return lifecycle; + } + + @Column("varchar", { length: 255 }) + status?: ItemStatus + + @Column("varchar", { length: 255, name: "reject_reason", nullable: true }) + rejectReason?: string | null; + + @Column("timestamp without time zone", { name: "reviewed_on", nullable: true }) + reviewedOn?: Date; + + @Column("timestamp without time zone", { name: "added_on", nullable: true }) + addedOn?: Date; + + @Column("timestamp without time zone", { name: "acknowledged_by_owner_on", nullable: true }) + acknowledgedByOwnerOn?: Date; + + @Column("boolean", { name: "acknowledged_by_owner", default: false }) + acknowledgedByOwner?: boolean; + + @Column("timestamp without time zone", { name: "acknowledged_by_verified_issuer_on", nullable: true }) + acknowledgedByVerifiedIssuerOn?: Date; + + @Column("boolean", { name: "acknowledged_by_verified_issuer", default: false }) + acknowledgedByVerifiedIssuer?: boolean; +} + +export type ItemStatus = components["schemas"]["ItemStatus"]; + +export type SubmissionType = "MANUAL_BY_USER" | "MANUAL_BY_OWNER" | "DIRECT_BY_REQUESTER"; // "USER" can be Requester or VI. + +export interface ItemLifecycle { + readonly status: ItemStatus; + readonly rejectReason?: string; + readonly reviewedOn?: Moment; + readonly addedOn?: Moment; + readonly acknowledgedByOwnerOn?: Moment; + readonly acknowledgedByVerifiedIssuerOn?: Moment; +} diff --git a/src/logion/model/loc_seal.ts b/src/logion/model/loc_seal.ts new file mode 100644 index 0000000..faabcc0 --- /dev/null +++ b/src/logion/model/loc_seal.ts @@ -0,0 +1,36 @@ +import { Column } from "typeorm"; + +import { PublicSeal, Seal } from "../services/seal.service"; +import { Hash } from "@logion/node-api"; + +export class EmbeddableSeal { + @Column({ name: "seal_salt", type: "uuid", nullable: true }) + salt?: string | null; + + @Column({ name: "seal_hash", type: "varchar", length: 255, nullable: true }) + hash?: string | null; + + @Column({ name: "seal_version", type: "integer", default: 0 }) + version?: number | null; + + static from(seal: Seal | undefined): EmbeddableSeal | undefined { + if (!seal) { + return undefined; + } + const result = new EmbeddableSeal(); + result.hash = seal.hash.toHex(); + result.salt = seal.salt; + result.version = seal.version; + return result; + } +} + +export function toPublicSeal(embedded: EmbeddableSeal | undefined): PublicSeal | undefined { + return embedded && embedded.hash && embedded.version !== undefined && embedded.version !== null + ? + { + hash: Hash.fromHex(embedded.hash), + version: embedded.version + } + : undefined; +} diff --git a/src/logion/model/loc_void.ts b/src/logion/model/loc_void.ts new file mode 100644 index 0000000..88469f3 --- /dev/null +++ b/src/logion/model/loc_void.ts @@ -0,0 +1,16 @@ +import { Moment } from "moment"; +import { Column } from "typeorm"; + +export class EmbeddableVoidInfo { + + @Column("text", { name: "void_reason", nullable: true }) + reason?: string | null; + + @Column("timestamp without time zone", { name: "voided_on", nullable: true }) + voidedOn?: string | null; +} + +export interface VoidInfo { + readonly reason: string; + readonly voidedOn: Moment | null; +} diff --git a/src/logion/model/loc_vos.ts b/src/logion/model/loc_vos.ts new file mode 100644 index 0000000..26b708e --- /dev/null +++ b/src/logion/model/loc_vos.ts @@ -0,0 +1,42 @@ +import { UUID, ValidAccountId } from "@logion/node-api"; + +import { components } from "../controllers/components.js"; +import { UserIdentity } from "./useridentity.js"; +import { PostalAddress } from "./postaladdress.js"; +import { PublicSeal } from "../services/seal.service.js"; +import { LocFees } from "./loc_fees.js"; + +export type LocType = components["schemas"]["LocType"]; + +export interface CollectionParams { + lastBlockSubmission: bigint | undefined; + maxSize: number | undefined; + canUpload: boolean; +} + +export interface LocRequestDescription { + readonly requesterAddress?: ValidAccountId; + readonly requesterIdentityLoc?: string; + readonly ownerAddress: ValidAccountId; + readonly description: string; + readonly createdOn: string; + readonly userIdentity: UserIdentity | undefined; + readonly userPostalAddress: PostalAddress | undefined; + readonly locType: LocType; + readonly seal?: PublicSeal; + readonly company?: string; + readonly template?: string; + readonly sponsorshipId?: UUID; + readonly fees: LocFees; + readonly collectionParams?: CollectionParams; +} + +export interface LocRequestDecision { + readonly decisionOn: string; + readonly rejectReason?: string; +} + +export interface RecoverableSecret { + readonly name: string; + readonly value: string; +} diff --git a/src/logion/model/locrequest.model.ts b/src/logion/model/locrequest.model.ts index a55176d..11ccf20 100644 --- a/src/logion/model/locrequest.model.ts +++ b/src/logion/model/locrequest.model.ts @@ -8,15 +8,14 @@ import { appDataSource, Log, requireDefined, badRequest } from "@logion/rest-api import { components } from "../controllers/components.js"; import { EmbeddableUserIdentity, toUserIdentity, UserIdentity } from "./useridentity.js"; -import { orderAndMap, HasIndex, isTruthy } from "../lib/db/collections.js"; -import { deleteIndexedChild, Child, saveIndexedChildren, saveChildren } from "./child.js"; +import { orderAndMap, HasIndex } from "../lib/db/collections.js"; +import { deleteIndexedChild, Child, saveIndexedChildren, saveChildren, deleteChild } from "./child.js"; import { EmbeddablePostalAddress, PostalAddress } from "./postaladdress.js"; -import { LATEST_SEAL_VERSION, PersonalInfoSealService, PublicSeal, Seal } from "../services/seal.service.js"; +import { LATEST_SEAL_VERSION, PersonalInfoSealService, Seal } from "../services/seal.service.js"; import { PersonalInfo } from "./personalinfo.model.js"; import { IdenfyCallbackPayload, IdenfyVerificationSession, - IdenfyVerificationStatus } from "../services/idenfy/idenfy.types.js"; import { AMOUNT_PRECISION, EmbeddableStorageFees } from "./fees.js"; import { @@ -26,395 +25,16 @@ import { } from "./supportedaccountid.model.js"; import { SelectQueryBuilder } from "typeorm/query-builder/SelectQueryBuilder.js"; import { Hash, HashTransformer } from "../lib/crypto/hashing.js"; -import { toBigInt } from "../lib/convert.js"; +import { EmbeddableLifecycle, ItemStatus, SubmissionType } from "./loc_lifecycle.js"; +import { LocRequestDecision, LocRequestDescription, RecoverableSecret } from "./loc_vos.js"; +import { EmbeddableSeal, toPublicSeal } from "./loc_seal.js"; +import { EmbeddableLocFees } from "./loc_fees.js"; +import { EmbeddableVoidInfo, VoidInfo } from "./loc_void.js"; +import { FileDescription, FileParams, LinkDescription, LinkParams, LocItems, MetadataItemDescription, MetadataItemParams, StoredFile, Submitted } from "./loc_items.js"; +import { EmbeddableIdenfyVerification } from "./idenfy.js"; const { logger } = Log; -export type LocRequestStatus = components["schemas"]["LocRequestStatus"]; -export type LocType = components["schemas"]["LocType"]; -export type IdentityLocType = components["schemas"]["IdentityLocType"]; -export type ItemStatus = components["schemas"]["ItemStatus"]; - -export interface LocFees { - readonly valueFee?: bigint; - readonly legalFee?: bigint; - readonly collectionItemFee?: bigint; - readonly tokensRecordFee?: bigint; -} - -export interface CollectionParams { - lastBlockSubmission: bigint | undefined; - maxSize: number | undefined; - canUpload: boolean; -} - -export interface LocRequestDescription { - readonly requesterAddress?: ValidAccountId; - readonly requesterIdentityLoc?: string; - readonly ownerAddress: ValidAccountId; - readonly description: string; - readonly createdOn: string; - readonly userIdentity: UserIdentity | undefined; - readonly userPostalAddress: PostalAddress | undefined; - readonly locType: LocType; - readonly seal?: PublicSeal; - readonly company?: string; - readonly template?: string; - readonly sponsorshipId?: UUID; - readonly fees: LocFees; - readonly collectionParams?: CollectionParams; -} - -export interface LocRequestDecision { - readonly decisionOn: string; - readonly rejectReason?: string; -} - -interface FileDescriptionMandatoryFields { - readonly name: string; - readonly hash: Hash; - readonly submitter: ValidAccountId; - readonly restrictedDelivery: boolean; - readonly size: number; -} - -export interface FileParams extends FileDescriptionMandatoryFields { - readonly cid?: string; - readonly contentType?: string; - readonly nature: string; -} - -export interface StoredFile { - readonly name: string; - readonly size: number; - readonly cid?: string; - readonly contentType?: string; -} - -export interface FileDescription extends FileDescriptionMandatoryFields, ItemLifecycle { - readonly oid?: number; - readonly cid?: string; - readonly contentType?: string; - readonly nature?: string; - readonly fees?: Fees; - readonly storageFeePaidBy?: string; -} - -interface MetadataItemDescriptionMandatoryFields { - readonly submitter: ValidAccountId; -} - -export interface MetadataItemParams extends MetadataItemDescriptionMandatoryFields { - readonly name: string; - readonly value: string; -} - -export interface MetadataItemDescription extends MetadataItemDescriptionMandatoryFields, ItemLifecycle { - readonly name?: string; - readonly nameHash: Hash; - readonly value?: string; - readonly fees?: Fees; -} - -export interface ItemLifecycle { - readonly status: ItemStatus; - readonly rejectReason?: string; - readonly reviewedOn?: Moment; - readonly addedOn?: Moment; - readonly acknowledgedByOwnerOn?: Moment; - readonly acknowledgedByVerifiedIssuerOn?: Moment; -} - -export interface LinkParams { - readonly target: string; - readonly nature: string; - readonly submitter: ValidAccountId; -} - -export interface LinkDescription extends LinkParams, ItemLifecycle { - readonly fees?: Fees; -} - -export interface VoidInfo { - readonly reason: string; - readonly voidedOn: Moment | null; -} - -export type SubmissionType = "MANUAL_BY_USER" | "MANUAL_BY_OWNER" | "DIRECT_BY_REQUESTER"; // "USER" can be Requester or VI. - -export class EmbeddableLifecycle { - - @Column("varchar", { length: 255 }) - status?: ItemStatus - - @Column("varchar", { length: 255, name: "reject_reason", nullable: true }) - rejectReason?: string | null; - - @Column("timestamp without time zone", { name: "reviewed_on", nullable: true }) - reviewedOn?: Date; - - @Column("timestamp without time zone", { name: "added_on", nullable: true }) - addedOn?: Date; - - @Column("timestamp without time zone", { name: "acknowledged_by_owner_on", nullable: true }) - acknowledgedByOwnerOn?: Date; - - @Column("boolean", { name: "acknowledged_by_owner", default: false }) - acknowledgedByOwner?: boolean; - - @Column("timestamp without time zone", { name: "acknowledged_by_verified_issuer_on", nullable: true }) - acknowledgedByVerifiedIssuerOn?: Date; - - @Column("boolean", { name: "acknowledged_by_verified_issuer", default: false }) - acknowledgedByVerifiedIssuer?: boolean; - - requestReview() { - if (this.status !== "DRAFT") { - throw badRequest(`Cannot request a review on item with status ${ this.status }`); - } - this.status = "REVIEW_PENDING"; - } - - accept() { - if (this.status !== "REVIEW_PENDING") { - throw badRequest(`Cannot accept an item with status ${ this.status }`); - } - this.status = "REVIEW_ACCEPTED"; - this.reviewedOn = moment().toDate(); - } - - reject(reason: string) { - if (this.status !== "REVIEW_PENDING") { - throw badRequest(`Cannot reject an item with status ${ this.status }`); - } - this.status = "REVIEW_REJECTED"; - this.rejectReason = reason; - this.reviewedOn = moment().toDate(); - } - - prePublishOrAcknowledge(isAcknowledged: boolean, addedOn?: Moment) { - const acknowledgedOrPublished = isAcknowledged ? "ACKNOWLEDGED" : "PUBLISHED"; - if (this.status !== "REVIEW_ACCEPTED" && this.status !== acknowledgedOrPublished) { - throw badRequest(`Cannot pre-publish/-acknowledge item with status ${ this.status }`); - } - this.status = acknowledgedOrPublished; - this.addedOn = addedOn?.toDate(); - } - - cancelPrePublishOrAcknowledge(isAcknowledged: boolean) { - const acknowledgedOrPublished = isAcknowledged ? "ACKNOWLEDGED" : "PUBLISHED"; - if (this.status !== acknowledgedOrPublished) { - throw badRequest(`Cannot cancel pre-publish/-acknowledge of item with status ${ this.status }`); - } - if(this.addedOn) { - throw badRequest(`Cannot cancel, published/acknowledged`); - } - this.status = "REVIEW_ACCEPTED"; - } - - preAcknowledge(expectVerifiedIssuer: boolean, byVerifiedIssuer: boolean, acknowledgedOn?: Moment) { - if (this.status !== "PUBLISHED" && this.status !== "ACKNOWLEDGED") { - throw badRequest(`Cannot confirm-acknowledge item with status ${ this.status }`); - } - if(byVerifiedIssuer) { - this.acknowledgedByVerifiedIssuer = true; - this.acknowledgedByVerifiedIssuerOn = acknowledgedOn ? acknowledgedOn.toDate() : undefined; - } else { - this.acknowledgedByOwner = true; - this.acknowledgedByOwnerOn = acknowledgedOn ? acknowledgedOn.toDate() : undefined; - } - if( - (this.acknowledgedByOwner && this.acknowledgedByVerifiedIssuer) - || (!expectVerifiedIssuer && this.acknowledgedByOwner) - ) { - this.status = "ACKNOWLEDGED"; - } - } - - cancelPreAcknowledge(byVerifiedIssuer: boolean) { - if (this.status !== "ACKNOWLEDGED") { - throw badRequest(`Cannot confirm-acknowledge item with status ${ this.status }`); - } - if(this.acknowledgedByVerifiedIssuerOn || this.acknowledgedByOwnerOn) { - throw badRequest(`Cannot cancel, acknowledged`); - } - if(byVerifiedIssuer) { - this.acknowledgedByVerifiedIssuer = false; - } else { - this.acknowledgedByOwner = false; - } - if(this.status === "ACKNOWLEDGED") { - this.status = "PUBLISHED"; - } - } - - isAcknowledged(): boolean { - return this.status === "ACKNOWLEDGED"; - } - - isAcknowledgedOnChain(expectVerifiedIssuer: boolean): boolean { - return this.isAcknowledged() && ( - (expectVerifiedIssuer && isTruthy(this.acknowledgedByOwnerOn) && isTruthy(this.acknowledgedByVerifiedIssuerOn)) - || (!expectVerifiedIssuer && isTruthy(this.acknowledgedByOwnerOn)) - ); - } - - isPublished(): boolean { - return this.status === "PUBLISHED"; - } - - isPublishedOnChain(): boolean { - return this.isPublished() && isTruthy(this.addedOn); - } - - setAddedOn(addedOn: Moment) { - if (this.addedOn) { - logger.warn("Item added on date is already set"); - } - this.addedOn = addedOn.toDate(); - if(this.status === "REVIEW_ACCEPTED") { - this.status = "PUBLISHED"; - } - } - - getDescription(): ItemLifecycle { - return { - status: this.status!, - rejectReason: this.status === "REVIEW_REJECTED" ? this.rejectReason! : undefined, - reviewedOn: this.reviewedOn ? moment(this.reviewedOn) : undefined, - addedOn: this.addedOn ? moment(this.addedOn) : undefined, - acknowledgedByOwnerOn: this.acknowledgedByOwnerOn ? moment(this.acknowledgedByOwnerOn) : undefined, - acknowledgedByVerifiedIssuerOn: this.acknowledgedByVerifiedIssuerOn ? moment(this.acknowledgedByVerifiedIssuerOn) : undefined, - } - } - - static fromSubmissionType(submissionType: SubmissionType) { - const lifecycle = new EmbeddableLifecycle(); - lifecycle.status = - submissionType === "DIRECT_BY_REQUESTER" ? "PUBLISHED" : - submissionType === "MANUAL_BY_OWNER" ? "REVIEW_ACCEPTED" : "DRAFT"; - lifecycle.acknowledgedByOwner = submissionType === "MANUAL_BY_OWNER"; - lifecycle.acknowledgedByOwnerOn = submissionType === "MANUAL_BY_OWNER" ? moment().toDate() : undefined; - lifecycle.acknowledgedByVerifiedIssuer = false; - return lifecycle; - } - - static default(status: ItemStatus | undefined) { - const lifecycle = new EmbeddableLifecycle(); - lifecycle.status = status; - lifecycle.acknowledgedByOwner = false; - lifecycle.acknowledgedByVerifiedIssuer = false; - return lifecycle; - } -} - -class EmbeddableVoidInfo { - - @Column("text", { name: "void_reason", nullable: true }) - reason?: string | null; - - @Column("timestamp without time zone", { name: "voided_on", nullable: true }) - voidedOn?: string | null; -} - -class EmbeddableSeal { - @Column({ name: "seal_salt", type: "uuid", nullable: true }) - salt?: string | null; - - @Column({ name: "seal_hash", type: "varchar", length: 255, nullable: true }) - hash?: string | null; - - @Column({ name: "seal_version", type: "integer", default: 0 }) - version?: number | null; - - static from(seal: Seal | undefined): EmbeddableSeal | undefined { - if (!seal) { - return undefined; - } - const result = new EmbeddableSeal(); - result.hash = seal.hash.toHex(); - result.salt = seal.salt; - result.version = seal.version; - return result; - } -} - -function toPublicSeal(embedded: EmbeddableSeal | undefined): PublicSeal | undefined { - return embedded && embedded.hash && embedded.version !== undefined && embedded.version !== null - ? - { - hash: Hash.fromHex(embedded.hash), - version: embedded.version - } - : undefined; -} - -type EmbeddableIdenfyVerificationStatus = 'PENDING' | IdenfyVerificationStatus; - -class EmbeddableIdenfyVerification { - - @Column("varchar", { name: "idenfy_auth_token", length: 40, nullable: true }) - authToken?: string | null; - - @Column("varchar", { name: "idenfy_scan_ref", length: 40, nullable: true }) - scanRef?: string | null; - - @Column("varchar", { name: "idenfy_status", length: 255, nullable: true }) - status?: EmbeddableIdenfyVerificationStatus | null; - - @Column("text", { name: "idenfy_callback_payload", nullable: true }) - callbackPayload?: string | null; -} - -export interface LocItems { - metadataNameHashes: Hash[]; - fileHashes: Hash[]; - linkTargets: UUID[]; -} - -export const EMPTY_ITEMS: LocItems = { - fileHashes: [], - linkTargets: [], - metadataNameHashes: [], -}; - -export class EmbeddableLocFees { - - @Column("numeric", { name: "value_fee", precision: AMOUNT_PRECISION, nullable: true }) - valueFee?: string | null; - - @Column("numeric", { name: "legal_fee", precision: AMOUNT_PRECISION, nullable: true }) - legalFee?: string | null; - - @Column("numeric", { name: "collection_item_fee", precision: AMOUNT_PRECISION, nullable: true }) - collectionItemFee?: string | null; - - @Column("numeric", { name: "tokens_record_fee", precision: AMOUNT_PRECISION, nullable: true }) - tokensRecordFee?: string | null; - - static from(fees: LocFees | undefined): EmbeddableLocFees | undefined { - if(fees) { - const embeddable = new EmbeddableLocFees(); - embeddable.valueFee = fees.valueFee?.toString(); - embeddable.legalFee = fees.legalFee?.toString(); - embeddable.collectionItemFee = fees.collectionItemFee?.toString(); - embeddable.tokensRecordFee = fees.tokensRecordFee?.toString(); - return embeddable; - } else { - return undefined; - } - } - - to(): LocFees { - return { - valueFee: toBigInt(this.valueFee), - legalFee: toBigInt(this.legalFee), - collectionItemFee: toBigInt(this.collectionItemFee), - tokensRecordFee: toBigInt(this.tokensRecordFee), - } - } -} - @Entity("loc_request") export class LocRequestAggregateRoot { @@ -1370,17 +990,78 @@ export class LocRequestAggregateRoot { isValidPolkadotIdentityLocOrThrow(requesterAddress: ValidAccountId, ownerAddress: ValidAccountId) { const requester = this.getRequester(); - const valid = this.locType === "Identity" + const valid = this.isValidIdentityLoc() && requester !== undefined && requesterAddress.equals(requester) - && ownerAddress.equals(this.getOwner()) - && this.status === "CLOSED" - && !this.isVoid(); + && ownerAddress.equals(this.getOwner()); if (!valid) { throw badRequest("Identity LOC not valid") } } + isValidIdentityLoc() { + return this.locType === "Identity" + && this.status === "CLOSED" + && !this.isVoid(); + } + + getSecretOrThrow(name: string) { + return requireDefined( + this.secret(name), + () => new Error(`There is no secret with name '${ name }'`) + ); + } + + hasSecret(name: string): boolean { + return this.secret(name) !== undefined; + } + + secret(name: string): string | undefined { + return this.secrets?.find(secret => secret.name === name)?.value; + } + + addSecret(name: string, value: string) { + if(!this.isValidIdentityLoc()) { + throw new Error(`Secrets can only be added to a valid Identity LOC`); + } + if(this.hasSecret(name)) { + throw new Error(`A secret with name '${ name }' already exists`); + } + if(name.length > 255) { + throw new Error(`Name is too long (max 255 characters)`); + } + if(value.length > 4096) { + throw new Error(`Value is too long (max 4096 characters)`); + } + if((this.secrets?.length || 0) > 10) { + throw new Error(`Too many secrets (max 10)`); + } + const secret = new RecoverableSecretEntity(); + secret.name = name; + secret.value = value; + secret._toAdd = true; + this.secrets?.push(secret); + } + + removeSecret(name: string) { + const index = this.secrets?.findIndex(secret => secret.name === name); + if(index === undefined || index === -1) { + throw new Error(`There is no secret with name '${ name }'`); + } + deleteChild(index, this.secrets!, this._secretsToDelete); + } + + getSecrets(viewer?: ValidAccountId): RecoverableSecret[] { + if(this.getRequester()?.equals(viewer)) { + return (this.secrets || []).map(entity => ({ + name: entity.name || "", + value: entity.value || "", + })); + } else { + return []; + } + } + @PrimaryColumn({ type: "uuid" }) id?: string; @@ -1490,11 +1171,23 @@ export class LocRequestAggregateRoot { @Column({ type: "boolean", name: "collection_can_upload", nullable: true }) collectionCanUpload?: boolean; + @OneToMany(() => RecoverableSecretEntity, item => item.request, { + eager: true, + cascade: false, + persistence: false + }) + secrets?: RecoverableSecretEntity[]; + _filesToDelete: LocFile[] = []; _linksToDelete: LocLink[] = []; _metadataToDelete: LocMetadataItem[] = []; + _secretsToDelete: RecoverableSecretEntity[] = []; } +export type LocRequestStatus = components["schemas"]["LocRequestStatus"]; +export type LocType = components["schemas"]["LocType"]; +export type IdentityLocType = components["schemas"]["IdentityLocType"]; + @Entity("loc_request_file") @Unique([ "requestId", "index" ]) export class LocFile extends Child implements HasIndex, Submitted { @@ -1679,11 +1372,6 @@ export class LocMetadataItem extends Child implements HasIndex, Submitted { } } -interface Submitted { - submitter?: EmbeddableAccountId; - lifecycle?: EmbeddableLifecycle; -} - @Entity("loc_link") @Unique([ "requestId", "index" ]) export class LocLink extends Child implements HasIndex, Submitted { @@ -1732,6 +1420,24 @@ export class LocLink extends Child implements HasIndex, Submitted { } } +@Entity("loc_secret") +@Unique([ "requestId", "name" ]) +export class RecoverableSecretEntity extends Child { + + @PrimaryColumn({ type: "uuid", name: "request_id" }) + requestId?: string; + + @PrimaryColumn({ length: 255 }) + name?: string; + + @Column({ length: 4096 }) + value?: string; + + @ManyToOne(() => LocRequestAggregateRoot, request => request.secrets) + @JoinColumn({ name: "request_id" }) + request?: LocRequestAggregateRoot; +} + export interface FetchLocRequestsSpecification { readonly expectedRequesterAddress?: ValidAccountId; @@ -1762,6 +1468,7 @@ export class LocRequestRepository { await this.saveFiles(this.repository.manager, root) await this.saveMetadata(this.repository.manager, root) await this.saveLinks(this.repository.manager, root) + await this.saveSecrets(this.repository.manager, root) } private async saveFiles(entityManager: EntityManager, root: LocRequestAggregateRoot): Promise { @@ -1822,12 +1529,26 @@ export class LocRequestRepository { }) } + private async saveSecrets(entityManager: EntityManager, root: LocRequestAggregateRoot): Promise { + const whereExpression: (sql: E, item: RecoverableSecretEntity) => E = (sql, item) => sql + .where("request_id = :id", { id: root.id }) + .andWhere("name = :name", { name: item.name }); + await saveChildren({ + children: root.secrets!, + entityManager, + entityClass: RecoverableSecretEntity, + whereExpression, + childrenToDelete: root._secretsToDelete + }); + } + public async findBy(specification: FetchLocRequestsSpecification): Promise { const builder = this.createQueryBuilder(specification) .leftJoinAndSelect("request.files", "file") .leftJoinAndSelect("file.delivered", "delivered") .leftJoinAndSelect("request.metadata", "metadata_item") - .leftJoinAndSelect("request.links", "link"); + .leftJoinAndSelect("request.links", "link") + .leftJoinAndSelect("request.secrets", "secret"); builder .orderBy("request.voided_on", "DESC", "NULLS FIRST") @@ -1908,6 +1629,7 @@ export class LocRequestRepository { await this.repository.manager.delete(LocFile, { requestId: request.id }); await this.repository.manager.delete(LocMetadataItem, { requestId: request.id }); await this.repository.manager.delete(LocLink, { requestId: request.id }); + await this.repository.manager.delete(RecoverableSecretEntity, { requestId: request.id }); await this.repository.manager.delete(LocRequestAggregateRoot, request.id); } @@ -2080,15 +1802,19 @@ export class LocRequestFactory { request.description = description.description; request.locType = description.locType; request.createdOn = description.createdOn; + if (request.locType === 'Identity') { this.ensureUserIdentityPresent(description) this.populateIdentity(request, description); } + request.files = []; request.metadata = []; request.links = []; + request.template = description.template; request.sponsorshipId = description.sponsorshipId?.toString(); + if(request.locType === "Collection") { requireDefined(description.fees.valueFee, () => new Error("Collection LOC must have a value fee")); requireDefined(description.fees.collectionItemFee, () => new Error("Collection LOC must have a collection item fee")); @@ -2098,6 +1824,9 @@ export class LocRequestFactory { request.collectionLastBlockSubmission = description.collectionParams?.lastBlockSubmission?.toString(); request.collectionMaxSize = description.collectionParams?.maxSize; request.collectionCanUpload = description.collectionParams?.canUpload; + + request.secrets = []; + return request; } diff --git a/src/logion/services/idenfy/idenfy.service.ts b/src/logion/services/idenfy/idenfy.service.ts index a61126a..cc29ddf 100644 --- a/src/logion/services/idenfy/idenfy.service.ts +++ b/src/logion/services/idenfy/idenfy.service.ts @@ -7,7 +7,6 @@ import { LocRequestService } from "../locrequest.service.js"; import { LocRequestAggregateRoot, LocRequestRepository, - FileParams } from "../../model/locrequest.model.js"; import { IdenfyCallbackPayload, IdenfyCallbackPayloadFileTypes, IdenfyVerificationSession } from "./idenfy.types.js"; import { createWriteStream } from "fs"; @@ -18,6 +17,7 @@ import crypto from "crypto"; import { sha256File } from '../../lib/crypto/hashing.js'; import { FileStorageService } from "../file.storage.service.js"; import { AxiosFactory } from "../axiosfactory.service.js"; +import { FileParams } from "src/logion/model/loc_items.js"; const { logger } = Log; diff --git a/src/logion/services/locsynchronization.service.ts b/src/logion/services/locsynchronization.service.ts index 253b283..39c7117 100644 --- a/src/logion/services/locsynchronization.service.ts +++ b/src/logion/services/locsynchronization.service.ts @@ -1,9 +1,9 @@ import { injectable } from 'inversify'; import { UUID, Adapters, Fees, Hash, Lgnt, ValidAccountId } from "@logion/node-api"; -import { Log, PolkadotService, requireDefined } from "@logion/rest-api-core"; +import { Log, requireDefined } from "@logion/rest-api-core"; import { Moment } from "moment"; -import { EMPTY_ITEMS, LocItems, LocRequestAggregateRoot, LocRequestRepository } from '../model/locrequest.model.js'; +import { LocRequestAggregateRoot, LocRequestRepository } from '../model/locrequest.model.js'; import { JsonExtrinsic, toString, extractUuid } from "./types/responses/Extrinsic.js"; import { LocRequestService } from './locrequest.service.js'; import { CollectionService } from './collection.service.js'; @@ -12,6 +12,7 @@ import { NotificationService } from './notification.service.js'; import { DirectoryService } from './directory.service.js'; import { VerifiedIssuerSelectionService } from './verifiedissuerselection.service.js'; import { TokensRecordService } from './tokensrecord.service.js'; +import { EMPTY_ITEMS, LocItems } from '../model/loc_items.js'; const { logger } = Log; @@ -24,7 +25,6 @@ export class LocSynchronizer { private collectionService: CollectionService, private notificationService: NotificationService, private directoryService: DirectoryService, - private polkadotService: PolkadotService, private verifiedIssuerSelectionService: VerifiedIssuerSelectionService, private tokensRecordService: TokensRecordService, ) {} diff --git a/src/logion/services/notification.service.ts b/src/logion/services/notification.service.ts index 49659ee..83e3daf 100644 --- a/src/logion/services/notification.service.ts +++ b/src/logion/services/notification.service.ts @@ -36,6 +36,7 @@ export const templateValues = [ "sof-requested", "review-requested", "data-reviewed", + "recoverable-secret-added", ] as const; export type Template = typeof templateValues[number]; diff --git a/test/integration/migration/migration.spec.ts b/test/integration/migration/migration.spec.ts index 42ddd48..d840cf6 100644 --- a/test/integration/migration/migration.spec.ts +++ b/test/integration/migration/migration.spec.ts @@ -3,7 +3,7 @@ const { connect, disconnect, queryRunner, runAllMigrations, revertAllMigrations describe('Migration', () => { - const NUM_OF_TABLES = 22; + const NUM_OF_TABLES = 23; jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000; beforeEach(async () => { diff --git a/test/integration/model/locrequest.model.spec.ts b/test/integration/model/locrequest.model.spec.ts index c1272bb..5f75622 100644 --- a/test/integration/model/locrequest.model.spec.ts +++ b/test/integration/model/locrequest.model.spec.ts @@ -9,17 +9,18 @@ import { LocLink, LocType, LocFileDelivered, - EmbeddableLocFees, + RecoverableSecretEntity, } from "../../../src/logion/model/locrequest.model.js"; import { ALICE_ACCOUNT, BOB_ACCOUNT } from "../../helpers/addresses.js"; import { v4 as uuid } from "uuid"; import { LocRequestService, TransactionalLocRequestService } from "../../../src/logion/services/locrequest.service.js"; import { Hash, UUID, ValidAccountId } from "@logion/node-api"; import { EmbeddableNullableAccountId, DB_SS58_PREFIX } from "../../../src/logion/model/supportedaccountid.model.js"; +import { EmbeddableLocFees } from "../../../src/logion/model/loc_fees.js"; const SUBMITTER = ValidAccountId.polkadot("129ZYz7x64MKMrW3SQsTBUCRMLCAmRaYeXEzkRmd9qoGbqQi"); const { connect, disconnect, checkNumOfRows, executeScript } = TestDb; -const ENTITIES = [ LocRequestAggregateRoot, LocFile, LocMetadataItem, LocLink, LocFileDelivered ]; +const ENTITIES = [ LocRequestAggregateRoot, LocFile, LocMetadataItem, LocLink, LocFileDelivered, RecoverableSecretEntity ]; const hash = Hash.fromHex("0x1307990e6ba5ca145eb35e99182a9bec46531bc54ddf656a602c780fa0240dee"); const anotherHash = Hash.fromHex("0x5a60f0a435fa1c508ccc7a7dd0a0fe8f924ba911b815b10c9ef0ddea0c49052e"); const collectionLocId = "15ed922d-5960-4147-a73f-97d362cb7c46"; diff --git a/test/unit/controllers/collection.controller.spec.ts b/test/unit/controllers/collection.controller.spec.ts index 0e69240..10db215 100644 --- a/test/unit/controllers/collection.controller.spec.ts +++ b/test/unit/controllers/collection.controller.spec.ts @@ -18,7 +18,7 @@ import { LocRequestRepository, LocRequestAggregateRoot, LocFile, - LocFileDelivered, EmbeddableLifecycle + LocFileDelivered, } from "../../../src/logion/model/locrequest.model.js"; import { FileStorageService } from "../../../src/logion/services/file.storage.service.js"; import { @@ -41,6 +41,7 @@ import { EmbeddableNullableAccountId } from "../../../src/logion/model/supportedaccountid.model.js"; import { ItIsHash } from "../../helpers/Mock.js"; +import { EmbeddableLifecycle } from "../../../src/logion/model/loc_lifecycle.js"; const collectionLocId = "d61e2e12-6c06-4425-aeee-2a0e969ac14e"; const collectionLocOwner = ALICE_ACCOUNT; diff --git a/test/unit/controllers/locrequest.controller.creation.spec.ts b/test/unit/controllers/locrequest.controller.creation.spec.ts index 7bea19a..6233421 100644 --- a/test/unit/controllers/locrequest.controller.creation.spec.ts +++ b/test/unit/controllers/locrequest.controller.creation.spec.ts @@ -291,7 +291,7 @@ function mockModelForCreationWithLogionIdentityLoc(container: Container): void { mockLogionIdentityLoc(repository, true); mockPolkadotIdentityLoc(repository, false); - const request = mockRequest("REVIEW_PENDING", { ...testDataWithLogionIdentity, requesterIdentityLocId: testDataWithLogionIdentity.requesterIdentityLoc }); + const request = mockRequest("REVIEW_PENDING", testDataWithLogionIdentity); factory.setup(instance => instance.newLOLocRequest(It.Is(params => params.description.requesterIdentityLoc === userIdentities["Logion"].identityLocId && params.description.ownerAddress.equals(ALICE_ACCOUNT) diff --git a/test/unit/controllers/locrequest.controller.fetch.spec.ts b/test/unit/controllers/locrequest.controller.fetch.spec.ts index 5d85d9d..c52cb0b 100644 --- a/test/unit/controllers/locrequest.controller.fetch.spec.ts +++ b/test/unit/controllers/locrequest.controller.fetch.spec.ts @@ -4,11 +4,7 @@ import { Container } from "inversify"; import request from "supertest"; import { ALICE_ACCOUNT, BOB_ACCOUNT } from "../../helpers/addresses.js"; import { - FileDescription, - LinkDescription, - MetadataItemDescription, LocType, - LocRequestDescription, } from "../../../src/logion/model/locrequest.model.js"; import moment from "moment"; import { @@ -34,6 +30,8 @@ import { mockAuthenticationForUserOrLegalOfficer } from "@logion/rest-api-core/d import { UserPrivateData } from "src/logion/controllers/adapters/locrequestadapter.js"; import { Fees, Hash, Lgnt } from "@logion/node-api"; import { ValidAccountId } from "@logion/node-api"; +import { LocRequestDescription } from "src/logion/model/loc_vos.js"; +import { FileDescription, LinkDescription, MetadataItemDescription } from "src/logion/model/loc_items.js"; const { mockAuthenticationWithCondition, setupApp } = TestApp; diff --git a/test/unit/controllers/locrequest.controller.items.spec.ts b/test/unit/controllers/locrequest.controller.items.spec.ts index 43c71f4..3effb08 100644 --- a/test/unit/controllers/locrequest.controller.items.spec.ts +++ b/test/unit/controllers/locrequest.controller.items.spec.ts @@ -9,8 +9,6 @@ import { ALICE_ACCOUNT, BOB_ACCOUNT } from "../../helpers/addresses.js"; import { Mock, It, Times } from "moq.ts"; import { LocRequestAggregateRoot, - LinkDescription, - MetadataItemDescription, FileDescription, SubmissionType, } from "../../../src/logion/model/locrequest.model.js"; import { fileExists } from "../../helpers/filehelper.js"; import { @@ -30,6 +28,8 @@ import { } from "./locrequest.controller.shared.js"; import { mockAuthenticationForUserOrLegalOfficer } from "@logion/rest-api-core/dist/TestApp.js"; import { ItIsAccount, ItIsHash } from "../../helpers/Mock.js"; +import { SubmissionType } from "../../../src/logion/model/loc_lifecycle.js"; +import { FileDescription, LinkDescription, MetadataItemDescription } from "../../../src/logion/model/loc_items.js"; const { mockAuthenticationWithCondition, setupApp } = TestApp; diff --git a/test/unit/controllers/locrequest.controller.secrets.spec.ts b/test/unit/controllers/locrequest.controller.secrets.spec.ts new file mode 100644 index 0000000..c0d111d --- /dev/null +++ b/test/unit/controllers/locrequest.controller.secrets.spec.ts @@ -0,0 +1,77 @@ +import { TestApp } from "@logion/rest-api-core"; +import { Express } from 'express'; +import { LocRequestController } from "../../../src/logion/controllers/locrequest.controller.js"; +import { Container } from "inversify"; +import request from "supertest"; +import { ALICE_ACCOUNT } from "../../helpers/addresses.js"; +import { It, Mock } from "moq.ts"; +import { + LocRequestAggregateRoot, +} from "../../../src/logion/model/locrequest.model.js"; +import { + buildMocksForUpdate, + mockPolkadotIdentityLoc, + mockRequest, + REQUEST_ID, + testDataWithUserIdentity, + mockRequester, + mockOwner, + POLKADOT_REQUESTER, +} from "./locrequest.controller.shared.js"; +import { mockAuthenticationForUserOrLegalOfficer } from "@logion/rest-api-core/dist/TestApp.js"; + +const { setupApp } = TestApp; + +describe('LocRequestController - Secrets -', () => { + + it('adds a secret', async () => { + const locRequest = mockRequestForSecrets(); + const authenticatedUserMock = mockAuthenticationForUserOrLegalOfficer(false, POLKADOT_REQUESTER); + const app = setupApp(LocRequestController, (container) => mockModel(container, locRequest), authenticatedUserMock); + await testAddSecretSuccess(app, locRequest); + }); + + it('removes a secret', async () => { + const locRequest = mockRequestForSecrets(); + const authenticatedUserMock = mockAuthenticationForUserOrLegalOfficer(false, POLKADOT_REQUESTER); + const app = setupApp(LocRequestController, (container) => mockModel(container, locRequest), authenticatedUserMock); + await testDeleteSecretSuccess(app, locRequest); + }); +}); + +function mockModel(container: Container, request: Mock) { + const { repository } = buildMocksForUpdate(container, { request }); + mockPolkadotIdentityLoc(repository, false); +} + +function mockRequestForSecrets(): Mock { + const request = mockRequest("CLOSED", testDataWithUserIdentity, [], [], [], [], "Identity"); + mockOwner(request, ALICE_ACCOUNT); + mockRequester(request, POLKADOT_REQUESTER); + request.setup(instance => instance.isRequester(It.IsAny())).returns(true); + request.setup(instance => instance.addSecret(SECRET_NAME, SECRET_VALUE)) + .returns(); + request.setup(instance => instance.removeSecret(SECRET_NAME)) + .returns(); + return request; +} + +const SECRET_NAME = "name with exotic char !é\"/&'"; +const SECRET_VALUE = "value with exotic char !é\"/&'"; + +async function testAddSecretSuccess(app: Express, locRequest: Mock) { + await request(app) + .post(`/api/loc-request/${ REQUEST_ID }/secrets`) + .send({ name: SECRET_NAME, value: SECRET_VALUE }) + .expect(204); + + locRequest.verify(instance => instance.addSecret(SECRET_NAME, SECRET_VALUE)); +} + +async function testDeleteSecretSuccess(app: Express, locRequest: Mock) { + await request(app) + .delete(`/api/loc-request/${ REQUEST_ID }/secrets/${ encodeURIComponent(SECRET_NAME) }`) + .expect(204); + + locRequest.verify(instance => instance.removeSecret(SECRET_NAME)); +} diff --git a/test/unit/controllers/locrequest.controller.shared.ts b/test/unit/controllers/locrequest.controller.shared.ts index 67a8a81..83d3494 100644 --- a/test/unit/controllers/locrequest.controller.shared.ts +++ b/test/unit/controllers/locrequest.controller.shared.ts @@ -8,12 +8,8 @@ import { LocRequestFactory, LocRequestAggregateRoot, LocRequestStatus, - FileDescription, - LinkDescription, - MetadataItemDescription, LocType, FetchLocRequestsSpecification, - LocRequestDescription, } from "../../../src/logion/model/locrequest.model.js"; import { FileStorageService } from "../../../src/logion/services/file.storage.service.js"; import { NotificationService, Template } from "../../../src/logion/services/notification.service.js"; @@ -34,6 +30,8 @@ import { VerifiedIssuerSelectionRepository } from "../../../src/logion/model/ver import { LocAuthorizationService } from "../../../src/logion/services/locauthorization.service.js"; import { EmbeddableNullableAccountId } from "../../../src/logion/model/supportedaccountid.model.js"; import { SponsorshipService } from "../../../src/logion/services/sponsorship.service.js"; +import { LocRequestDescription, RecoverableSecret } from "src/logion/model/loc_vos.js"; +import { FileDescription, LinkDescription, MetadataItemDescription } from "../../../src/logion/model/loc_items.js"; export type IdentityLocation = "Logion" | "Polkadot" | 'EmbeddedInLoc'; export const POLKADOT_REQUESTER = ValidAccountId.polkadot("5CXLTF2PFBE89tTYsrofGPkSfGTdmW4ciw4vAfgcKhjggRgZ"); @@ -258,12 +256,14 @@ function mockOtherDependencies(container: Container, existingMocks?: { export function mockRequest( status: LocRequestStatus, - data: any, + description: Partial, files: FileDescription[] = [], metadataItems: MetadataItemDescription[] = [], links: LinkDescription[] = [], + secrets: RecoverableSecret[] = [], + locType: LocType | undefined = undefined, ): Mock { - return mockRequestWithId(REQUEST_ID, undefined, status, data, files, metadataItems, links) + return mockRequestWithId(REQUEST_ID, locType, status, description, files, metadataItems, links, secrets) } export const REQUEST_ID = "3e67427a-d80f-41d7-9c86-75a63b8563a1"; @@ -276,9 +276,10 @@ export function mockRequestWithId( files: FileDescription[] = [], metadataItems: MetadataItemDescription[] = [], links: LinkDescription[] = [], + secrets: RecoverableSecret[] = [], ): Mock { const request = new Mock(); - setupRequest(request, id, locType, status, description, files, metadataItems, links); + setupRequest(request, id, locType, status, description, files, metadataItems, links, secrets); return request; } @@ -291,6 +292,7 @@ export function setupRequest( files: FileDescription[] = [], metadataItems: MetadataItemDescription[] = [], links: LinkDescription[] = [], + secrets: RecoverableSecret[] = [], ) { if (locType) { request.setup(instance => instance.locType) @@ -317,6 +319,7 @@ export function setupRequest( request.setup(instance => instance.getLinks(It.IsAny())).returns(links); request.setup(instance => instance.getLinks()).returns(links); request.setup(instance => instance.getVoidInfo()).returns(null); + request.setup(instance => instance.getSecrets(It.IsAny())).returns(secrets); mockOwner(request, description.ownerAddress || ALICE_ACCOUNT) if (description.requesterAddress) { mockRequester(request, description.requesterAddress); diff --git a/test/unit/controllers/protectionrequest.controller.spec.ts b/test/unit/controllers/protectionrequest.controller.spec.ts index eee3ae8..c81f72b 100644 --- a/test/unit/controllers/protectionrequest.controller.spec.ts +++ b/test/unit/controllers/protectionrequest.controller.spec.ts @@ -22,10 +22,11 @@ import { notifiedLegalOfficer } from "../services/notification-test-data.js"; import { UserIdentity } from '../../../src/logion/model/useridentity.js'; import { PostalAddress } from '../../../src/logion/model/postaladdress.js'; import { NonTransactionalProtectionRequestService, ProtectionRequestService } from '../../../src/logion/services/protectionrequest.service.js'; -import { LocRequestAggregateRoot, LocRequestDescription, LocRequestRepository } from "../../../src/logion/model/locrequest.model.js"; +import { LocRequestAggregateRoot, LocRequestRepository } from "../../../src/logion/model/locrequest.model.js"; import { LocRequestAdapter } from "../../../src/logion/controllers/adapters/locrequestadapter.js"; import { ValidAccountId } from "@logion/node-api"; import { DB_SS58_PREFIX } from "../../../src/logion/model/supportedaccountid.model.js"; +import { LocRequestDescription } from 'src/logion/model/loc_vos.js'; const DECISION_TIMESTAMP = "2021-06-10T16:25:23.668294"; const { mockAuthenticationWithCondition, setupApp, mockLegalOfficerOnNode, mockAuthenticationWithAuthenticatedUser, mockAuthenticatedUser } = TestApp; diff --git a/test/unit/controllers/vaulttransferrequest.controller.spec.ts b/test/unit/controllers/vaulttransferrequest.controller.spec.ts index 4457efd..958287c 100644 --- a/test/unit/controllers/vaulttransferrequest.controller.spec.ts +++ b/test/unit/controllers/vaulttransferrequest.controller.spec.ts @@ -25,9 +25,10 @@ import { import { UserIdentity } from '../../../src/logion/model/useridentity.js'; import { PostalAddress } from '../../../src/logion/model/postaladdress.js'; import { NonTransactionalVaultTransferRequestService, VaultTransferRequestService } from '../../../src/logion/services/vaulttransferrequest.service.js'; -import { LocRequestAggregateRoot, LocRequestDescription, LocRequestRepository } from '../../../src/logion/model/locrequest.model.js'; +import { LocRequestAggregateRoot, LocRequestRepository } from '../../../src/logion/model/locrequest.model.js'; import { LogionNodeApiClass, ValidAccountId } from '@logion/node-api'; import { DB_SS58_PREFIX } from "../../../src/logion/model/supportedaccountid.model.js"; +import { LocRequestDescription } from 'src/logion/model/loc_vos.js'; const { mockAuthenticatedUser, mockAuthenticationWithAuthenticatedUser, mockAuthenticationWithCondition, setupApp, mockLegalOfficerOnNode } = TestApp; diff --git a/test/unit/model/locrequest.model.spec.ts b/test/unit/model/locrequest.model.spec.ts index 5c05dc4..80b9479 100644 --- a/test/unit/model/locrequest.model.spec.ts +++ b/test/unit/model/locrequest.model.spec.ts @@ -2,20 +2,11 @@ import { v4 as uuid } from "uuid"; import { ALICE_ACCOUNT } from "../../helpers/addresses.js"; import moment, { Moment } from "moment"; import { - LocRequestDescription, LocRequestFactory, LocRequestAggregateRoot, LocRequestStatus, - FileDescription, - MetadataItemDescription, - LinkDescription, - VoidInfo, LocType, LocRequestRepository, - MetadataItemParams, - ItemStatus, - FileParams, - LinkParams, SubmissionType, EMPTY_ITEMS } from "../../../src/logion/model/locrequest.model.js"; import { UserIdentity } from "../../../src/logion/model/useridentity.js"; import { Mock, It } from "moq.ts"; @@ -31,6 +22,10 @@ import { IdenfyVerificationSession, IdenfyVerificationStatus } from "src/logion/ import { Hash } from "../../../src/logion/lib/crypto/hashing.js"; import { POLKADOT_REQUESTER } from "../controllers/locrequest.controller.shared.js"; import { EmbeddableNullableAccountId } from "../../../src/logion/model/supportedaccountid.model.js"; +import { ItemStatus, SubmissionType } from "../../../src/logion/model/loc_lifecycle.js"; +import { EMPTY_ITEMS, FileDescription, FileParams, LinkDescription, LinkParams, MetadataItemDescription, MetadataItemParams } from "../../../src/logion/model/loc_items.js"; +import { LocRequestDescription } from "../../../src/logion/model/loc_vos.js"; +import { VoidInfo } from "../../../src/logion/model/loc_void.js"; const SUBMITTER = ValidAccountId.polkadot("vQtS4iX2RGv5ERHZVg7Xsi54Qw5wXkQkK4tuNt7nTVVn8F6AN"); @@ -2023,7 +2018,66 @@ const ACCEPTED_ON = moment().add(1, "minute"); const VOID_REASON = "Some good reason"; const VOIDED_ON = moment(); -function givenPreVoidedRequest(status: LocRequestStatus) { +describe("LocRequestAggregateRoot (secrets)", () => { + + it("adds and exposes secret", () => { + givenRequestWithStatus('CLOSED', "Identity"); + + request.addSecret("name", "value"); + + expect(request.getSecretOrThrow("name")).toBe("value"); + expect(request.hasSecret("name")).toBe(true); + const secrets = request.getSecrets(SUBMITTER); + expect(secrets.length).toBe(1); + expect(secrets[0].name).toBe("name"); + expect(secrets[0].value).toBe("value"); + }); + + it("does not accept several secrets with same name", () => { + givenRequestWithStatus('CLOSED', "Identity"); + + request.addSecret("name", "value1"); + + expect(() => request.addSecret("name", "value2")).toThrowError("A secret with name 'name' already exists"); + }); + + it("removes secret", () => { + givenRequestWithStatus('CLOSED', "Identity"); + request.addSecret("name", "value"); + + request.removeSecret("name"); + + expect(() => request.getSecretOrThrow("name")).toThrowError("There is no secret with name 'name'"); + expect(request.hasSecret("name")).toBe(false); + }); + + it("cannot add secret to open LOC", () => { + givenRequestWithStatus('OPEN', "Identity"); + expect(() => request.addSecret("name", "value")).toThrowError("Secrets can only be added to a valid Identity LOC"); + }); + + it("cannot add secret to closed Collection LOC", () => { + givenRequestWithStatus('CLOSED', "Collection"); + expect(() => request.addSecret("name", "value")).toThrowError("Secrets can only be added to a valid Identity LOC"); + }); + + it("cannot add secret to closed Transaction LOC", () => { + givenRequestWithStatus('CLOSED', "Transaction"); + expect(() => request.addSecret("name", "value")).toThrowError("Secrets can only be added to a valid Identity LOC"); + }); + + it("cannot add secret to void LOC", () => { + givenPreVoidedRequest('CLOSED', "Identity"); + expect(() => request.addSecret("name", "value")).toThrowError("Secrets can only be added to a valid Identity LOC"); + }); + + it("fails when trying to remove unknown secret", () => { + givenPreVoidedRequest('CLOSED', "Identity"); + expect(() => request.removeSecret("name")).toThrowError("There is no secret with name 'name'"); + }); +}) + +function givenPreVoidedRequest(status: LocRequestStatus, locType?: LocType) { givenRequestWithStatus(status); request.voidInfo = { reason: VOID_REASON @@ -2035,14 +2089,19 @@ function givenVoidedRequest(status: LocRequestStatus) { request.voidInfo!.voidedOn = VOIDED_ON.toISOString(); } -function givenRequestWithStatus(status: LocRequestStatus) { +function givenRequestWithStatus(status: LocRequestStatus, locType?: LocType) { request = new LocRequestAggregateRoot(); request.status = status; request.files = []; request.metadata = []; request.links = []; + request.secrets = []; request.ownerAddress = OWNER; request.requester = EmbeddableNullableAccountId.from(SUBMITTER) + + if(locType) { + request.locType = locType; + } } const OWNER_ACCOUNT = ALICE_ACCOUNT; diff --git a/test/unit/services/idenfy/idenfy.service.spec.ts b/test/unit/services/idenfy/idenfy.service.spec.ts index 3e651b7..80597ff 100644 --- a/test/unit/services/idenfy/idenfy.service.spec.ts +++ b/test/unit/services/idenfy/idenfy.service.spec.ts @@ -1,6 +1,6 @@ import { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosResponseHeaders, CreateAxiosDefaults } from "axios"; import { It, Mock, PlayTimes, Times } from "moq.ts"; -import { LocRequestAggregateRoot, LocRequestDescription, LocRequestRepository, FileDescription } from "src/logion/model/locrequest.model.js"; +import { LocRequestAggregateRoot, LocRequestRepository } from "src/logion/model/locrequest.model.js"; import { AxiosFactory } from "src/logion/services/axiosfactory.service.js"; import { FileStorageService } from "src/logion/services/file.storage.service.js"; import { LocRequestService } from "src/logion/services/locrequest.service.js"; @@ -15,6 +15,8 @@ import { NonTransactionalLocRequestService } from "../../../../src/logion/servic import { Readable } from "stream"; import { mockOwner } from "../../controllers/locrequest.controller.shared.js"; import { expectAsyncToThrow } from "../../../helpers/asynchelper.js"; +import { FileDescription } from "src/logion/model/loc_items.js"; +import { LocRequestDescription } from "src/logion/model/loc_vos.js"; describe("DisabledIdenfyService", () => { diff --git a/test/unit/services/locsynchronization.service.spec.ts b/test/unit/services/locsynchronization.service.spec.ts index 14810e1..1605a39 100644 --- a/test/unit/services/locsynchronization.service.spec.ts +++ b/test/unit/services/locsynchronization.service.spec.ts @@ -1,9 +1,8 @@ import { UUID, TypesJsonObject, Fees } from "@logion/node-api"; -import { PolkadotService } from "@logion/rest-api-core"; import moment, { Moment } from 'moment'; import { It, Mock } from 'moq.ts'; import { LocSynchronizer } from "../../../src/logion/services/locsynchronization.service.js"; -import { EMPTY_ITEMS, LocRequestAggregateRoot, LocRequestRepository } from '../../../src/logion/model/locrequest.model.js'; +import { LocRequestAggregateRoot, LocRequestRepository } from '../../../src/logion/model/locrequest.model.js'; import { JsonExtrinsic } from '../../../src/logion/services/types/responses/Extrinsic.js'; import { CollectionRepository, @@ -19,6 +18,7 @@ import { TokensRecordRepository } from "../../../src/logion/model/tokensrecord.m import { ALICE_ACCOUNT } from "../../helpers/addresses.js"; import { Hash } from "../../../src/logion/lib/crypto/hashing.js"; import { ItIsAccount, ItIsHash } from "../../helpers/Mock.js"; +import { EMPTY_ITEMS } from "../../../src/logion/model/loc_items.js"; describe("LocSynchronizer", () => { @@ -27,7 +27,6 @@ describe("LocSynchronizer", () => { collectionRepository = new Mock(); notificationService = new Mock(); directoryService = new Mock(); - polkadotService = new Mock(); verifiedIssuerSelectionService = new Mock(); tokensRecordRepository = new Mock(); }); @@ -259,7 +258,6 @@ function locSynchronizer(): LocSynchronizer { new NonTransactionalCollectionService(collectionRepository.object()), notificationService.object(), directoryService.object(), - polkadotService.object(), verifiedIssuerSelectionService.object(), new NonTransactionalTokensRecordService(tokensRecordRepository.object()), ); @@ -267,7 +265,6 @@ function locSynchronizer(): LocSynchronizer { let notificationService: Mock; let directoryService: Mock; -let polkadotService: Mock; let verifiedIssuerSelectionService: Mock; let tokensRecordRepository: Mock; diff --git a/test/unit/services/notification-test-data.ts b/test/unit/services/notification-test-data.ts index 32e5fdf..b733f66 100644 --- a/test/unit/services/notification-test-data.ts +++ b/test/unit/services/notification-test-data.ts @@ -4,9 +4,9 @@ import { } from "../../../src/logion/model/protectionrequest.model.js"; import { ALICE_ACCOUNT, BOB_ACCOUNT } from "../../helpers/addresses.js"; import { LegalOfficer } from "../../../src/logion/model/legalofficer.model.js"; -import { LocRequestDescription, LocRequestDecision } from "../../../src/logion/model/locrequest.model.js"; import { VaultTransferRequestDescription } from "src/logion/model/vaulttransferrequest.model.js"; import { ValidAccountId } from "@logion/node-api"; +import { LocRequestDecision, LocRequestDescription } from "src/logion/model/loc_vos.js"; export const notifiedProtection: ProtectionRequestDescription & { decision: LegalOfficerDecisionDescription } = { requesterAddress: ValidAccountId.polkadot("5H4MvAsobfZ6bBCDyj5dsrWYLrA8HrRzaqa9p61UXtxMhSCY"),