From eeb33a5c2cc7691f925dd20f94afd72c9a87c266 Mon Sep 17 00:00:00 2001 From: Sownak Roy Date: Tue, 10 Dec 2024 14:44:28 +0000 Subject: [PATCH 1/3] feat: Add updateResource endpoint and tests Signed-off-by: Sownak Roy --- README.md | 33 ++- src/app.ts | 4 +- src/controllers/resource.ts | 181 +++++++++++++++- src/types/constants.ts | 2 + src/types/types.ts | 3 + tests/did/validateDid.spec.ts | 141 +++++++++++++ tests/resource/createResource.spec.ts | 24 +-- tests/resource/resource-create.spec.ts | 4 +- tests/resource/updateResource.spec.ts | 228 +++++++++++++++++++++ tests/resource/validateResource.spec.ts | 261 ++++++++++++++++++++++++ 10 files changed, 846 insertions(+), 35 deletions(-) create mode 100644 tests/did/validateDid.spec.ts create mode 100644 tests/resource/updateResource.spec.ts create mode 100644 tests/resource/validateResource.spec.ts diff --git a/README.md b/README.md index 4ee759a..de2b719 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,17 @@ The purpose of this service is to provide a [Universal Registrar driver](https:/ ## 📖 Endpoints -- `/create` -- `/update` -- `/deactivate` -- `/create-resource` -- `/api-docs` +- POST `/create` +- POST `/update` +- POST `/deactivate` +- POST `/{did}/create-resource` +- POST `/createResource` +- POST `/updateResource` +- GET `/key-pair` +- GET `/did-document` +- GET `/properties` +- GET `/methods` +- GET `/traits` ## 🧑‍💻🛠 Developer Guide @@ -65,6 +71,23 @@ npm run build npm start ``` +### 🛠 Testing + +This repository contains the playwright tests for unit and integration testing. +Add any additional tests in the `tests` directory. + +You must set up these two env vars before running test: +1. `TEST_PRIVATE_KEY` : Private key for signing the requests +2. `TEST_PUBLIC_KEY` : Corresponding public key + +Then execute the tests + +```bash +npm run test +# if tests faile because of parallelism, run +npm run test -- --workers=1 +``` + ## 🐞 Bug reports & 🤔 feature requests If you notice anything not behaving how you expected, or would like to make a suggestion / request for a new feature, please create a [**new issue**](https://github.com/cheqd/did-registrar/issues/new/choose) and let us know. diff --git a/src/app.ts b/src/app.ts index ca468d5..8a05365 100644 --- a/src/app.ts +++ b/src/app.ts @@ -88,9 +88,9 @@ class App { ); app.post( `${URL_PREFIX}/updateResource`, - ResourceController.createResourceValidator, + ResourceController.updateResourceValidator, DidController.commonValidator, - new ResourceController().create + new ResourceController().updateResource ); app.get(`${URL_PREFIX}/methods`, (req: Request, res: Response) => { diff --git a/src/controllers/resource.ts b/src/controllers/resource.ts index 787005b..f703989 100644 --- a/src/controllers/resource.ts +++ b/src/controllers/resource.ts @@ -7,7 +7,13 @@ import { v4 } from 'uuid'; import { fromString } from 'uint8arrays'; import { CheqdRegistrar, CheqdResolver, NetworkType } from '../service/cheqd.js'; -import { IResourceCreateRequest, IResourceCreateRequestV1, IResourceUpdateRequest, IState } from '../types/types.js'; +import { + ContentOperation, + IResourceCreateRequest, + IResourceCreateRequestV1, + IResourceUpdateRequest, + IState, +} from '../types/types.js'; import { Messages } from '../types/constants.js'; import { convertToSignInfo } from '../helpers/helpers.js'; import { Responses } from '../helpers/response.js'; @@ -37,14 +43,41 @@ export class ResourceController { return true; }) .withMessage('name, type and content are required'), + check('content').exists().isString().withMessage(Messages.Invalid), check('name').optional().isString().withMessage(Messages.Invalid), check('type').optional().isString().withMessage(Messages.Invalid), - check('content').exists().isString().withMessage(Messages.Invalid), + check('version').optional().isString().withMessage(Messages.Invalid), check('relativeDidUrl').optional().isString().contains('/resources/').withMessage(Messages.InvalidDidUrl), check('alsoKnownAs').optional().isArray().withMessage(Messages.Invalid), check('alsoKnownAs.*.uri').isString().withMessage(Messages.Invalid), check('alsoKnownAs.*.description').isString().withMessage(Messages.Invalid), ]; + public static updateResourceValidator = [ + check('did').exists().isString().contains('did:cheqd').withMessage(Messages.InvalidDid), + check('jobId') + .custom((value, { req }) => { + if (!value && !(req.body.name && req.body.type && req.body.content)) return false; + return true; + }) + .withMessage('name, type and content are required'), + check('name').optional().isString().withMessage(Messages.Invalid), + check('type').optional().isString().withMessage(Messages.Invalid), + check('content') + .exists() + .isArray() + .custom((value) => { + if (value.length !== 1) return false; + if (typeof value[0] !== 'string') return false; + return true; + }) + .withMessage('The content array must be provided and must have exactly one string'), + check('relativeDidUrl').exists().isString().contains('/resources/').withMessage(Messages.InvalidDidUrl), + check('contentOperation') + .optional() + .isArray() + .custom((value) => value[0] === ContentOperation.Set && value.length == 1) + .withMessage('Only Set operation is supported'), + ]; public async create(request: Request, response: Response) { const result = validationResult(request); @@ -183,7 +216,7 @@ export class ResourceController { if (!resolvedDocument?.didDocument || resolvedDocument.didDocumentMetadata.deactivated) { return response .status(400) - .send(Responses.GetInvalidResponse({ id: did }, secret, Messages.DidNotFound)); + .send(Responses.GetInvalidResourceResponse(did, {}, secret, Messages.DidNotFound)); } else { resolvedDocument = resolvedDocument.didDocument; } @@ -197,7 +230,7 @@ export class ResourceController { return response.status(201).json({ jobId, didUrlState: { - resourceId: storeData.resource.id, + didUrl: storeData.resource.id, state: IState.Finished, secret, resource: storeData.resource, @@ -242,6 +275,146 @@ export class ResourceController { ); } + options.network = options.network || (did.split(':')[2] as NetworkType); + await CheqdRegistrar.instance.connect(options); + const result = await CheqdRegistrar.instance.createResource(signInputs, resourcePayload); + if (result.code == 0) { + return response + .status(201) + .json(Responses.GetResourceSuccessResponse(jobId, secret, did, resourcePayload)); + } else { + return response + .status(400) + .json(Responses.GetInvalidResourceResponse(did, resourcePayload, secret, Messages.InvalidResource)); + } + } catch (error) { + return response.status(500).json({ + jobId, + didUrlState: { + state: IState.Failed, + reason: Messages.Internal, + description: Messages.TryAgain + error, + secret, + resourcePayload, + }, + }); + } + } + public async updateResource(request: Request, response: Response) { + const result = validationResult(request); + if (!result.isEmpty()) { + return response + .status(400) + .json(Responses.GetInvalidResourceResponse('', {}, request.body.secret, result.array()[0].msg)); + } + + let { + did, + jobId, + content, + relativeDidUrl, + name, + type, + version, + secret = {}, + options = {}, + } = request.body as IResourceUpdateRequest; + + let resourcePayload: Partial = {}; + let existingResource; + try { + // check if did is registered on the ledger + let resolvedDocument = await CheqdResolver(did); + if (!resolvedDocument?.didDocument || resolvedDocument.didDocumentMetadata.deactivated) { + return response + .status(400) + .send(Responses.GetInvalidResourceResponse(did, {}, secret, Messages.DidNotFound)); + } else { + const didUrlIndex = resolvedDocument.didDocumentMetadata.linkedResourceMetadata.findIndex( + (resource: { resourceURI: string }) => resource.resourceURI === did + relativeDidUrl + ); + if (didUrlIndex !== -1) { + existingResource = resolvedDocument.didDocumentMetadata.linkedResourceMetadata[didUrlIndex]; + if (existingResource.resourceName !== name || existingResource.resourceType !== type) + return response + .status(400) + .send( + Responses.GetInvalidResourceResponse( + did, + { id: relativeDidUrl.split('resources/')[1] }, + secret, + Messages.InvalidUpdateResource + ) + ); + } else { + return response + .status(400) + .send( + Responses.GetInvalidResourceResponse( + did, + { id: relativeDidUrl.split('resources/')[1] }, + secret, + Messages.ResourceNotFound + ) + ); + } + resolvedDocument = resolvedDocument.didDocument; + } + + // Validate and get store data if any + if (jobId) { + const storeData = LocalStore.instance.getResource(jobId); + if (!storeData) { + return response.status(400).json(Responses.GetJobExpiredResponse(jobId)); + } else if (storeData.state == IState.Finished) { + return response.status(201).json({ + jobId, + didUrlState: { + didUrl: storeData.resource.id, + state: IState.Finished, + secret, + resource: storeData.resource, + }, + }); + } + + resourcePayload = storeData.resource; + resourcePayload.data = new Uint8Array(Object.values(resourcePayload.data!)); + } else if (!content) { + return response + .status(400) + .json(Responses.GetInvalidResourceResponse('', {}, secret, Messages.InvalidContent)); + } else { + jobId = v4(); + + resourcePayload = { + collectionId: did.split(':').pop()!, + id: v4(), + name, + resourceType: type, + version: version, + data: fromString(content[0], 'base64'), + }; + } + + let signInputs: SignInfo[]; + + if (secret.signingResponse) { + signInputs = convertToSignInfo(secret.signingResponse); + } else { + LocalStore.instance.setResource(jobId, { resource: resourcePayload, state: IState.Action }); + return response + .status(200) + .json( + Responses.GetResourceActionSignatureResponse( + jobId, + resolvedDocument.verificationMethod, + did, + resourcePayload + ) + ); + } + options.network = options.network || (did.split(':')[2] as NetworkType); await CheqdRegistrar.instance.connect(options); const result = await CheqdRegistrar.instance.createResource(signInputs, resourcePayload); diff --git a/src/types/constants.ts b/src/types/constants.ts index 8c3e0f3..473025b 100644 --- a/src/types/constants.ts +++ b/src/types/constants.ts @@ -10,7 +10,9 @@ export enum Messages { InvalidJob = 'The jobId is either expired or not found', SecretValidation = 'Provide either a valid KeyPair or Signature', InvalidResource = 'Resource Data is invalid', + ResourceNotFound = 'Resource does not exist', InvalidContent = 'Resource Content is invalid', + InvalidUpdateResource = 'Update resource name or type does not match existing resource', TestnetFaucet = 'sketch mountain erode window enact net enrich smoke claim kangaroo another visual write meat latin bacon pulp similar forum guilt father state erase bright', SigingResponse = 'e.g. { kid: did:cheqd:testnet:qsqdcansoica#key-1, signature: aca1s12q14213casdvaadcfas }', InvalidOptions = 'The provided options are invalid', diff --git a/src/types/types.ts b/src/types/types.ts index dc9052a..61dfb57 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -119,6 +119,9 @@ export interface IResourceUpdateRequest { relativeDidUrl: string; content: any; contentOperation: ContentOperation[]; + name: string; + type: string; + version: string; } export enum ContentOperation { diff --git a/tests/did/validateDid.spec.ts b/tests/did/validateDid.spec.ts new file mode 100644 index 0000000..aac2ffa --- /dev/null +++ b/tests/did/validateDid.spec.ts @@ -0,0 +1,141 @@ +import { test, expect } from '@playwright/test'; +import { toString, fromString } from 'uint8arrays'; +import * as dotenv from 'dotenv'; +import { assert } from 'console'; + +dotenv.config(); + +const pub_key_base_64 = process.env.TEST_PUBLIC_KEY; + +assert(pub_key_base_64, 'TEST_PUBLIC_KEY is not defined'); + +const pubKeyHex = toString(fromString(pub_key_base_64 as string, 'base64pad'), 'base16'); + +let didPayload; +let indyDid = 'did:indy:sovrin:WRfXPg8dantKVubE3HX8pw'; +let deactiveDid = 'did:cheqd:testnet:ca9ff47c-0286-4614-a4be-8ffa83911e09'; + +test('did-document. Generate the payload', async ({ request }) => { + const payload = await request.get( + `/1.0/did-document?verificationMethod=JsonWebKey2020&methodSpecificIdAlgo=uuid&network=testnet&publicKeyHex=${pubKeyHex}` + ); + + expect(payload.status()).toBe(200); + + const body = await payload.json(); + expect(body.didDoc).toBeDefined(); + expect(body.key).toBeDefined(); + expect(body.key.kid).toBeDefined(); + expect(body.key.publicKeyHex).toBeDefined(); + + didPayload = body.didDoc; +}); + +test('did-create. wrong didDocument', async ({ request }) => { + const payload = await request.post('/1.0/create', { + data: { + didDocument: {}, + secret: {}, + options: { + network: 'testnet', + }, + }, + }); + + expect(payload.status()).toBe(400); + + const body = await payload.json(); + expect(body.didState).toBeDefined(); + expect(body.didState.description).toEqual( + 'Invalid payload: Provide a DID Document with at least one valid verification method' + ); +}); + +test('did-update. invalid did', async ({ request }) => { + const payload = await request.post(`/1.0/update`, { + data: { + did: indyDid, + didDocument: [didPayload], + options: { + network: 'testnet', + }, + }, + }); + + expect(payload.status()).toBe(400); + + const body = await payload.json(); + expect(body.didState).toBeDefined(); + expect(body.didState.description).toEqual('Invalid payload: The DID is not valid'); +}); + +test('did-update. Send deactivated did', async ({ request }) => { + const payload = await request.post(`/1.0/update`, { + data: { + did: deactiveDid, + didDocument: [didPayload], + options: { + network: 'testnet', + }, + }, + }); + + expect(payload.status()).toBe(400); + + const body = await payload.json(); + expect(body.didState).toBeDefined(); + expect(body.didState.description).toEqual('Invalid payload: The DID does not exist or is Deactivated'); +}); + +test('did-update. Send wrong operation', async ({ request }) => { + const payload = await request.post(`/1.0/update`, { + data: { + didDocument: [didPayload], + did: didPayload.id, + didDocumentOperation: ['removeFromDidDocument'], + options: { + network: 'testnet', + }, + }, + }); + + expect(payload.status()).toBe(400); + + const body = await payload.json(); + expect(body.didState).toBeDefined(); + expect(body.didState.description).toEqual('Invalid payload: Only Set operation is supported'); +}); + +test('did-deactivate. invalid did', async ({ request }) => { + const payload = await request.post(`/1.0/deactivate`, { + data: { + did: indyDid, + options: { + network: 'testnet', + }, + }, + }); + + expect(payload.status()).toBe(400); + + const body = await payload.json(); + expect(body.didState).toBeDefined(); + expect(body.didState.description).toEqual('Invalid payload: The DID is not valid'); +}); + +test('did-deactivate. Send deactivated did', async ({ request }) => { + const payload = await request.post(`/1.0/deactivate`, { + data: { + did: deactiveDid, + options: { + network: 'testnet', + }, + }, + }); + + expect(payload.status()).toBe(400); + + const body = await payload.json(); + expect(body.didState).toBeDefined(); + expect(body.didState.description).toEqual('Invalid payload: The DID does not exist or is Deactivated'); +}); diff --git a/tests/resource/createResource.spec.ts b/tests/resource/createResource.spec.ts index 90a95b0..f1eb31d 100644 --- a/tests/resource/createResource.spec.ts +++ b/tests/resource/createResource.spec.ts @@ -64,7 +64,7 @@ test('resource-create. Initiate DID Create procedure', async ({ request }) => { jobId = body.jobId; }); -test('resource-create. Send the final request for DID creating', async ({ request }) => { +test('resource-create. Send the final request for DID creation', async ({ request }) => { const serializedPayload = didState.signingRequest[0].serializedPayload; const serializedBytes = Buffer.from(serializedPayload, 'base64'); const signature = sign(privKeyBytes, serializedBytes); @@ -92,26 +92,6 @@ test('resource-create. Send the final request for DID creating', async ({ reques expect(didCreate.status()).toBe(201); }); -test('resource-create. Fail to send content', async ({ request }) => { - const payload = await request.post(`/1.0/createResource`, { - data: { - did: didPayload.id, - name: 'ResourceName', - type: 'TextDocument', - version: '1.0', - options: { - network: 'testnet', - }, - }, - }); - - expect(payload.status()).toBe(400); - - const body = await payload.json(); - expect(body.didUrlState).toBeDefined(); - expect(body.didUrlState.description).toEqual('Invalid payload: name, type and content are required'); -}); - test('resource-create. Initiate Resource creation procedure', async ({ request }) => { const payload = await request.post(`/1.0/createResource`, { data: { @@ -138,7 +118,7 @@ test('resource-create. Initiate Resource creation procedure', async ({ request } resourceJobId = body.jobId; }); -test('resource-create. Send the final request for Resource creating', async ({ request }) => { +test('resource-create. Send the final request for Resource creation', async ({ request }) => { const serializedPayload = didUrlState.signingRequest[0].serializedPayload; const serializedBytes = Buffer.from(serializedPayload, 'base64'); const signature = sign(privKeyBytes, serializedBytes); diff --git a/tests/resource/resource-create.spec.ts b/tests/resource/resource-create.spec.ts index d109927..951283f 100644 --- a/tests/resource/resource-create.spec.ts +++ b/tests/resource/resource-create.spec.ts @@ -63,7 +63,7 @@ test('resource-create. Initiate DID Create procedure', async ({ request }) => { jobId = body.jobId; }); -test('resource-create. Send the final request for DID creating', async ({ request }) => { +test('resource-create. Send the final request for DID creation', async ({ request }) => { const serializedPayload = didState.signingRequest[0].serializedPayload; const serializedBytes = Buffer.from(serializedPayload, 'base64'); const signature = sign(privKeyBytes, serializedBytes); @@ -111,7 +111,7 @@ test('resource-create. Initiate Resource creation procedure', async ({ request } jobId = body.jobId; }); -test('resource-create. Send the final request for Resource creating', async ({ request }) => { +test('resource-create. Send the final request for Resource creation', async ({ request }) => { const serializedPayload = resourceState.signingRequest[0].serializedPayload; const serializedBytes = Buffer.from(serializedPayload, 'base64'); const signature = sign(privKeyBytes, serializedBytes); diff --git a/tests/resource/updateResource.spec.ts b/tests/resource/updateResource.spec.ts new file mode 100644 index 0000000..861bb06 --- /dev/null +++ b/tests/resource/updateResource.spec.ts @@ -0,0 +1,228 @@ +import { test, expect } from '@playwright/test'; +import { sign } from '@stablelib/ed25519'; +import { toString, fromString } from 'uint8arrays'; +import base64url from 'base64url'; + +import * as dotenv from 'dotenv'; +import { assert } from 'console'; + +dotenv.config(); + +const pub_key_base_64 = process.env.TEST_PUBLIC_KEY; +const priv_key_base_64 = process.env.TEST_PRIVATE_KEY; + +assert(pub_key_base_64, 'TEST_PUBLIC_KEY is not defined'); +assert(priv_key_base_64, 'TEST_PRIVATE_KEY is not defined'); + +const pubKeyHex = toString(fromString(pub_key_base_64 as string, 'base64pad'), 'base16'); +const privKeyBytes = base64url.toBuffer(priv_key_base_64 as string); + +let didPayload; +let didState; +let didUrlState; +let jobId; +let resourceJobId; +let resourceId; + +test('did-document. Generate the payload', async ({ request }) => { + const payload = await request.get( + `/1.0/did-document?verificationMethod=JsonWebKey2020&methodSpecificIdAlgo=uuid&network=testnet&publicKeyHex=${pubKeyHex}` + ); + + expect(payload.status()).toBe(200); + + const body = await payload.json(); + expect(body.didDoc).toBeDefined(); + expect(body.key).toBeDefined(); + expect(body.key.kid).toBeDefined(); + expect(body.key.publicKeyHex).toBeDefined(); + + didPayload = body.didDoc; +}); + +test('resource-update. Initiate DID Create procedure', async ({ request }) => { + const payload = await request.post('/1.0/create', { + data: { + didDocument: didPayload, + secret: {}, + options: { + network: 'testnet', + }, + }, + }); + + expect(payload.status()).toBe(200); + + const body = await payload.json(); + + expect(body.jobId).toBeDefined(); + expect(body.didState).toBeDefined(); + expect(body.didState.did).toBeDefined(); + expect(body.didState.state).toBeDefined(); + expect(body.didState.secret).toBeDefined(); + + didState = body.didState; + jobId = body.jobId; +}); + +test('resource-update. Send the final request for DID creation', async ({ request }) => { + const serializedPayload = didState.signingRequest[0].serializedPayload; + const serializedBytes = Buffer.from(serializedPayload, 'base64'); + const signature = sign(privKeyBytes, serializedBytes); + + const secret = { + signingResponse: [ + { + kid: didState.signingRequest[0].kid, + signature: toString(signature, 'base64'), + }, + ], + }; + + const didCreate = await request.post(`/1.0/create`, { + data: { + jobId: jobId, + secret: secret, + options: { + network: 'testnet', + }, + didDocument: didPayload, + }, + }); + + expect(didCreate.status()).toBe(201); +}); + +test('resource-update. Initiate Resource creation procedure', async ({ request }) => { + const payload = await request.post(`/1.0/createResource`, { + data: { + did: didPayload.id, + content: 'SGVsbG8gV29ybGQ=', + name: 'ResourceName', + type: 'TextDocument', + version: '1.0', + options: { + network: 'testnet', + }, + }, + }); + + expect(payload.status()).toBe(200); + + const body = await payload.json(); + expect(body.didUrlState).toBeDefined(); + expect(body.didUrlState.didUrl).toBeDefined(); + expect(body.didUrlState.state).toBeDefined(); + expect(body.didUrlState.signingRequest).toBeDefined(); + + didUrlState = body.didUrlState; + resourceJobId = body.jobId; +}); + +test('resource-update. Send the final request for Resource creation', async ({ request }) => { + const serializedPayload = didUrlState.signingRequest[0].serializedPayload; + const serializedBytes = Buffer.from(serializedPayload, 'base64'); + const signature = sign(privKeyBytes, serializedBytes); + + const secret = { + signingResponse: [ + { + kid: didUrlState.signingRequest[0].kid, + signature: toString(signature, 'base64'), + }, + ], + }; + + const resourceCreate = await request.post(`/1.0/createResource`, { + data: { + did: didPayload.id, + content: 'SGVsbG8gV29ybGQ=', + name: 'ResourceName', + type: 'TextDocument', + version: '1.0', + jobId: resourceJobId, + secret: secret, + options: { + network: 'testnet', + }, + }, + }); + const response = await resourceCreate.json(); + expect(resourceCreate.status()).toBe(201); + expect(response.didUrlState).toBeDefined(); + expect(response.didUrlState.didUrl).toBeDefined(); + expect(response.didUrlState.state).toBeDefined(); + expect(response.didUrlState.state).toEqual('finished'); + expect(response.didUrlState.name).toEqual('ResourceName'); + expect(response.didUrlState.type).toEqual('TextDocument'); + expect(response.didUrlState.version).toEqual('1.0'); + console.log('DIDUrl:' + response.didUrlState.didUrl); + resourceId = response.didUrlState.didUrl.split('/resources/')[1]; +}); + +test('resource-update. Initiate Resource update procedure', async ({ request }) => { + const payload = await request.post(`/1.0/updateResource`, { + data: { + did: didPayload.id, + name: 'ResourceName', + type: 'TextDocument', + content: ['SGVsbG8gV29ybGQ='], + version: '2.0', + relativeDidUrl: '/resources/' + resourceId, + options: { + network: 'testnet', + }, + }, + }); + + expect(payload.status()).toBe(200); + + const body = await payload.json(); + expect(body.didUrlState).toBeDefined(); + expect(body.didUrlState.didUrl).toBeDefined(); + expect(body.didUrlState.state).toBeDefined(); + expect(body.didUrlState.signingRequest).toBeDefined(); + + didUrlState = body.didUrlState; + resourceJobId = body.jobId; +}); + +test('resource-update. Send the final request for Resource update', async ({ request }) => { + const serializedPayload = didUrlState.signingRequest[0].serializedPayload; + const serializedBytes = Buffer.from(serializedPayload, 'base64'); + const signature = sign(privKeyBytes, serializedBytes); + + const secret = { + signingResponse: [ + { + kid: didUrlState.signingRequest[0].kid, + signature: toString(signature, 'base64'), + }, + ], + }; + + const resourceCreate = await request.post(`/1.0/updateResource`, { + data: { + did: didPayload.id, + content: ['SGVsbG8gV29ybGQ='], + name: 'ResourceName', + type: 'TextDocument', + version: '1.0', + jobId: resourceJobId, + relativeDidUrl: '/resources/' + resourceId, + secret: secret, + options: { + network: 'testnet', + }, + }, + }); + const response = await resourceCreate.json(); + expect(resourceCreate.status()).toBe(201); + expect(response.didUrlState).toBeDefined(); + expect(response.didUrlState.didUrl).toBeDefined(); + expect(response.didUrlState.state).toBeDefined(); + expect(response.didUrlState.state).toEqual('finished'); + expect(response.didUrlState.name).toEqual('ResourceName'); + expect(response.didUrlState.type).toEqual('TextDocument'); + expect(response.didUrlState.version).toEqual('2.0'); +}); diff --git a/tests/resource/validateResource.spec.ts b/tests/resource/validateResource.spec.ts new file mode 100644 index 0000000..de6e1cc --- /dev/null +++ b/tests/resource/validateResource.spec.ts @@ -0,0 +1,261 @@ +import { test, expect } from '@playwright/test'; +import * as dotenv from 'dotenv'; + +dotenv.config(); + +let activeDid = 'did:cheqd:testnet:a9ed7bb4-d706-4454-bbbc-feabebe801b8'; +let indyDid = 'did:indy:sovrin:WRfXPg8dantKVubE3HX8pw'; +let deactiveDid = 'did:cheqd:testnet:ca9ff47c-0286-4614-a4be-8ffa83911e09'; +let didUrl = '/resources/bf94eb78-228b-4e4a-88be-1ef2fa44be48'; + +test('resource-create. wrong did', async ({ request }) => { + const payload = await request.post(`/1.0/createResource`, { + data: { + did: indyDid, + name: 'ResourceName', + type: 'TextDocument', + version: '1.0', + options: { + network: 'testnet', + }, + }, + }); + + expect(payload.status()).toBe(400); + + const body = await payload.json(); + expect(body.didUrlState).toBeDefined(); + expect(body.didUrlState.description).toEqual('Invalid payload: The DID is not valid'); +}); + +test('resource-create. Fail to send content', async ({ request }) => { + const payload = await request.post(`/1.0/createResource`, { + data: { + did: activeDid, + name: 'ResourceName', + type: 'TextDocument', + version: '1.0', + options: { + network: 'testnet', + }, + }, + }); + + expect(payload.status()).toBe(400); + + const body = await payload.json(); + expect(body.didUrlState).toBeDefined(); + expect(body.didUrlState.description).toEqual('Invalid payload: name, type and content are required'); +}); + +test('resource-create. Send wrong content type', async ({ request }) => { + const payload = await request.post(`/1.0/createResource`, { + data: { + did: activeDid, + name: 'ResourceName', + type: 'TextDocument', + version: '1.0', + content: 50, + options: { + network: 'testnet', + }, + }, + }); + + expect(payload.status()).toBe(400); + + const body = await payload.json(); + expect(body.didUrlState).toBeDefined(); + expect(body.didUrlState.description).toEqual('Invalid payload: Invalid payload'); +}); +test('resource-create. Send deactivated did', async ({ request }) => { + const payload = await request.post(`/1.0/createResource`, { + data: { + did: deactiveDid, + name: 'ResourceName', + type: 'TextDocument', + version: '1.0', + content: 'Test Data', + options: { + network: 'testnet', + }, + }, + }); + + expect(payload.status()).toBe(400); + + const body = await payload.json(); + expect(body.didUrlState).toBeDefined(); + expect(body.didUrlState.description).toEqual('Invalid payload: The DID does not exist or is Deactivated'); +}); + +test('resource-update. wrong did', async ({ request }) => { + const payload = await request.post(`/1.0/updateResource`, { + data: { + did: indyDid, + name: 'ResourceName', + type: 'TextDocument', + version: '1.0', + options: { + network: 'testnet', + }, + }, + }); + + expect(payload.status()).toBe(400); + + const body = await payload.json(); + expect(body.didUrlState).toBeDefined(); + expect(body.didUrlState.description).toEqual('Invalid payload: The DID is not valid'); +}); + +test('resource-update. Fail to send content', async ({ request }) => { + const payload = await request.post(`/1.0/updateResource`, { + data: { + did: activeDid, + name: 'ResourceName', + type: 'TextDocument', + version: '1.0', + options: { + network: 'testnet', + }, + }, + }); + + expect(payload.status()).toBe(400); + + const body = await payload.json(); + expect(body.didUrlState).toBeDefined(); + expect(body.didUrlState.description).toEqual('Invalid payload: name, type and content are required'); +}); + +test('resource-update. Send wrong content type', async ({ request }) => { + const payload = await request.post(`/1.0/updateResource`, { + data: { + did: activeDid, + name: 'ResourceName', + type: 'TextDocument', + version: '1.0', + content: [50], + options: { + network: 'testnet', + }, + }, + }); + + expect(payload.status()).toBe(400); + + const body = await payload.json(); + expect(body.didUrlState).toBeDefined(); + expect(body.didUrlState.description).toEqual( + 'Invalid payload: The content array must be provided and must have exactly one string' + ); +}); +test('resource-update. Send wrong didUrl', async ({ request }) => { + const payload = await request.post(`/1.0/updateResource`, { + data: { + did: deactiveDid, + name: 'ResourceName', + type: 'TextDocument', + version: '1.0', + content: ['Test Data'], + relativeDidUrl: 'abcdef', + options: { + network: 'testnet', + }, + }, + }); + + expect(payload.status()).toBe(400); + + const body = await payload.json(); + expect(body.didUrlState).toBeDefined(); + expect(body.didUrlState.description).toEqual('Invalid payload: The DID URL is not valid'); +}); +test('resource-update. Send wrong operation', async ({ request }) => { + const payload = await request.post(`/1.0/updateResource`, { + data: { + did: deactiveDid, + name: 'ResourceName', + type: 'TextDocument', + version: '1.0', + content: ['Test Data'], + relativeDidUrl: didUrl, + contentOperation: ['removeContent'], + options: { + network: 'testnet', + }, + }, + }); + + expect(payload.status()).toBe(400); + + const body = await payload.json(); + expect(body.didUrlState).toBeDefined(); + expect(body.didUrlState.description).toEqual('Invalid payload: Only Set operation is supported'); +}); +test('resource-update. Send deactivated did', async ({ request }) => { + const payload = await request.post(`/1.0/updateResource`, { + data: { + did: deactiveDid, + name: 'ResourceName', + type: 'TextDocument', + version: '1.0', + content: ['Test Data'], + relativeDidUrl: didUrl, + options: { + network: 'testnet', + }, + }, + }); + + expect(payload.status()).toBe(400); + + const body = await payload.json(); + expect(body.didUrlState).toBeDefined(); + expect(body.didUrlState.description).toEqual('Invalid payload: The DID does not exist or is Deactivated'); +}); +test('resource-update. Send wrong name/type', async ({ request }) => { + const payload = await request.post(`/1.0/updateResource`, { + data: { + did: activeDid, + name: 'ResourceName1', + type: 'TextDocument', + version: '1.0', + content: ['Test Data'], + relativeDidUrl: didUrl, + options: { + network: 'testnet', + }, + }, + }); + + expect(payload.status()).toBe(400); + + const body = await payload.json(); + expect(body.didUrlState).toBeDefined(); + expect(body.didUrlState.description).toEqual( + 'Invalid payload: Update resource name or type does not match existing resource' + ); +}); +test('resource-update. Resource not found', async ({ request }) => { + const payload = await request.post(`/1.0/updateResource`, { + data: { + did: activeDid, + name: 'ResourceName', + type: 'TextDocument', + version: '1.0', + content: ['Test Data'], + relativeDidUrl: '/resources/1234567', + options: { + network: 'testnet', + }, + }, + }); + + expect(payload.status()).toBe(400); + + const body = await payload.json(); + expect(body.didUrlState).toBeDefined(); + expect(body.didUrlState.description).toEqual('Invalid payload: Resource does not exist'); +}); From b52a7dc1d2040d3ab046d87101723818cf658776 Mon Sep 17 00:00:00 2001 From: Sownak Roy Date: Tue, 10 Dec 2024 14:47:36 +0000 Subject: [PATCH 2/3] fix md error Signed-off-by: Sownak Roy --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index de2b719..62c0875 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ This repository contains the playwright tests for unit and integration testing. Add any additional tests in the `tests` directory. You must set up these two env vars before running test: + 1. `TEST_PRIVATE_KEY` : Private key for signing the requests 2. `TEST_PUBLIC_KEY` : Corresponding public key From c76b13162861c106ea94d625aaca0db17038226c Mon Sep 17 00:00:00 2001 From: Sownak Roy Date: Thu, 12 Dec 2024 14:51:10 +0000 Subject: [PATCH 3/3] Updated with review comments Signed-off-by: Sownak Roy --- src/controllers/resource.ts | 123 ++++++++++++++++-------- src/types/constants.ts | 1 + tests/resource/createResource.spec.ts | 23 +++++ tests/resource/updateResource.spec.ts | 91 +++++++++++++++++- tests/resource/validateResource.spec.ts | 21 ++++ 5 files changed, 217 insertions(+), 42 deletions(-) diff --git a/src/controllers/resource.ts b/src/controllers/resource.ts index f703989..0f1edbd 100644 --- a/src/controllers/resource.ts +++ b/src/controllers/resource.ts @@ -43,7 +43,7 @@ export class ResourceController { return true; }) .withMessage('name, type and content are required'), - check('content').exists().isString().withMessage(Messages.Invalid), + check('content').optional().isString().withMessage(Messages.Invalid), check('name').optional().isString().withMessage(Messages.Invalid), check('type').optional().isString().withMessage(Messages.Invalid), check('version').optional().isString().withMessage(Messages.Invalid), @@ -63,7 +63,7 @@ export class ResourceController { check('name').optional().isString().withMessage(Messages.Invalid), check('type').optional().isString().withMessage(Messages.Invalid), check('content') - .exists() + .optional() .isArray() .custom((value) => { if (value.length !== 1) return false; @@ -71,7 +71,7 @@ export class ResourceController { return true; }) .withMessage('The content array must be provided and must have exactly one string'), - check('relativeDidUrl').exists().isString().contains('/resources/').withMessage(Messages.InvalidDidUrl), + check('relativeDidUrl').optional().isString().contains('/resources/').withMessage(Messages.InvalidDidUrl), check('contentOperation') .optional() .isArray() @@ -190,6 +190,28 @@ export class ResourceController { }); } } + + // function to get resource by using name and type + public static async checkResourceStatus( + did: string, + name: string, + type: string + ): Promise<{ existingResource: any }> { + let existingResource; + let queryString = did + '?resourceName=' + name + '&resourceType=' + type + '&resourceMetadata=true'; + let resource = await CheqdResolver(queryString); + if (resource) + if (resource.contentStream) { + let metadata = resource.contentStream.linkedResourceMetadata || []; + if (metadata.length >= 1) { + return { + existingResource: metadata[0], + }; + } + } + return { existingResource: existingResource }; + } + public async createResource(request: Request, response: Response) { const result = validationResult(request); if (!result.isEmpty()) { @@ -210,6 +232,7 @@ export class ResourceController { } = request.body as IResourceCreateRequest; let resourcePayload: Partial = {}; + try { // check if did is registered on the ledger let resolvedDocument = await CheqdResolver(did); @@ -217,10 +240,8 @@ export class ResourceController { return response .status(400) .send(Responses.GetInvalidResourceResponse(did, {}, secret, Messages.DidNotFound)); - } else { - resolvedDocument = resolvedDocument.didDocument; } - + resolvedDocument = resolvedDocument.didDocument; // Validate and get store data if any if (jobId) { const storeData = LocalStore.instance.getResource(jobId); @@ -245,6 +266,12 @@ export class ResourceController { .status(400) .json(Responses.GetInvalidResourceResponse('', {}, secret, Messages.InvalidContent)); } else { + const checkResource = await ResourceController.checkResourceStatus(did, name, type); + if (checkResource.existingResource) { + return response + .status(400) + .send(Responses.GetInvalidResourceResponse(did, {}, secret, Messages.ResourceExists)); + } jobId = v4(); resourcePayload = { @@ -321,7 +348,7 @@ export class ResourceController { } = request.body as IResourceUpdateRequest; let resourcePayload: Partial = {}; - let existingResource; + try { // check if did is registered on the ledger let resolvedDocument = await CheqdResolver(did); @@ -329,38 +356,8 @@ export class ResourceController { return response .status(400) .send(Responses.GetInvalidResourceResponse(did, {}, secret, Messages.DidNotFound)); - } else { - const didUrlIndex = resolvedDocument.didDocumentMetadata.linkedResourceMetadata.findIndex( - (resource: { resourceURI: string }) => resource.resourceURI === did + relativeDidUrl - ); - if (didUrlIndex !== -1) { - existingResource = resolvedDocument.didDocumentMetadata.linkedResourceMetadata[didUrlIndex]; - if (existingResource.resourceName !== name || existingResource.resourceType !== type) - return response - .status(400) - .send( - Responses.GetInvalidResourceResponse( - did, - { id: relativeDidUrl.split('resources/')[1] }, - secret, - Messages.InvalidUpdateResource - ) - ); - } else { - return response - .status(400) - .send( - Responses.GetInvalidResourceResponse( - did, - { id: relativeDidUrl.split('resources/')[1] }, - secret, - Messages.ResourceNotFound - ) - ); - } - resolvedDocument = resolvedDocument.didDocument; } - + const resolvedDidDocument = resolvedDocument.didDocument; // Validate and get store data if any if (jobId) { const storeData = LocalStore.instance.getResource(jobId); @@ -385,6 +382,54 @@ export class ResourceController { .status(400) .json(Responses.GetInvalidResourceResponse('', {}, secret, Messages.InvalidContent)); } else { + let existingResource; + const linkedResourceMetadata = resolvedDocument.didDocumentMetadata.linkedResourceMetadata || []; + + if (relativeDidUrl) { + // search resource using relativeDidUrl + const didUrlIndex = linkedResourceMetadata.findIndex( + (resource: { resourceURI: string }) => resource.resourceURI === did + relativeDidUrl + ); + if (didUrlIndex !== -1) { + // if resource is found using relativeDidUrl + existingResource = linkedResourceMetadata[didUrlIndex]; + // passed name and type must match + if (existingResource.resourceName !== name || existingResource.resourceType !== type) + return response + .status(400) + .send( + Responses.GetInvalidResourceResponse( + did, + { id: relativeDidUrl.split('resources/')[1] }, + secret, + Messages.InvalidUpdateResource + ) + ); + // If resource has a nextVersionId, then return error + if (existingResource.nextVersionId) { + return response + .status(400) + .send( + Responses.GetInvalidResourceResponse( + did, + {}, + secret, + 'Only latest version of resource can be updated' + ) + ); + } + } + } else { + // if not relativeDidUrl, find by name and type + const checkResource = await ResourceController.checkResourceStatus(did, name, type); + existingResource = checkResource.existingResource; + } + if (!existingResource) { + return response + .status(400) + .send(Responses.GetInvalidResourceResponse(did, {}, secret, Messages.ResourceNotFound)); + } + jobId = v4(); resourcePayload = { @@ -408,7 +453,7 @@ export class ResourceController { .json( Responses.GetResourceActionSignatureResponse( jobId, - resolvedDocument.verificationMethod, + resolvedDidDocument.verificationMethod, did, resourcePayload ) diff --git a/src/types/constants.ts b/src/types/constants.ts index 473025b..957edba 100644 --- a/src/types/constants.ts +++ b/src/types/constants.ts @@ -11,6 +11,7 @@ export enum Messages { SecretValidation = 'Provide either a valid KeyPair or Signature', InvalidResource = 'Resource Data is invalid', ResourceNotFound = 'Resource does not exist', + ResourceExists = 'Resource already exists', InvalidContent = 'Resource Content is invalid', InvalidUpdateResource = 'Update resource name or type does not match existing resource', TestnetFaucet = 'sketch mountain erode window enact net enrich smoke claim kangaroo another visual write meat latin bacon pulp similar forum guilt father state erase bright', diff --git a/tests/resource/createResource.spec.ts b/tests/resource/createResource.spec.ts index f1eb31d..5c1c60d 100644 --- a/tests/resource/createResource.spec.ts +++ b/tests/resource/createResource.spec.ts @@ -156,3 +156,26 @@ test('resource-create. Send the final request for Resource creation', async ({ r expect(response.didUrlState.type).toEqual('TextDocument'); expect(response.didUrlState.version).toEqual('1.0'); }); + +test('resource-create. Fail second create with same name and type', async ({ request }) => { + const payload = await request.post(`/1.0/createResource`, { + data: { + did: didPayload.id, + content: 'SGVsbG8gV29ybGQ=', + name: 'ResourceName', + type: 'TextDocument', + version: '1.0', + options: { + network: 'testnet', + }, + }, + }); + + expect(payload.status()).toBe(400); + + const body = await payload.json(); + expect(body.didUrlState).toBeDefined(); + expect(body.didUrlState.state).toBeDefined(); + expect(body.didUrlState.state).toEqual('failed'); + expect(body.didUrlState.description).toEqual('Invalid payload: Resource already exists'); +}); diff --git a/tests/resource/updateResource.spec.ts b/tests/resource/updateResource.spec.ts index 861bb06..3d5304b 100644 --- a/tests/resource/updateResource.spec.ts +++ b/tests/resource/updateResource.spec.ts @@ -201,7 +201,7 @@ test('resource-update. Send the final request for Resource update', async ({ req ], }; - const resourceCreate = await request.post(`/1.0/updateResource`, { + const resourceUpdate = await request.post(`/1.0/updateResource`, { data: { did: didPayload.id, content: ['SGVsbG8gV29ybGQ='], @@ -216,8 +216,8 @@ test('resource-update. Send the final request for Resource update', async ({ req }, }, }); - const response = await resourceCreate.json(); - expect(resourceCreate.status()).toBe(201); + const response = await resourceUpdate.json(); + expect(resourceUpdate.status()).toBe(201); expect(response.didUrlState).toBeDefined(); expect(response.didUrlState.didUrl).toBeDefined(); expect(response.didUrlState.state).toBeDefined(); @@ -226,3 +226,88 @@ test('resource-update. Send the final request for Resource update', async ({ req expect(response.didUrlState.type).toEqual('TextDocument'); expect(response.didUrlState.version).toEqual('2.0'); }); + +test('resource-update. Resource update without relativeDidUrl', async ({ request }) => { + const payload = await request.post(`/1.0/updateResource`, { + data: { + did: didPayload.id, + name: 'ResourceName', + type: 'TextDocument', + content: ['SGVsbG8gV29ybGQ='], + version: '3.0', + options: { + network: 'testnet', + }, + }, + }); + + expect(payload.status()).toBe(200); + + const body = await payload.json(); + expect(body.didUrlState).toBeDefined(); + expect(body.didUrlState.didUrl).toBeDefined(); + expect(body.didUrlState.state).toBeDefined(); + expect(body.didUrlState.signingRequest).toBeDefined(); + + didUrlState = body.didUrlState; + resourceJobId = body.jobId; +}); + +test('resource-update. Send the final update without relativeDidUrl', async ({ request }) => { + const serializedPayload = didUrlState.signingRequest[0].serializedPayload; + const serializedBytes = Buffer.from(serializedPayload, 'base64'); + const signature = sign(privKeyBytes, serializedBytes); + + const secret = { + signingResponse: [ + { + kid: didUrlState.signingRequest[0].kid, + signature: toString(signature, 'base64'), + }, + ], + }; + + const resourceUpdate = await request.post(`/1.0/updateResource`, { + data: { + did: didPayload.id, + jobId: resourceJobId, + secret: secret, + options: { + network: 'testnet', + }, + }, + }); + const response = await resourceUpdate.json(); + expect(resourceUpdate.status()).toBe(201); + expect(response.didUrlState).toBeDefined(); + expect(response.didUrlState.didUrl).toBeDefined(); + expect(response.didUrlState.state).toBeDefined(); + expect(response.didUrlState.state).toEqual('finished'); + expect(response.didUrlState.name).toEqual('ResourceName'); + expect(response.didUrlState.type).toEqual('TextDocument'); + expect(response.didUrlState.version).toEqual('3.0'); +}); + +test('resource-update. Fail Resource update with existing nextVersionId', async ({ request }) => { + const payload = await request.post(`/1.0/updateResource`, { + data: { + did: didPayload.id, + name: 'ResourceName', + type: 'TextDocument', + content: ['SGVsbG8gV29ybGQ='], + version: '4.0', + relativeDidUrl: '/resources/' + resourceId, + options: { + network: 'testnet', + }, + }, + }); + + expect(payload.status()).toBe(400); + + const body = await payload.json(); + expect(body.didUrlState).toBeDefined(); + expect(body.didUrlState.state).toBeDefined(); + expect(body.didUrlState.state).toEqual('failed'); + expect(body.didUrlState.description).toEqual('Invalid payload: Only latest version of resource can be updated'); +}); diff --git a/tests/resource/validateResource.spec.ts b/tests/resource/validateResource.spec.ts index de6e1cc..c780999 100644 --- a/tests/resource/validateResource.spec.ts +++ b/tests/resource/validateResource.spec.ts @@ -259,3 +259,24 @@ test('resource-update. Resource not found', async ({ request }) => { expect(body.didUrlState).toBeDefined(); expect(body.didUrlState.description).toEqual('Invalid payload: Resource does not exist'); }); + +test('resource-update. Send wrong name/type without relativeDidUrl', async ({ request }) => { + const payload = await request.post(`/1.0/updateResource`, { + data: { + did: activeDid, + name: 'ResourceName2', + type: 'TextDocument2', + version: '1.0', + content: ['Test Data'], + options: { + network: 'testnet', + }, + }, + }); + + expect(payload.status()).toBe(400); + + const body = await payload.json(); + expect(body.didUrlState).toBeDefined(); + expect(body.didUrlState.description).toEqual('Invalid payload: Resource does not exist'); +});