diff --git a/README.md b/README.md index ca204f6..e245919 100644 --- a/README.md +++ b/README.md @@ -2,32 +2,32 @@ ## Description -An API service for issuing hydrogen certificates +An API service for issuing hydrogen certificates ## Configuration Use a `.env` at root of the repository to set values for the environment variables defined in `.env` file. -| variable | required | default | description | -| :--------------------- | :------: | :--------------------: | :------------------------------------------------------------------------------------------- | -| PORT | N | `3000` | The port for the API to listen on | -| LOG_LEVEL | N | `debug` | Logging level. Valid values are [`trace`, `debug`, `info`, `warn`, `error`, `fatal`] | -| ENVIRONMENT_VAR | N | `example` | An environment specific variable | -| DB_PORT | N | `5432` | The port for the database | -| DB_HOST | Y | - | The database hostname / host | +| variable | required | default | description | +| :--------------------- | :------: | :-----------------: | :------------------------------------------------------------------------------------------- | +| PORT | N | `3000` | The port for the API to listen on | +| LOG_LEVEL | N | `debug` | Logging level. Valid values are [`trace`, `debug`, `info`, `warn`, `error`, `fatal`] | +| ENVIRONMENT_VAR | N | `example` | An environment specific variable | +| DB_PORT | N | `5432` | The port for the database | +| DB_HOST | Y | - | The database hostname / host | | DB_NAME | N | `dscp-hyproof-api ` | The database name | -| DB_USERNAME | Y | - | The database username | -| DB_PASSWORD | Y | - | The database password | -| IDENTITY_SERVICE_HOST | Y | - | Hostname of the `dscp-identity-service` | -| IDENTITY_SERVICE_PORT | N | `3000` | Port of the `dscp-identity-service` | -| NODE_HOST | Y | - | The hostname of the `dscp-node` the API should connect to | -| NODE_PORT | N | `9944` | The port of the `dscp-node` the API should connect to | -| LOG_LEVEL | N | `info` | Logging level. Valid values are [`trace`, `debug`, `info`, `warn`, `error`, `fatal`] | -| USER_URI | Y | - | The Substrate `URI` representing the private key to use when making `dscp-node` transactions | -| IPFS_HOST | Y | - | Hostname of the `IPFS` node to use for metadata storage | -| IPFS_PORT | N | `5001` | Port of the `IPFS` node to use for metadata storage | -| WATCHER_POLL_PERIOD_MS | N | `10000` | Number of ms between polling of service state | -| WATCHER_TIMEOUT_MS | N | `2000` | Timeout period in ms for service state | +| DB_USERNAME | Y | - | The database username | +| DB_PASSWORD | Y | - | The database password | +| IDENTITY_SERVICE_HOST | Y | - | Hostname of the `dscp-identity-service` | +| IDENTITY_SERVICE_PORT | N | `3000` | Port of the `dscp-identity-service` | +| NODE_HOST | Y | - | The hostname of the `dscp-node` the API should connect to | +| NODE_PORT | N | `9944` | The port of the `dscp-node` the API should connect to | +| LOG_LEVEL | N | `info` | Logging level. Valid values are [`trace`, `debug`, `info`, `warn`, `error`, `fatal`] | +| USER_URI | Y | - | The Substrate `URI` representing the private key to use when making `dscp-node` transactions | +| IPFS_HOST | Y | - | Hostname of the `IPFS` node to use for metadata storage | +| IPFS_PORT | N | `5001` | Port of the `IPFS` node to use for metadata storage | +| WATCHER_POLL_PERIOD_MS | N | `10000` | Number of ms between polling of service state | +| WATCHER_TIMEOUT_MS | N | `2000` | Timeout period in ms for service state | ## Getting started @@ -108,14 +108,31 @@ npm run flows ### Fundamental entities -there is the `attachment` entity which returns an `id` to be used when preparing entity updates to attach files. +This is project is primary based on two entities in regards to the API. -### Services +> Certificate - that can be issued, initiated and revoked by a regulator. This is something we will keep circulating arround +> Attachments - file or json information that is stored on IPFS servie +> Transaction - records representing interactions with the chain -Run `docker-compose up -d` to start the required dependencies to fully demo a single-party version of `dscp-hyproof-api`. +### Running locally + +running with docker-compose -f docker-compose-3-personal.yml logs -f will render all logs, also can be parsed by `| grep`. + +``` +docker-compose -f docker-compose-3-personal.yml logs -f | grep regulator +``` + +> single persona +> Run `docker composel up -d` to start the required dependencies to fully demo `dscp-hyproof-api`. + +> multiple persona +> Run `docker-compose -f docker-compose-3-personal.yml up` to start the required dependencies to fully demo `dscp-hyproof-api`. + +> services - dscp-hyproof-api (+ PostgreSQL) - dscp-identity-service (+ PostgreSQL) +- ipfs - dscp-node You can run a full 3-party demonstration using `docker-compose -f docker-compose-3-persona.yml up --build -d`. @@ -126,27 +143,27 @@ The 3-party demonstration creates 3 personas with different roles, given below. `Heidi (the Hydrogen Producer)`: - - [localhost:8000/swagger](http://localhost:8000/swagger/#/) +- [localhost:8000/swagger](http://localhost:8000/swagger/#/) - - [localhost:9000/v1/swagger](http://localhost:9000/v1/swagger/#/) +- [localhost:9000/v1/swagger](http://localhost:9000/v1/swagger/#/) `Emma (the Energy Provider)`: - - [localhost:8010/swagger](http://localhost:8010/swagger/#/) +- [localhost:8010/swagger](http://localhost:8010/swagger/#/) - - [localhost:9010/v1/swagger](http://localhost:9010/v1/swagger/#/) +- [localhost:9010/v1/swagger](http://localhost:9010/v1/swagger/#/) `Reginald (the Regulator)`: - - [localhost:8020/swagger](http://localhost:8020/swagger/#/) +- [localhost:8020/swagger](http://localhost:8020/swagger/#/) - - [localhost:9020/v1/swagger](http://localhost:9020/v1/swagger/#/) +- [localhost:9020/v1/swagger](http://localhost:9020/v1/swagger/#/) The single-party version only uses: - - [localhost:8000/swagger](http://localhost:8000/swagger/#/) +- [localhost:8000/swagger](http://localhost:8000/swagger/#/) - - [localhost:9000/v1/swagger](http://localhost:9000/v1/swagger/#/) +- [localhost:9000/v1/swagger](http://localhost:9000/v1/swagger/#/) ### Using the HyProof API diff --git a/package-lock.json b/package-lock.json index 8584f11..b16bda7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@digicatapult/dscp-hyproof-api", - "version": "0.5.14", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@digicatapult/dscp-hyproof-api", - "version": "0.5.14", + "version": "0.6.0", "license": "Apache-2.0", "dependencies": { "@polkadot/api": "^10.11.1", diff --git a/package.json b/package.json index 8fd7a8b..85774c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@digicatapult/dscp-hyproof-api", - "version": "0.5.14", + "version": "0.6.0", "description": "An OpenAPI API service for DSCP", "main": "src/index.ts", "scripts": { diff --git a/processFlows.dscp b/processFlows.dscp index a70737a..eb7ed68 100644 --- a/processFlows.dscp +++ b/processFlows.dscp @@ -65,4 +65,5 @@ pub fn revoke_cert |input: IssuedCert| => |output: RevokedCert| where { output.energy_owner != sender, output.hydrogen_owner != sender, output.regulator == sender, + output.reason: File } diff --git a/processFlows.json b/processFlows.json index 3d2b82a..de5cd5e 100644 --- a/processFlows.json +++ b/processFlows.json @@ -755,6 +755,18 @@ } } }, + { + "Op": "And" + }, + { + "Restriction": { + "FixedOutputMetadataValueType": { + "index": 0, + "metadata_key": "reason", + "metadata_value_type": "File" + } + } + }, { "Op": "And" } diff --git a/src/controllers/v1/attachment/index.ts b/src/controllers/v1/attachment/index.ts index 57a3fac..80ceae1 100644 --- a/src/controllers/v1/attachment/index.ts +++ b/src/controllers/v1/attachment/index.ts @@ -124,14 +124,16 @@ export class attachment extends Controller { this.log.debug(`attempting to retrieve ${id} attachment`) const [attachment] = await this.db.get('attachment', { id }) if (!attachment) throw new NotFound('attachment') - const { filename, ipfs_hash, size } = attachment + let { filename, size } = attachment - const { blob, filename: ipfsFilename } = await this.ipfs.getFile(ipfs_hash) + const { blob, filename: ipfsFilename } = await this.ipfs.getFile(attachment.ipfs_hash) const blobBuffer = Buffer.from(await blob.arrayBuffer()) if (size === null || filename === null) { + filename = ipfsFilename + size = blob.size try { - await this.db.update('attachment', { id }, { filename: ipfsFilename, size: blob.size }) + await this.db.update('attachment', { id }, { filename, size }) } catch (err) { const message = err instanceof Error ? err.message : 'unknown' this.log.warn('Error updating attachment size: %s', message) @@ -155,6 +157,6 @@ export class attachment extends Controller { } } } - return this.octetResponse(blobBuffer, filename || ipfsFilename) + return this.octetResponse(blobBuffer, filename) } } diff --git a/src/controllers/v1/certificate/index.ts b/src/controllers/v1/certificate/index.ts index 2278256..b409dbd 100644 --- a/src/controllers/v1/certificate/index.ts +++ b/src/controllers/v1/certificate/index.ts @@ -17,13 +17,14 @@ import type { Logger } from 'pino' import { injectable } from 'tsyringe' import { logger } from '../../../lib/logger' -import Database, { CertificateRow, Where } from '../../../lib/db' +import { CertificateRow, Where } from '../../../lib/db/types' +import Database from '../../../lib/db' import { BadRequest, InternalServerError, NotFound } from '../../../lib/error-handler/index' import Identity from '../../../lib/services/identity' import * as Certificate from '../../../models/certificate' import { DATE, UUID } from '../../../models/strings' import ChainNode from '../../../lib/chainNode' -import { processInitiateCert, processIssueCert } from '../../../lib/payload' +import { processInitiateCert, processIssueCert, processRevokeCert } from '../../../lib/payload' import { TransactionState } from '../../../models/transaction' import Commitment from '../../../lib/services/commitment' import EmissionsCalculator from '../../../lib/services/emissionsCalculator' @@ -307,9 +308,10 @@ export class CertificateController extends Controller { } /** - * Update a certificate on-chain to include emissions data - * @summary Issue a new certificate on-chain - * @param demandAId The certificate's identifier + * Update a certificate on-chain to include + * @summary updates initiated certitificate status to issued on chain + * along with the embodied_co2 + * @param id the local certificate's identifier */ @Post('{id}/issuance') @Response(404, 'Item not found') @@ -364,4 +366,80 @@ export class CertificateController extends Controller { return transaction } + + /** + * @summary returns certificate transaction by certificate and transaction id + * @param id - the local certificate's identifier + * @example id "52907745-7672-470e-a803-a2f8feb52944" + */ + @Response(422, 'Validation Failed') + @Response(404, ' not found') + @Response(400, 'ID must be supplied in UUID format') + @Get('{id}/revocation/{transactionId}') + public async getRevocationTransaction( + @Path() id: UUID, + transactionId: UUID + ): Promise { + if (!id || !transactionId) throw new BadRequest() + + const [transaction] = await this.db.get('transaction', { + local_id: id, + id: transactionId, + api_type: 'certificate', + transaction_type: 'revoke_cert', + }) + if (!transaction) throw new NotFound(id) + return transaction + } + + /** + * @summary returns transactions by certificate local id + * @example id "52907745-7672-470e-a803-a2f8feb52944" + */ + @Response(422, 'Validation Failed') + @Response(404, ' not found') + @Response(400, 'ID must be supplied in UUID format') + @Get('{id}/revocation') + public async getRevocationTransactions(@Path() id: UUID): Promise { + if (!id) throw new BadRequest() + return await this.db.get('transaction', { local_id: id, api_type: 'certificate', transaction_type: 'revoke_cert' }) + } + + /** + * Updates a certificate on-chain to include + * @summary changes issued certificate to revoked + * @param id - the local certificate's identifier + */ + @Post('{id}/revocation') + @Response(404, 'Item not found') + @SuccessResponse('201') + public async revokeOnChain( + @Path() id: UUID, + @Body() { reason }: Certificate.RevokePayload + ): Promise { + const { address: self_address } = await this.identity.getMemberBySelf() + const [certificate] = await this.db.get('certificate', { id }) + + if (!certificate) throw new NotFound(id) + if (certificate.state !== 'issued') throw new BadRequest('certificate must be issued to revoke') + if (certificate.regulator !== self_address) throw new BadRequest('certificates can be revoked only by a regulator') + + const [attachment] = await this.db.get('attachment', { id: reason }) + if (!attachment) throw new NotFound(reason) + + const extrinsic = await this.node.prepareRunProcess(processRevokeCert(certificate, attachment)) + const [transaction] = await this.db.insert('transaction', { + api_type: 'certificate', + local_id: certificate.id, + hash: extrinsic.hash.toHex(), + transaction_type: 'revoke_cert', + }) + if (!transaction) throw new InternalServerError('Transaction must exist') + + this.node.submitRunProcess(extrinsic, (state: TransactionState) => + this.db.update('transaction', { id: transaction.id }, { state }) + ) + + return transaction + } } diff --git a/src/lib/chainNode.ts b/src/lib/chainNode.ts index 8f28b73..746f75b 100644 --- a/src/lib/chainNode.ts +++ b/src/lib/chainNode.ts @@ -13,6 +13,7 @@ import { logger } from './logger' import { Env } from '../env' import { injectable, singleton } from 'tsyringe' import { trim0x } from './utils/shared' +import Database from './db' const processRanTopic = blake2AsHex('utxoNFT.ProcessRan') @@ -59,7 +60,10 @@ export default class ChainNode { private logger: Logger private userUri: string - constructor(private env: Env) { + constructor( + private env: Env, + private db: Database + ) { this.logger = logger.child({ module: 'ChainNode' }) this.provider = new WsProvider(`ws://${this.env.get('NODE_HOST')}:${this.env.get('NODE_PORT')}`) this.userUri = this.env.get('USER_URI') @@ -260,21 +264,25 @@ export default class ChainNode { const api = blockHash ? await this.api.at(blockHash) : this.api const token = (await api.query.utxoNFT.tokensById(tokenId)).toJSON() as unknown as SubstrateToken const metadata = new Map( - Object.entries(token.metadata).map(([keyHex, entry]) => { - const key = Buffer.from(keyHex.substring(2), 'hex').toString('utf8') - const [valueKey, valueRaw] = Object.entries(entry)[0] - if (valueKey === 'None' || valueKey === 'tokenId') { - return [key, valueRaw] - } + await Promise.all( + Object.entries(token.metadata).map(async ([keyHex, entry]): Promise => { + const key = Buffer.from(keyHex.substring(2), 'hex').toString('utf8') + const [valueKey, valueRaw] = Object.entries(entry)[0] + if (valueKey === 'None' || valueKey === 'tokenId') { + return [key, valueRaw] + } - if (valueKey === 'file') { - return [key, hexToBs58(valueRaw)] - } + if (valueKey === 'file') { + const base58 = hexToBs58(valueRaw) + const [attachment] = await this.db.get('attachment', { ipfs_hash: base58 }) + return [key, attachment?.id || base58] + } - const valueHex = valueRaw || '0x' - const value = Buffer.from(valueHex.substring(2), 'hex').toString('utf8') - return [key, value] - }) + const valueHex = valueRaw || '0x' + const value = Buffer.from(valueHex.substring(2), 'hex').toString('utf8') + return [key, value] + }) + ) ) const roles = new Map( Object.entries(token.roles).map(([role, account]) => [Buffer.from(trim0x(role), 'hex').toString('utf8'), account]) diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index c127857..99b6580 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -1,162 +1,24 @@ -import knex, { Knex } from 'knex' +import knex from 'knex' import { pgConfig } from './knexfile' import { z } from 'zod' import { singleton } from 'tsyringe' - -const tablesList = ['attachment', 'certificate', 'transaction', 'processed_blocks'] as const -type TABLES_TUPLE = typeof tablesList -type TABLE = TABLES_TUPLE[number] - -type IDB = { - [key in TABLE]: () => Knex.QueryBuilder -} - -const insertAttachmentRowZ = z.object({ - filename: z - .union([z.string(), z.null()]) - .optional() - .transform((filename) => filename || null), - size: z - .union([z.string(), z.null()]) - .optional() - .transform((size) => { - if (!size) return null - const asInt = parseInt(size) - if (!Number.isSafeInteger(asInt) || asInt < 0) { - throw new Error('Size must be an integer > 0') - } - return asInt - }), - ipfs_hash: z.string(), -}) - -const attachmentRowZ = insertAttachmentRowZ.extend({ - id: z.string(), - created_at: z.date(), -}) - -export type InsertAttachmentRow = z.infer -export type AttachmentRow = z.infer - -const insertTransactionRowZ = z.object({ - api_type: z.union([z.literal('certificate'), z.literal('example_a'), z.literal('example_b')]), - transaction_type: z.union([z.literal('initiate_cert'), z.literal('issue_cert')]), - local_id: z.string(), - hash: z.string(), - state: z - .union([z.literal('submitted'), z.literal('inBlock'), z.literal('finalised'), z.literal('failed')]) - .optional(), -}) - -const transactionRowZ = insertTransactionRowZ.extend({ - id: z.string(), - state: z.union([z.literal('submitted'), z.literal('inBlock'), z.literal('finalised'), z.literal('failed')]), - created_at: z.date(), - updated_at: z.date(), -}) - -export type InsertTransactionRow = z.infer -export type TransactionRow = z.infer - -const insertCertificateRowZ = z.object({ - hydrogen_owner: z.string(), - energy_owner: z.string(), - regulator: z.string(), - hydrogen_quantity_mwh: z.number(), - original_token_id: z.union([z.number(), z.null()]), - latest_token_id: z.union([z.number(), z.null()]), - commitment: z.string(), - commitment_salt: z.union([z.string(), z.null()]), - production_start_time: z.union([z.date(), z.null()]), - production_end_time: z.union([z.date(), z.null()]), - energy_consumed_mwh: z.union([z.number(), z.null()]), -}) - -const certificateRowZ = insertCertificateRowZ.extend({ - id: z.string(), - state: z.union([z.literal('pending'), z.literal('initiated'), z.literal('issued'), z.literal('revoked')]), - created_at: z.date(), - updated_at: z.date(), - embodied_co2: z.union([z.number(), z.null()]), -}) - -export type InsertCertificateRow = z.infer -export type CertificateRow = z.infer - -const insertProcessedBlockRowZ = z.object({ - hash: z.string().regex(/^[0-9a-z]{64}$/), - parent: z.string().regex(/^[0-9a-z]{64}$/), - height: z.string().transform((height) => { - const asInt = parseInt(height) - if (!Number.isSafeInteger(asInt) || asInt < 0) { - throw new Error('Height cannot be less than zero') - } - return asInt - }), -}) - -const processedBlockRowZ = insertProcessedBlockRowZ.extend({ - created_at: z.date(), -}) - -export type InsertProcessedBlockRow = z.infer -export type ProcessedBlockRow = z.infer - -const TestModelsValidation = { - attachment: { - insert: insertAttachmentRowZ, - get: attachmentRowZ, - }, - certificate: { - insert: insertCertificateRowZ, - get: certificateRowZ, - }, - transaction: { - insert: insertTransactionRowZ, - get: transactionRowZ, - }, - processed_blocks: { - insert: insertProcessedBlockRowZ, - get: processedBlockRowZ, - }, -} -export type Models = { - [key in TABLE]: { - get: z.infer<(typeof TestModelsValidation)[key]['get']> - insert: z.infer<(typeof TestModelsValidation)[key]['insert']> - } -} - -type WhereComparison = { - [key in keyof Models[M]['get']]: [ - Extract, - '=' | '>' | '>=' | '<' | '<=' | '<>', - Extract, - ] -} -type WhereMatch = { - [key in keyof Models[M]['get']]?: Models[M]['get'][key] -} - -export type Where = WhereMatch | (WhereMatch | WhereComparison[keyof Models[M]['get']])[] - -export type Order = [keyof Models[M]['get'], 'asc' | 'desc'][] -export type Update = Partial +import Zod, { tablesList, IDatabase, TABLE, Models, Where, Update, Order } from './types' const clientSingleton = knex(pgConfig) + @singleton() export default class Database { - private db: IDB + private db: IDatabase constructor(private client = clientSingleton) { this.client = client - const models: IDB = tablesList.reduce((acc, name) => { + const models: IDatabase = tablesList.reduce((acc, name) => { return { [name]: () => clientSingleton(name), ...acc, } - }, {}) as IDB + }, {}) as IDatabase this.db = models } @@ -165,7 +27,7 @@ export default class Database { model: M, record: Models[typeof model]['insert'] ): Promise => { - return z.array(TestModelsValidation[model].get).parse(await this.db[model]().insert(record).returning('*')) + return z.array(Zod[model].get).parse(await this.db[model]().insert(record).returning('*')) } delete = async (model: M, where: Where): Promise => { @@ -188,7 +50,7 @@ export default class Database { } query = where.reduce((acc, w) => (Array.isArray(w) ? acc.where(w[0], w[1], w[2]) : acc.where(w)), query) - return z.array(TestModelsValidation[model].get).parse(await query.returning('*')) + return z.array(Zod[model].get).parse(await query.returning('*')) } get = async ( @@ -209,7 +71,7 @@ export default class Database { } if (limit !== undefined) query = query.limit(limit) const result = await query - return z.array(TestModelsValidation[model].get).parse(result) + return z.array(Zod[model].get).parse(result) } withTransaction = (update: (db: Database) => Promise) => { diff --git a/src/lib/db/migrations/20230310111029_initial.ts b/src/lib/db/migrations/20230310111029_initial.ts index e3b7d55..1737e69 100644 --- a/src/lib/db/migrations/20230310111029_initial.ts +++ b/src/lib/db/migrations/20230310111029_initial.ts @@ -14,6 +14,7 @@ export async function up(knex: Knex): Promise { def.bigInteger('size').nullable().defaultTo(null) def.binary('binary_blob').nullable().defaultTo(null) def.datetime('created_at').notNullable().defaultTo(knex.fn.now()) + def.datetime('updated_at').notNullable().defaultTo(knex.fn.now()) def.primary(['id']) }) @@ -40,7 +41,9 @@ export async function up(knex: Knex): Promise { def.integer('original_token_id').defaultTo(null) def.datetime('created_at').notNullable().defaultTo(now()) def.datetime('updated_at').notNullable().defaultTo(now()) + def.uuid('revocation_reason').nullable().defaultTo(null) def.primary(['id']) + def.foreign('revocation_reason').references('id').inTable('attachment').onDelete('CASCADE').onUpdate('CASCADE') }) await knex.schema.createTable('processed_blocks', (def) => { @@ -72,15 +75,18 @@ export async function up(knex: Knex): Promise { def.specificType('hash', 'CHAR(66)').notNullable() def.enum('api_type', ['certificate'], { useNative: true, enumName: 'api_type' }).notNullable() def - .enum('transaction_type', ['initiate_cert', 'issue_cert'], { useNative: true, enumName: 'transaction_type' }) + .enum('transaction_type', ['initiate_cert', 'issue_cert', 'revoke_cert'], { + useNative: true, + enumName: 'transaction_type', + }) .notNullable() def.unique(['id', 'local_id'], { indexName: 'transaction-id-local-id' }) }) } export async function down(knex: Knex): Promise { - await knex.schema.dropTable('attachment') await knex.schema.dropTable('certificate') + await knex.schema.dropTable('attachment') await knex.schema.dropTable('transaction') await knex.schema.dropTable('processed_blocks') await knex.raw('DROP TYPE certificate_state') diff --git a/src/lib/db/types.ts b/src/lib/db/types.ts new file mode 100644 index 0000000..a14dab2 --- /dev/null +++ b/src/lib/db/types.ts @@ -0,0 +1,139 @@ +import { Knex } from 'knex' + +import { z } from 'zod' + +export const tablesList = ['attachment', 'certificate', 'transaction', 'processed_blocks'] as const + +const insertAttachment = z.object({ + filename: z + .union([z.string(), z.null()]) + .optional() + .transform((filename) => filename || null), + size: z + .union([z.string(), z.null()]) + .optional() + .transform((size) => { + if (!size) return null + const asInt = parseInt(size) + if (!Number.isSafeInteger(asInt) || asInt < 0) { + throw new Error('Size must be an integer > 0') + } + return asInt + }), + ipfs_hash: z.string(), +}) + +const insertTransaction = z.object({ + api_type: z.union([z.literal('certificate'), z.literal('example_a'), z.literal('example_b')]), + transaction_type: z.union([z.literal('initiate_cert'), z.literal('issue_cert'), z.literal('revoke_cert')]), + local_id: z.string(), + hash: z.string(), + state: z + .union([z.literal('submitted'), z.literal('inBlock'), z.literal('finalised'), z.literal('failed')]) + .optional(), +}) + +const insertBlock = z.object({ + hash: z.string().regex(/^[0-9a-z]{64}$/), + parent: z.string().regex(/^[0-9a-z]{64}$/), + height: z.string().transform((height) => { + const asInt = parseInt(height) + if (!Number.isSafeInteger(asInt) || asInt < 0) { + throw new Error('Height cannot be less than zero') + } + return asInt + }), +}) + +const insertCertificate = z.object({ + hydrogen_owner: z.string(), + energy_owner: z.string(), + regulator: z.string(), + hydrogen_quantity_mwh: z.number(), + original_token_id: z.union([z.number(), z.null()]), + latest_token_id: z.union([z.number(), z.null()]), + commitment: z.string(), + commitment_salt: z.union([z.string(), z.null()]), + production_start_time: z.union([z.date(), z.null()]), + production_end_time: z.union([z.date(), z.null()]), + energy_consumed_mwh: z.union([z.number(), z.null()]), +}) + +const Zod = { + attachment: { + insert: insertAttachment, + get: insertAttachment.extend({ + id: z.string(), + created_at: z.date(), + }), + }, + processed_blocks: { + insert: insertBlock, + get: insertBlock.extend({ + created_at: z.date(), + }), + }, + transaction: { + insert: insertTransaction, + get: insertTransaction.extend({ + id: z.string(), + state: z.union([z.literal('submitted'), z.literal('inBlock'), z.literal('finalised'), z.literal('failed')]), + created_at: z.date(), + updated_at: z.date(), + }), + }, + certificate: { + insert: insertCertificate, + get: insertCertificate.extend({ + id: z.string(), + state: z.union([z.literal('pending'), z.literal('initiated'), z.literal('issued'), z.literal('revoked')]), + created_at: z.date(), + updated_at: z.date(), + embodied_co2: z.union([z.number(), z.null()]), + revocation_reason: z.union([z.string(), z.null()]), + }), + }, +} + +const { transaction, attachment, processed_blocks, certificate } = Zod + +export type InsertTransaction = z.infer +export type TransactionRow = z.infer + +export type InsertCertificateRow = z.infer +export type CertificateRow = z.infer + +export type InsertProcessedBlockRow = z.infer +export type ProcessedBlockRow = z.infer + +export type InsertAttachmentRow = z.infer +export type AttachmentRow = z.infer + +export type TABLES_TUPLE = typeof tablesList +export type TABLE = TABLES_TUPLE[number] +export type Models = { + [key in TABLE]: { + get: z.infer<(typeof Zod)[key]['get']> + insert: z.infer<(typeof Zod)[key]['insert']> + } +} + +type WhereComparison = { + [key in keyof Models[M]['get']]: [ + Extract, + '=' | '>' | '>=' | '<' | '<=' | '<>', + Extract, + ] +} +type WhereMatch = { + [key in keyof Models[M]['get']]?: Models[M]['get'][key] +} + +export type Where = WhereMatch | (WhereMatch | WhereComparison[keyof Models[M]['get']])[] +export type Order = [keyof Models[M]['get'], 'asc' | 'desc'][] +export type Update = Partial +export type IDatabase = { + [key in TABLE]: () => Knex.QueryBuilder +} + +export default Zod diff --git a/src/lib/indexer/__tests__/eventProcessor.test.ts b/src/lib/indexer/__tests__/eventProcessor.test.ts index 4d17179..2f7bdc5 100644 --- a/src/lib/indexer/__tests__/eventProcessor.test.ts +++ b/src/lib/indexer/__tests__/eventProcessor.test.ts @@ -2,7 +2,7 @@ import { describe, it } from 'mocha' import eventProcessors from '../eventProcessor' import { expect } from 'chai' -import { TransactionRow } from '../../db' +import { TransactionRow } from '../../db/types' describe('eventProcessor', function () { describe('initiate_cert', function () { @@ -175,4 +175,92 @@ describe('eventProcessor', function () { expect(error).to.empty.instanceOf(Error) }) }) + + describe('revoke_cert', function () { + it('should error with version != 1', function () { + let error: Error | null = null + try { + eventProcessors['revoke_cert']({ version: 0, sender: 'alice', inputs: [], outputs: [] }) + } catch (err) { + error = err instanceof Error ? err : null + } + expect(error).instanceOf(Error) + }) + + it('should update without attachment if transaction is present', function () { + const result = eventProcessors['revoke_cert']({ + version: 1, + sender: 'alice', + transaction: { local_id: '42' } as TransactionRow, + inputs: [{ id: 1, local_id: 'caa699b7-b0b6-4e0e-ac15-698b7b1f6541' }], + outputs: [ + { + id: 2, + metadata: new Map([['reason', '90234681-8808-4eaa-ac65-c643c22e3524']]), + roles: new Map(), + }, + ], + }) + + expect(result).to.deep.equal({ + certificates: new Map([ + [ + 'caa699b7-b0b6-4e0e-ac15-698b7b1f6541', + { + id: 'caa699b7-b0b6-4e0e-ac15-698b7b1f6541', + type: 'update', + latest_token_id: 2, + state: 'revoked', + revocation_reason: '90234681-8808-4eaa-ac65-c643c22e3524', + }, + ], + ]), + }) + }) + + it('should update with attachment if transaction is not present', function () { + const result = eventProcessors['revoke_cert']({ + version: 1, + sender: 'alice', + inputs: [{ id: 1, local_id: 'caa699b7-b0b6-4e0e-ac15-698b7b1f6541' }], + outputs: [ + { + id: 2, + metadata: new Map([['reason', 'QmXVStDC6kTpVHY1shgBQmyA4SuSrYnNRnHSak5iB6Eehn']]), + roles: new Map(), + }, + ], + }) + + const attachmentId = result.attachments?.keys().next().value + expect(attachmentId).to.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) + + expect(result).to.deep.equal({ + attachments: new Map([ + [ + attachmentId, + { + type: 'insert', + id: attachmentId, + filename: null, + size: null, + ipfs_hash: 'QmXVStDC6kTpVHY1shgBQmyA4SuSrYnNRnHSak5iB6Eehn', + }, + ], + ]), + certificates: new Map([ + [ + 'caa699b7-b0b6-4e0e-ac15-698b7b1f6541', + { + id: 'caa699b7-b0b6-4e0e-ac15-698b7b1f6541', + type: 'update', + latest_token_id: 2, + state: 'revoked', + revocation_reason: attachmentId, + }, + ], + ]), + }) + }) + }) }) diff --git a/src/lib/indexer/__tests__/fixtures/eventProcessor.ts b/src/lib/indexer/__tests__/fixtures/eventProcessor.ts index ae8a1a5..5b83073 100644 --- a/src/lib/indexer/__tests__/fixtures/eventProcessor.ts +++ b/src/lib/indexer/__tests__/fixtures/eventProcessor.ts @@ -5,4 +5,5 @@ import sinon from 'sinon' export const withMockEventProcessors: (result?: ChangeSet) => EventProcessors = (result: ChangeSet = {}) => ({ initiate_cert: sinon.stub().returns(result), issue_cert: sinon.stub().returns(result), + revoke_cert: sinon.stub().returns(result), }) diff --git a/src/lib/indexer/changeSet.ts b/src/lib/indexer/changeSet.ts index b52c57d..d63ab3d 100644 --- a/src/lib/indexer/changeSet.ts +++ b/src/lib/indexer/changeSet.ts @@ -28,6 +28,7 @@ export type CertificateRecord = id: UUID state: 'pending' | 'initiated' | 'issued' | 'revoked' embodied_co2?: number + revocation_reason?: UUID original_token_id?: number latest_token_id: number } @@ -38,8 +39,8 @@ export type AttachmentRecord = type: 'insert' id: string filename: string | null - ipfs_hash: string size: number | null + ipfs_hash: string } | never diff --git a/src/lib/indexer/eventProcessor.ts b/src/lib/indexer/eventProcessor.ts index 82b6165..2f150ae 100644 --- a/src/lib/indexer/eventProcessor.ts +++ b/src/lib/indexer/eventProcessor.ts @@ -1,10 +1,10 @@ import { v4 as UUIDv4 } from 'uuid' import { UUID } from '../../models/strings' -import { TransactionRow } from '../db' -import { ChangeSet, CertificateRecord } from './changeSet' +import { TransactionRow } from '../../lib/db/types' +import { ChangeSet, CertificateRecord, AttachmentRecord } from './changeSet' -const processNames = ['initiate_cert', 'issue_cert'] as const +const processNames = ['initiate_cert', 'issue_cert', 'revoke_cert'] as const type PROCESSES_TUPLE = typeof processNames type PROCESSES = PROCESSES_TUPLE[number] @@ -38,13 +38,13 @@ const parseIntegerOrThrow = (value: string): number => { return result } -/* INFO uncomment if we decided to use attachments const attachmentPayload = (map: Map, key: string): AttachmentRecord => ({ type: 'insert', id: UUIDv4(), ipfs_hash: getOrError(map, key), + filename: null, + size: null, }) -*/ const DefaultEventProcessors: EventProcessors = { initiate_cert: ({ version, transaction, outputs }) => { @@ -112,6 +112,41 @@ const DefaultEventProcessors: EventProcessors = { certificates: new Map([[local_id, update]]), } }, + revoke_cert: ({ version, transaction, inputs, outputs }) => { + if (version !== 1) throw new Error(`Incompatible version ${version} for issue_cert process`) + + const { local_id } = inputs[0] + const { id: latest_token_id, ...cert } = outputs[0] + + const update: CertificateRecord = { + type: 'update', + id: local_id, + latest_token_id, + state: 'revoked', + } + + if (transaction) { + return { + certificates: new Map([ + [ + local_id, + { + ...update, + revocation_reason: getOrError(cert.metadata, 'reason'), + }, + ], + ]), + } + } + + const attachment: AttachmentRecord = attachmentPayload(cert.metadata, 'reason') + update.revocation_reason = attachment.id + + return { + certificates: new Map([[local_id, update]]), + attachments: new Map([[attachment.id, attachment]]), + } + }, } export default DefaultEventProcessors diff --git a/src/lib/payload.ts b/src/lib/payload.ts index 2d558fc..77cf067 100644 --- a/src/lib/payload.ts +++ b/src/lib/payload.ts @@ -1,4 +1,5 @@ -import { CertificateRow } from './db' +import { AttachmentRow, CertificateRow } from './db/types' +import { bs58ToHex } from '../utils/controller-helpers' export interface Payload { process: { id: string; version: number } @@ -57,3 +58,23 @@ export const processIssueCert = (certificate: CertificateRow): Payload => ({ }, ], }) + +export const processRevokeCert = (certificate: CertificateRow, attachment: AttachmentRow): Payload => ({ + process: { id: 'revoke_cert', version: 1 }, + inputs: [certificate.latest_token_id || Number.NaN], + outputs: [ + { + roles: { + regulator: certificate.regulator, + hydrogen_owner: certificate.hydrogen_owner, + energy_owner: certificate.energy_owner, + }, + metadata: { + '@version': { type: 'LITERAL', value: '1' }, + '@type': { type: 'LITERAL', value: 'RevokedCert' }, + '@original_id': { type: 'TOKEN_ID', value: certificate.original_token_id || Number.NaN }, + reason: { type: 'FILE', value: bs58ToHex(attachment.ipfs_hash) }, + }, + }, + ], +}) diff --git a/src/lib/service-watcher/apiStatus.ts b/src/lib/service-watcher/apiStatus.ts index aea2f1c..6ca8c6e 100644 --- a/src/lib/service-watcher/apiStatus.ts +++ b/src/lib/service-watcher/apiStatus.ts @@ -1,20 +1,12 @@ -import { container } from 'tsyringe' - import { startStatusHandler } from './statusPoll' import { Env } from '../../env' import ChainNode from '../chainNode' -const env = container.resolve(Env) -const node = container.resolve(ChainNode) - -const WATCHER_POLL_PERIOD_MS = env.get('WATCHER_POLL_PERIOD_MS') -const WATCHER_TIMEOUT_MS = env.get('WATCHER_TIMEOUT_MS') - -const startApiStatus = () => +const startApiStatus = (env: Env, node: ChainNode) => startStatusHandler({ getStatus: node.getStatus, - pollingPeriodMs: WATCHER_POLL_PERIOD_MS, - serviceTimeoutMs: WATCHER_TIMEOUT_MS, + pollingPeriodMs: env.get('WATCHER_POLL_PERIOD_MS'), + serviceTimeoutMs: env.get('WATCHER_TIMEOUT_MS'), }) export default startApiStatus diff --git a/src/lib/service-watcher/identityStatus.ts b/src/lib/service-watcher/identityStatus.ts index aadd463..bded3b9 100644 --- a/src/lib/service-watcher/identityStatus.ts +++ b/src/lib/service-watcher/identityStatus.ts @@ -1,21 +1,12 @@ -import { container } from 'tsyringe' - import { startStatusHandler } from './statusPoll' import { Env } from '../../env' import Identity from '../services/identity' -const env = container.resolve(Env) - -const WATCHER_POLL_PERIOD_MS = env.get('WATCHER_POLL_PERIOD_MS') -const WATCHER_TIMEOUT_MS = env.get('WATCHER_TIMEOUT_MS') - -const identity = container.resolve(Identity) - -const startIdentityStatus = () => +const startIdentityStatus = (env: Env, identity: Identity) => startStatusHandler({ getStatus: identity.getStatus.bind(identity), - pollingPeriodMs: WATCHER_POLL_PERIOD_MS, - serviceTimeoutMs: WATCHER_TIMEOUT_MS, + pollingPeriodMs: env.get('WATCHER_POLL_PERIOD_MS'), + serviceTimeoutMs: env.get('WATCHER_TIMEOUT_MS'), }) export default startIdentityStatus diff --git a/src/lib/service-watcher/index.ts b/src/lib/service-watcher/index.ts index 473d813..33cea0f 100644 --- a/src/lib/service-watcher/index.ts +++ b/src/lib/service-watcher/index.ts @@ -1,10 +1,16 @@ -import { singleton } from 'tsyringe' +import { injectable, singleton } from 'tsyringe' import startApiStatus from './apiStatus' import startIpfsStatus from './ipfsStatus' import startIdentityStatus from './identityStatus' import { buildCombinedHandler, SERVICE_STATE, Status } from './statusPoll' +import { Env } from '../../env' +import ChainNode from '../chainNode' +import Identity from '../services/identity' +import Ipfs from '../ipfs' + @singleton() +@injectable() export class ServiceWatcher { handlersP: Promise<{ readonly status: SERVICE_STATE @@ -14,7 +20,12 @@ export class ServiceWatcher { close: () => void }> - constructor() { + constructor( + private env: Env, + private node: ChainNode, + private identity: Identity, + private ipfs: Ipfs + ) { this.handlersP = this.build() } @@ -27,9 +38,9 @@ export class ServiceWatcher { }> => { const handlers = new Map() const [apiStatus, ipfsStatus, identityStatus] = await Promise.all([ - startApiStatus(), - startIpfsStatus(), - startIdentityStatus(), + startApiStatus(this.env, this.node), + startIpfsStatus(this.env, this.ipfs), + startIdentityStatus(this.env, this.identity), ]) handlers.set('api', apiStatus) handlers.set('ipfs', ipfsStatus) diff --git a/src/lib/service-watcher/ipfsStatus.ts b/src/lib/service-watcher/ipfsStatus.ts index da34fe8..8aed3f8 100644 --- a/src/lib/service-watcher/ipfsStatus.ts +++ b/src/lib/service-watcher/ipfsStatus.ts @@ -1,20 +1,12 @@ -import { container } from 'tsyringe' - import { startStatusHandler } from './statusPoll' import Ipfs from '../ipfs' import { Env } from '../../env' -const env = container.resolve(Env) -const ipfs = container.resolve(Ipfs) - -const WATCHER_POLL_PERIOD_MS = env.get('WATCHER_POLL_PERIOD_MS') -const WATCHER_TIMEOUT_MS = env.get('WATCHER_TIMEOUT_MS') - -const startIpfsStatus = () => +const startIpfsStatus = (env: Env, ipfs: Ipfs) => startStatusHandler({ getStatus: ipfs.getStatus, - pollingPeriodMs: WATCHER_POLL_PERIOD_MS, - serviceTimeoutMs: WATCHER_TIMEOUT_MS, + pollingPeriodMs: env.get('WATCHER_POLL_PERIOD_MS'), + serviceTimeoutMs: env.get('WATCHER_TIMEOUT_MS'), }) export default startIpfsStatus diff --git a/src/models/certificate.ts b/src/models/certificate.ts index 3e90ad2..ceddcb0 100644 --- a/src/models/certificate.ts +++ b/src/models/certificate.ts @@ -17,12 +17,13 @@ export type GetCertificateResponse = { production_start_time?: Date | null production_end_time?: Date | null energy_consumed_mwh?: number | null + revocation_reason?: UUID | null } export type ListCertificatesResponse = GetCertificateResponse[] export type GetTransactionResponse = { id: UUID api_type: 'certificate' | 'example_a' | 'example_b' - transaction_type: 'issue_cert' | 'initiate_cert' + transaction_type: 'issue_cert' | 'initiate_cert' | 'revoke_cert' state: 'submitted' | 'inBlock' | 'finalised' | 'failed' local_id: string hash: string @@ -33,12 +34,21 @@ export type ListTransactionResponse = GetTransactionResponse[] /** * Certificate Request Body example * @example { - * "hydrogen_quantity_mwh": 1, - * "energy_owner": "emma", - * "production_start_time": "2023-01-01T00:00:00.000Z", - * "production_end_time": "2023-01-01T12:00:00.000Z", - * "energy_consumed_mwh": 1, + * "energy_consumed_mwh": 10, + * "production_end_time": "2023-12-11T20:34:21.749Z", + * "production_start_time": "2023-12-10T18:34:21.749Z", + * "regulator": "5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmLJbpXg2mp1hXcS59Y", + * "energy_owner": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + * "hydrogen_quantity_mwh": 5 * } + * { + "energy_consumed_mwh": 10, + "production_end_time": "2023-12-10T20:46:35.892Z", + "production_start_time": "2023-12-10T18:46:35.892Z", + "regulator": "5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmLJbpXg2mp1hXcS59Y", + "energy_owner": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + "hydrogen_quantity_mwh": 5 +} */ export type Payload = { hydrogen_quantity_mwh: number @@ -56,6 +66,14 @@ export type UpdatePayload = { commitment_salt: string } +export type RevokePayload = { + reason: UUID +} +/** + * 15193219-3340-4083-9ee9-a2cd37c14e7e + * af87b3d4-94f5-4b53-8d71-e8ec95a96a17 + */ + export type IssuancePayload = { embodied_co2?: number } diff --git a/src/models/transaction.ts b/src/models/transaction.ts index 024845b..f928d5b 100644 --- a/src/models/transaction.ts +++ b/src/models/transaction.ts @@ -25,4 +25,4 @@ export type ListTransactionResponse = GetTransactionResponse[] /** * Transaction type - matches the endpoint that initiates the transaction */ -export type TransactionType = 'issue_cert' | 'initiate_cert' +export type TransactionType = 'issue_cert' | 'initiate_cert' | 'revoke_cert' diff --git a/test/helpers/chainTest.ts b/test/helpers/chainTest.ts index df5e38e..3f3a64a 100644 --- a/test/helpers/chainTest.ts +++ b/test/helpers/chainTest.ts @@ -5,12 +5,13 @@ import { container } from 'tsyringe' import createHttpServer from '../../src/server' import Indexer from '../../src/lib/indexer' -import Database, { CertificateRow } from '../../src/lib/db' +import { CertificateRow } from '../../src/lib/db/types' +import Database from '../../src/lib/db' import ChainNode from '../../src/lib/chainNode' import { logger } from '../../src/lib/logger' import { put } from './routeHelper' import { mockEnv, notSelfAddress, regulatorAddress, selfAddress } from './mock' -import { processInitiateCert } from '../../src/lib/payload' +import { processInitiateCert, processIssueCert } from '../../src/lib/payload' const db = new Database() @@ -45,11 +46,12 @@ export const withAppAndIndexer = (context: { app: Express; indexer: Indexer }) = }) } -export const withInitialisedCertFromNotSelf = async (context: { app: Express; cert: CertificateRow }) => { +export const withInitialisedCertFromNotSelf = async (context: { app: Express; db: Database; cert: CertificateRow }) => { const node = new ChainNode( mockEnv({ USER_URI: '//Bob', - }) + }), + db ) const extrinsic = await node.prepareRunProcess( @@ -86,3 +88,59 @@ export const withInitialisedCertFromNotSelf = async (context: { app: Express; ce const [cert] = await db.get('certificate', { id }) context.cert = cert } + +export const withIssuedCertAsRegulator = async (context: { app: Express; db: Database; cert: CertificateRow }) => { + const heidiNode = new ChainNode( + mockEnv({ + USER_URI: '//Bob', + }), + db + ) + + const initExtrinsic = await heidiNode.prepareRunProcess( + processInitiateCert({ + hydrogen_owner: notSelfAddress, + energy_owner: regulatorAddress, + regulator: selfAddress, + hydrogen_quantity_mwh: 1, + commitment: 'ffb693f99a5aca369539a90b6978d0eb', + } as CertificateRow) + ) + + const initTokenId = await new Promise((resolve, reject) => { + heidiNode.submitRunProcess(initExtrinsic, (state, outputs) => { + if (state === 'finalised') { + setTimeout(() => resolve(outputs ? outputs[0] : Number.NaN), 100) + } else if (state === 'failed') reject() + }) + }) + + const emmaNode = new ChainNode( + mockEnv({ + USER_URI: '//Charlie', + }), + db + ) + + const issueExtrinsic = await emmaNode.prepareRunProcess( + processIssueCert({ + latest_token_id: initTokenId, + hydrogen_owner: notSelfAddress, + energy_owner: regulatorAddress, + regulator: selfAddress, + original_token_id: initTokenId, + embodied_co2: 42, + } as CertificateRow) + ) + + const issuedTokenId = await new Promise((resolve, reject) => { + emmaNode.submitRunProcess(issueExtrinsic, (state, outputs) => { + if (state === 'finalised') { + setTimeout(() => resolve(outputs ? outputs[0] : Number.NaN), 100) + } else if (state === 'failed') reject() + }) + }) + + const [cert] = await db.get('certificate', { latest_token_id: issuedTokenId }) + context.cert = cert +} diff --git a/test/helpers/poll.ts b/test/helpers/poll.ts index f470c2b..b6d5e91 100644 --- a/test/helpers/poll.ts +++ b/test/helpers/poll.ts @@ -1,4 +1,5 @@ -import Database, { TransactionRow } from '../../src/lib/db' +import Database from '../../src/lib/db' +import { TransactionRow } from '../../src/lib/db/types' import { TransactionState } from '../../src/models/transaction' import { UUID } from '../../src/models/strings' diff --git a/test/integration/offchain/certificate.test.ts b/test/integration/offchain/certificate.test.ts index 434fb9e..ca2dff7 100644 --- a/test/integration/offchain/certificate.test.ts +++ b/test/integration/offchain/certificate.test.ts @@ -6,7 +6,7 @@ import createHttpServer from '../../../src/server' import { put } from '../../helpers/routeHelper' import { cleanup, updateSeed } from '../../seeds/certificate' -import { CertificateRow } from '../../../src/lib/db' +import { CertificateRow } from '../../../src/lib/db/types' import { notSelfAlias, regulatorAlias, selfAlias, withExternalServicesMock } from '../../helpers/mock' describe('certificate', () => { diff --git a/test/integration/onchain/certificate.test.ts b/test/integration/onchain/certificate.test.ts index b08f686..ef956cf 100644 --- a/test/integration/onchain/certificate.test.ts +++ b/test/integration/onchain/certificate.test.ts @@ -17,17 +17,20 @@ import { regulatorAlias, regulatorAddress, } from '../../helpers/mock' -import Database, { CertificateRow } from '../../../src/lib/db' +import Database from '../../../src/lib/db' +import { CertificateRow } from '../../../src/lib/db/types' import ChainNode from '../../../src/lib/chainNode' import { pollTransactionState } from '../../helpers/poll' -import { withAppAndIndexer, withInitialisedCertFromNotSelf } from '../../helpers/chainTest' +import { withAppAndIndexer, withInitialisedCertFromNotSelf, withIssuedCertAsRegulator } from '../../helpers/chainTest' describe('on-chain', function () { this.timeout(60000) + let attachmentId: string const db = new Database() const node = container.resolve(ChainNode) - const context: { app: Express; indexer: Indexer; cert: CertificateRow } = {} as { + const context: { app: Express; db: Database; indexer: Indexer; cert: CertificateRow } = { db } as { app: Express + db: Database indexer: Indexer cert: CertificateRow } @@ -37,7 +40,8 @@ describe('on-chain', function () { withExternalServicesMock() beforeEach(async function () { - await seed() + const attachment = await seed() + attachmentId = attachment.id }) afterEach(async function () { @@ -111,6 +115,7 @@ describe('on-chain', function () { await pollTransactionState(db, transactionId, 'finalised') const [cert] = await db.get('certificate', { id: context.cert.id }) + expect(cert).to.deep.contain({ id: context.cert.id, state: 'issued', @@ -144,5 +149,33 @@ describe('on-chain', function () { }) }) }) + + describe('revocation', () => { + it('should revoke an issued certificate with a reason as an attachment', async function () { + await withIssuedCertAsRegulator(context) + const expectedTokenId = (context.cert.latest_token_id as number) + 1 + + const response = await post(context.app, `/v1/certificate/${context.cert.id}/revocation`, { + reason: attachmentId, + }) + expect(response.status).to.equal(201) + + const { id: transactionId, state } = response.body + expect(transactionId).to.match( + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89ABab][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/ + ) + expect(state).to.equal('submitted') + + await pollTransactionState(db, transactionId, 'finalised') + + const [cert] = await db.get('certificate', { id: context.cert.id }) + expect(cert).to.deep.contain({ + id: context.cert.id, + state: 'revoked', + revocation_reason: attachmentId, + latest_token_id: expectedTokenId, + }) + }) + }) }) }) diff --git a/test/seeds/certificate.ts b/test/seeds/certificate.ts index 039d39a..ad16ab1 100644 --- a/test/seeds/certificate.ts +++ b/test/seeds/certificate.ts @@ -11,6 +11,12 @@ export const cleanup = async () => { export const seed = async () => { await cleanup() + const [attachment] = await db.insert('attachment', { + filename: 'testing-revocation', + size: 0, + ipfs_hash: 'QmXVStDC6kTpVHY1shgBQmyA4SuSrYnNRnHSak5iB6Eehn', + }) + return attachment } export const updateSeed = async () => {