diff --git a/packages/core/src/agent/Dispatcher.ts b/packages/core/src/agent/Dispatcher.ts index 070b2b42ea..4d5eeb5c44 100644 --- a/packages/core/src/agent/Dispatcher.ts +++ b/packages/core/src/agent/Dispatcher.ts @@ -10,6 +10,7 @@ import { Lifecycle, scoped } from 'tsyringe' import { AgentConfig } from '../agent/AgentConfig' import { AriesFrameworkError } from '../error/AriesFrameworkError' +import { ProblemReportMessage } from './../modules/problem-reports/messages/ProblemReportMessage' import { EventEmitter } from './EventEmitter' import { AgentEventTypes } from './Events' import { MessageSender } from './MessageSender' @@ -45,15 +46,27 @@ class Dispatcher { try { outboundMessage = await handler.handle(messageContext) } catch (error) { - this.logger.error(`Error handling message with type ${message.type}`, { - message: message.toJSON(), - error, - senderVerkey: messageContext.senderVerkey, - recipientVerkey: messageContext.recipientVerkey, - connectionId: messageContext.connection?.id, - }) - - throw error + const problemReportMessage = error.problemReport + + if (problemReportMessage instanceof ProblemReportMessage && messageContext.connection) { + problemReportMessage.setThread({ + threadId: messageContext.message.threadId, + }) + outboundMessage = { + payload: problemReportMessage, + connection: messageContext.connection, + } + } else { + this.logger.error(`Error handling message with type ${message.type}`, { + message: message.toJSON(), + error, + senderVerkey: messageContext.senderVerkey, + recipientVerkey: messageContext.recipientVerkey, + connectionId: messageContext.connection?.id, + }) + + throw error + } } if (outboundMessage && isOutboundServiceMessage(outboundMessage)) { diff --git a/packages/core/src/agent/MessageReceiver.ts b/packages/core/src/agent/MessageReceiver.ts index 30ec17290e..b5ff07cc0b 100644 --- a/packages/core/src/agent/MessageReceiver.ts +++ b/packages/core/src/agent/MessageReceiver.ts @@ -9,21 +9,29 @@ import { Lifecycle, scoped } from 'tsyringe' import { AriesFrameworkError } from '../error' import { ConnectionService } from '../modules/connections/services/ConnectionService' +import { ProblemReportError, ProblemReportMessage } from '../modules/problem-reports' import { JsonTransformer } from '../utils/JsonTransformer' import { MessageValidator } from '../utils/MessageValidator' import { replaceLegacyDidSovPrefixOnMessage } from '../utils/messageType' +import { CommonMessageType } from './../modules/common/messages/CommonMessageType' import { AgentConfig } from './AgentConfig' import { Dispatcher } from './Dispatcher' import { EnvelopeService } from './EnvelopeService' +import { MessageSender } from './MessageSender' import { TransportService } from './TransportService' +import { createOutboundMessage } from './helpers' import { InboundMessageContext } from './models/InboundMessageContext' +export enum ProblemReportReason { + MessageParseFailure = 'message-parse-failure', +} @scoped(Lifecycle.ContainerScoped) export class MessageReceiver { private config: AgentConfig private envelopeService: EnvelopeService private transportService: TransportService + private messageSender: MessageSender private connectionService: ConnectionService private dispatcher: Dispatcher private logger: Logger @@ -33,12 +41,14 @@ export class MessageReceiver { config: AgentConfig, envelopeService: EnvelopeService, transportService: TransportService, + messageSender: MessageSender, connectionService: ConnectionService, dispatcher: Dispatcher ) { this.config = config this.envelopeService = envelopeService this.transportService = transportService + this.messageSender = messageSender this.connectionService = connectionService this.dispatcher = dispatcher this.logger = this.config.logger @@ -84,15 +94,12 @@ export class MessageReceiver { unpackedMessage.message ) - const message = await this.transformMessage(unpackedMessage) + let message: AgentMessage | null = null try { - await MessageValidator.validate(message) + message = await this.transformMessage(unpackedMessage) + await this.validateMessage(message) } catch (error) { - this.logger.error(`Error validating message ${message.type}`, { - errors: error, - message: message.toJSON(), - }) - + if (connection) await this.sendProblemReportMessage(error.message, connection, unpackedMessage) throw error } @@ -174,7 +181,9 @@ export class MessageReceiver { const MessageClass = this.dispatcher.getMessageClassForType(messageType) if (!MessageClass) { - throw new AriesFrameworkError(`No message class found for message type "${messageType}"`) + throw new ProblemReportError(`No message class found for message type "${messageType}"`, { + problemCode: ProblemReportReason.MessageParseFailure, + }) } // Cast the plain JSON object to specific instance of Message extended from AgentMessage @@ -182,4 +191,51 @@ export class MessageReceiver { return message } + + /** + * Validate an AgentMessage instance. + * @param message agent message to validate + */ + private async validateMessage(message: AgentMessage) { + try { + await MessageValidator.validate(message) + } catch (error) { + this.logger.error(`Error validating message ${message.type}`, { + errors: error, + message: message.toJSON(), + }) + throw new ProblemReportError(`Error validating message ${message.type}`, { + problemCode: ProblemReportReason.MessageParseFailure, + }) + } + } + + /** + * Send the problem report message (https://didcomm.org/notification/1.0/problem-report) to the recipient. + * @param message error message to send + * @param connection connection to send the message to + * @param unpackedMessage received unpackedMessage + */ + private async sendProblemReportMessage( + message: string, + connection: ConnectionRecord, + unpackedMessage: UnpackedMessageContext + ) { + if (unpackedMessage.message['@type'] === CommonMessageType.ProblemReport) { + throw new AriesFrameworkError(message) + } + const problemReportMessage = new ProblemReportMessage({ + description: { + en: message, + code: ProblemReportReason.MessageParseFailure, + }, + }) + problemReportMessage.setThread({ + threadId: unpackedMessage.message['@id'], + }) + const outboundMessage = createOutboundMessage(connection, problemReportMessage) + if (outboundMessage) { + await this.messageSender.sendMessage(outboundMessage) + } + } } diff --git a/packages/core/src/decorators/signature/SignatureDecoratorUtils.test.ts b/packages/core/src/decorators/signature/SignatureDecoratorUtils.test.ts index df9b57a013..8f6ee60073 100644 --- a/packages/core/src/decorators/signature/SignatureDecoratorUtils.test.ts +++ b/packages/core/src/decorators/signature/SignatureDecoratorUtils.test.ts @@ -76,7 +76,7 @@ describe('Decorators | Signature | SignatureDecoratorUtils', () => { try { await unpackAndVerifySignatureDecorator(wronglySignedData, wallet) } catch (error) { - expect(error.message).toEqual('Signature is not valid!') + expect(error.message).toEqual('Signature is not valid') } }) }) diff --git a/packages/core/src/decorators/signature/SignatureDecoratorUtils.ts b/packages/core/src/decorators/signature/SignatureDecoratorUtils.ts index 536da0b71e..54a603b10e 100644 --- a/packages/core/src/decorators/signature/SignatureDecoratorUtils.ts +++ b/packages/core/src/decorators/signature/SignatureDecoratorUtils.ts @@ -1,6 +1,6 @@ import type { Wallet } from '../../wallet/Wallet' -import { AriesFrameworkError } from '../../error' +import { ConnectionProblemReportError, ConnectionProblemReportReason } from '../../modules/connections/errors' import { BufferEncoder } from '../../utils/BufferEncoder' import { JsonEncoder } from '../../utils/JsonEncoder' import { Buffer } from '../../utils/buffer' @@ -29,7 +29,9 @@ export async function unpackAndVerifySignatureDecorator( const isValid = await wallet.verify(signerVerkey, signedData, signature) if (!isValid) { - throw new AriesFrameworkError('Signature is not valid!') + throw new ConnectionProblemReportError('Signature is not valid', { + problemCode: ConnectionProblemReportReason.RequestProcessingError, + }) } // TODO: return Connection instance instead of raw json diff --git a/packages/core/src/modules/common/messages/CommonMessageType.ts b/packages/core/src/modules/common/messages/CommonMessageType.ts index 0aba02a8dc..d0b36d2d37 100644 --- a/packages/core/src/modules/common/messages/CommonMessageType.ts +++ b/packages/core/src/modules/common/messages/CommonMessageType.ts @@ -1,3 +1,4 @@ export enum CommonMessageType { Ack = 'https://didcomm.org/notification/1.0/ack', + ProblemReport = 'https://didcomm.org/notification/1.0/problem-report', } diff --git a/packages/core/src/modules/connections/errors/ConnectionProblemReportError.ts b/packages/core/src/modules/connections/errors/ConnectionProblemReportError.ts new file mode 100644 index 0000000000..d58d1bd14f --- /dev/null +++ b/packages/core/src/modules/connections/errors/ConnectionProblemReportError.ts @@ -0,0 +1,22 @@ +import type { ConnectionProblemReportReason } from '.' +import type { ProblemReportErrorOptions } from '../../problem-reports' + +import { ProblemReportError } from '../../problem-reports' +import { ConnectionProblemReportMessage } from '../messages' + +interface ConnectionProblemReportErrorOptions extends ProblemReportErrorOptions { + problemCode: ConnectionProblemReportReason +} +export class ConnectionProblemReportError extends ProblemReportError { + public problemReport: ConnectionProblemReportMessage + + public constructor(public message: string, { problemCode }: ConnectionProblemReportErrorOptions) { + super(message, { problemCode }) + this.problemReport = new ConnectionProblemReportMessage({ + description: { + en: message, + code: problemCode, + }, + }) + } +} diff --git a/packages/core/src/modules/connections/errors/ConnectionProblemReportReason.ts b/packages/core/src/modules/connections/errors/ConnectionProblemReportReason.ts new file mode 100644 index 0000000000..06f81b83c3 --- /dev/null +++ b/packages/core/src/modules/connections/errors/ConnectionProblemReportReason.ts @@ -0,0 +1,11 @@ +/** + * Connection error code in RFC 160. + * + * @see https://github.com/hyperledger/aries-rfcs/blob/main/features/0160-connection-protocol/README.md#errors + */ +export enum ConnectionProblemReportReason { + RequestNotAccepted = 'request_not_accepted', + RequestProcessingError = 'request_processing_error', + ResponseNotAccepted = 'response_not_accepted', + ResponseProcessingError = 'response_processing_error', +} diff --git a/packages/core/src/modules/connections/errors/index.ts b/packages/core/src/modules/connections/errors/index.ts new file mode 100644 index 0000000000..09f2c7a53a --- /dev/null +++ b/packages/core/src/modules/connections/errors/index.ts @@ -0,0 +1,2 @@ +export * from './ConnectionProblemReportError' +export * from './ConnectionProblemReportReason' diff --git a/packages/core/src/modules/connections/handlers/ConnectionProblemReportHandler.ts b/packages/core/src/modules/connections/handlers/ConnectionProblemReportHandler.ts new file mode 100644 index 0000000000..b1b5896017 --- /dev/null +++ b/packages/core/src/modules/connections/handlers/ConnectionProblemReportHandler.ts @@ -0,0 +1,17 @@ +import type { Handler, HandlerInboundMessage } from '../../../agent/Handler' +import type { ConnectionService } from '../services' + +import { ConnectionProblemReportMessage } from '../messages' + +export class ConnectionProblemReportHandler implements Handler { + private connectionService: ConnectionService + public supportedMessages = [ConnectionProblemReportMessage] + + public constructor(connectionService: ConnectionService) { + this.connectionService = connectionService + } + + public async handle(messageContext: HandlerInboundMessage) { + await this.connectionService.processProblemReport(messageContext) + } +} diff --git a/packages/core/src/modules/connections/messages/ConnectionProblemReportMessage.ts b/packages/core/src/modules/connections/messages/ConnectionProblemReportMessage.ts new file mode 100644 index 0000000000..2f2f747219 --- /dev/null +++ b/packages/core/src/modules/connections/messages/ConnectionProblemReportMessage.ts @@ -0,0 +1,24 @@ +import type { ProblemReportMessageOptions } from '../../problem-reports/messages/ProblemReportMessage' + +import { Equals } from 'class-validator' + +import { ProblemReportMessage } from '../../problem-reports/messages/ProblemReportMessage' + +export type ConnectionProblemReportMessageOptions = ProblemReportMessageOptions + +/** + * @see https://github.com/hyperledger/aries-rfcs/blob/main/features/0035-report-problem/README.md + */ +export class ConnectionProblemReportMessage extends ProblemReportMessage { + /** + * Create new ConnectionProblemReportMessage instance. + * @param options + */ + public constructor(options: ConnectionProblemReportMessageOptions) { + super(options) + } + + @Equals(ConnectionProblemReportMessage.type) + public readonly type = ConnectionProblemReportMessage.type + public static readonly type = 'https://didcomm.org/connection/1.0/problem-report' +} diff --git a/packages/core/src/modules/connections/messages/index.ts b/packages/core/src/modules/connections/messages/index.ts index 6cb3241a61..2c3e27b80d 100644 --- a/packages/core/src/modules/connections/messages/index.ts +++ b/packages/core/src/modules/connections/messages/index.ts @@ -3,3 +3,4 @@ export * from './ConnectionRequestMessage' export * from './ConnectionResponseMessage' export * from './TrustPingMessage' export * from './TrustPingResponseMessage' +export * from './ConnectionProblemReportMessage' diff --git a/packages/core/src/modules/connections/models/ConnectionState.ts b/packages/core/src/modules/connections/models/ConnectionState.ts index 15071c2623..5b8c79ca48 100644 --- a/packages/core/src/modules/connections/models/ConnectionState.ts +++ b/packages/core/src/modules/connections/models/ConnectionState.ts @@ -10,4 +10,5 @@ export enum ConnectionState { Requested = 'requested', Responded = 'responded', Complete = 'complete', + None = 'none', } diff --git a/packages/core/src/modules/connections/repository/ConnectionRecord.ts b/packages/core/src/modules/connections/repository/ConnectionRecord.ts index bb56d18ed8..da0187197f 100644 --- a/packages/core/src/modules/connections/repository/ConnectionRecord.ts +++ b/packages/core/src/modules/connections/repository/ConnectionRecord.ts @@ -29,6 +29,7 @@ export interface ConnectionRecordProps { imageUrl?: string multiUseInvitation: boolean mediatorId?: string + errorMsg?: string } export type CustomConnectionTags = TagsBase @@ -68,6 +69,7 @@ export class ConnectionRecord public threadId?: string public mediatorId?: string + public errorMsg?: string public static readonly type = 'ConnectionRecord' public readonly type = ConnectionRecord.type @@ -94,6 +96,7 @@ export class ConnectionRecord this.imageUrl = props.imageUrl this.multiUseInvitation = props.multiUseInvitation this.mediatorId = props.mediatorId + this.errorMsg = props.errorMsg } } diff --git a/packages/core/src/modules/connections/services/ConnectionService.ts b/packages/core/src/modules/connections/services/ConnectionService.ts index 333def2a95..cf284df513 100644 --- a/packages/core/src/modules/connections/services/ConnectionService.ts +++ b/packages/core/src/modules/connections/services/ConnectionService.ts @@ -3,6 +3,7 @@ import type { InboundMessageContext } from '../../../agent/models/InboundMessage import type { Logger } from '../../../logger' import type { AckMessage } from '../../common' import type { ConnectionStateChangedEvent } from '../ConnectionEvents' +import type { ConnectionProblemReportMessage } from '../messages' import type { CustomConnectionTags } from '../repository/ConnectionRecord' import { firstValueFrom, ReplaySubject } from 'rxjs' @@ -18,6 +19,7 @@ import { JsonTransformer } from '../../../utils/JsonTransformer' import { MessageValidator } from '../../../utils/MessageValidator' import { Wallet } from '../../../wallet/Wallet' import { ConnectionEventTypes } from '../ConnectionEvents' +import { ConnectionProblemReportError, ConnectionProblemReportReason } from '../errors' import { ConnectionInvitationMessage, ConnectionRequestMessage, @@ -81,7 +83,6 @@ export class ConnectionService { autoAcceptConnection: config?.autoAcceptConnection, multiUseInvitation: config.multiUseInvitation ?? false, }) - const { didDoc } = connectionRecord const [service] = didDoc.didCommServices const invitation = new ConnectionInvitationMessage({ @@ -213,7 +214,9 @@ export class ConnectionService { connectionRecord.assertRole(ConnectionRole.Inviter) if (!message.connection.didDoc) { - throw new AriesFrameworkError('Public DIDs are not supported yet') + throw new ConnectionProblemReportError('Public DIDs are not supported yet', { + problemCode: ConnectionProblemReportReason.RequestNotAccepted, + }) } // Create new connection if using a multi use invitation @@ -330,8 +333,9 @@ export class ConnectionService { const signerVerkey = message.connectionSig.signer const invitationKey = connectionRecord.getTags().invitationKey if (signerVerkey !== invitationKey) { - throw new AriesFrameworkError( - `Connection object in connection response message is not signed with same key as recipient key in invitation expected='${invitationKey}' received='${signerVerkey}'` + throw new ConnectionProblemReportError( + `Connection object in connection response message is not signed with same key as recipient key in invitation expected='${invitationKey}' received='${signerVerkey}'`, + { problemCode: ConnectionProblemReportReason.ResponseNotAccepted } ) } @@ -403,6 +407,37 @@ export class ConnectionService { return connection } + /** + * Process a received {@link ProblemReportMessage}. + * + * @param messageContext The message context containing a connection problem report message + * @returns connection record associated with the connection problem report message + * + */ + public async processProblemReport( + messageContext: InboundMessageContext + ): Promise { + const { message: connectionProblemReportMessage, recipientVerkey } = messageContext + + this.logger.debug(`Processing connection problem report for verkey ${recipientVerkey}`) + + if (!recipientVerkey) { + throw new AriesFrameworkError('Unable to process connection problem report without recipientVerkey') + } + + const connectionRecord = await this.findByVerkey(recipientVerkey) + + if (!connectionRecord) { + throw new AriesFrameworkError( + `Unable to process connection problem report: connection for verkey ${recipientVerkey} not found` + ) + } + + connectionRecord.errorMsg = `${connectionProblemReportMessage.description.code} : ${connectionProblemReportMessage.description.en}` + await this.updateState(connectionRecord, ConnectionState.None) + return connectionRecord + } + /** * Assert that an inbound message either has a connection associated with it, * or has everything correctly set up for connection-less exchange. diff --git a/packages/core/src/modules/credentials/CredentialState.ts b/packages/core/src/modules/credentials/CredentialState.ts index 9e6c4c6000..dc306efb57 100644 --- a/packages/core/src/modules/credentials/CredentialState.ts +++ b/packages/core/src/modules/credentials/CredentialState.ts @@ -14,4 +14,5 @@ export enum CredentialState { CredentialIssued = 'credential-issued', CredentialReceived = 'credential-received', Done = 'done', + None = 'none', } diff --git a/packages/core/src/modules/credentials/CredentialsModule.ts b/packages/core/src/modules/credentials/CredentialsModule.ts index 5e1fab906e..dc779afe23 100644 --- a/packages/core/src/modules/credentials/CredentialsModule.ts +++ b/packages/core/src/modules/credentials/CredentialsModule.ts @@ -16,13 +16,16 @@ import { ConnectionService } from '../connections/services/ConnectionService' import { MediationRecipientService } from '../routing' import { CredentialResponseCoordinator } from './CredentialResponseCoordinator' +import { CredentialProblemReportReason } from './errors' import { CredentialAckHandler, IssueCredentialHandler, OfferCredentialHandler, ProposeCredentialHandler, RequestCredentialHandler, + CredentialProblemReportHandler, } from './handlers' +import { CredentialProblemReportMessage } from './messages' import { CredentialService } from './services' @scoped(Lifecycle.ContainerScoped) @@ -441,6 +444,33 @@ export class CredentialsModule { return credentialRecord } + /** + * Send problem report message for a credential record + * @param credentialRecordId The id of the credential record for which to send problem report + * @param message message to send + * @returns credential record associated with credential problem report message + */ + public async sendProblemReport(credentialRecordId: string, message: string) { + const record = await this.credentialService.getById(credentialRecordId) + if (!record.connectionId) { + throw new AriesFrameworkError(`No connectionId found for credential record '${record.id}'.`) + } + const connection = await this.connectionService.getById(record.connectionId) + const credentialProblemReportMessage = new CredentialProblemReportMessage({ + description: { + en: message, + code: CredentialProblemReportReason.IssuanceAbandoned, + }, + }) + credentialProblemReportMessage.setThread({ + threadId: record.threadId, + }) + const outboundMessage = createOutboundMessage(connection, credentialProblemReportMessage) + await this.messageSender.sendMessage(outboundMessage) + + return record + } + /** * Retrieve all credential records * @@ -500,5 +530,6 @@ export class CredentialsModule { new IssueCredentialHandler(this.credentialService, this.agentConfig, this.credentialResponseCoordinator) ) dispatcher.registerHandler(new CredentialAckHandler(this.credentialService)) + dispatcher.registerHandler(new CredentialProblemReportHandler(this.credentialService)) } } diff --git a/packages/core/src/modules/credentials/__tests__/CredentialService.test.ts b/packages/core/src/modules/credentials/__tests__/CredentialService.test.ts index f0ca2f12fb..c7eba091c2 100644 --- a/packages/core/src/modules/credentials/__tests__/CredentialService.test.ts +++ b/packages/core/src/modules/credentials/__tests__/CredentialService.test.ts @@ -20,6 +20,7 @@ import { IndyLedgerService } from '../../ledger/services' import { CredentialEventTypes } from '../CredentialEvents' import { CredentialState } from '../CredentialState' import { CredentialUtils } from '../CredentialUtils' +import { CredentialProblemReportReason } from '../errors/CredentialProblemReportReason' import { CredentialAckMessage, CredentialPreview, @@ -34,6 +35,7 @@ import { CredentialRecord } from '../repository/CredentialRecord' import { CredentialRepository } from '../repository/CredentialRepository' import { CredentialService } from '../services' +import { CredentialProblemReportMessage } from './../messages/CredentialProblemReportMessage' import { credDef, credOffer, credReq } from './fixtures' // Mock classes @@ -922,6 +924,109 @@ describe('CredentialService', () => { }) }) + describe('createProblemReport', () => { + const threadId = 'fd9c5ddb-ec11-4acd-bc32-540736249746' + let credential: CredentialRecord + + beforeEach(() => { + credential = mockCredentialRecord({ + state: CredentialState.OfferReceived, + threadId, + connectionId: 'b1e2f039-aa39-40be-8643-6ce2797b5190', + }) + }) + + test('returns problem report message base once get error', async () => { + // given + mockFunction(credentialRepository.getById).mockReturnValue(Promise.resolve(credential)) + + // when + const credentialProblemReportMessage = await new CredentialProblemReportMessage({ + description: { + en: 'Indy error', + code: CredentialProblemReportReason.IssuanceAbandoned, + }, + }) + + credentialProblemReportMessage.setThread({ threadId }) + // then + expect(credentialProblemReportMessage.toJSON()).toMatchObject({ + '@id': expect.any(String), + '@type': 'https://didcomm.org/issue-credential/1.0/problem-report', + '~thread': { + thid: 'fd9c5ddb-ec11-4acd-bc32-540736249746', + }, + }) + }) + }) + + describe('processProblemReport', () => { + let credential: CredentialRecord + let messageContext: InboundMessageContext + + beforeEach(() => { + credential = mockCredentialRecord({ + state: CredentialState.OfferReceived, + }) + + const credentialProblemReportMessage = new CredentialProblemReportMessage({ + description: { + en: 'Indy error', + code: CredentialProblemReportReason.IssuanceAbandoned, + }, + }) + credentialProblemReportMessage.setThread({ threadId: 'somethreadid' }) + messageContext = new InboundMessageContext(credentialProblemReportMessage, { + connection, + }) + }) + + test(`updates state to ${CredentialState.None} and returns credential record`, async () => { + const repositoryUpdateSpy = jest.spyOn(credentialRepository, 'update') + + // given + mockFunction(credentialRepository.getSingleByQuery).mockReturnValue(Promise.resolve(credential)) + + // when + const returnedCredentialRecord = await credentialService.processProblemReport(messageContext) + + // then + const expectedCredentialRecord = { + state: CredentialState.None, + } + expect(credentialRepository.getSingleByQuery).toHaveBeenNthCalledWith(1, { + threadId: 'somethreadid', + connectionId: connection.id, + }) + expect(repositoryUpdateSpy).toHaveBeenCalledTimes(1) + const [[updatedCredentialRecord]] = repositoryUpdateSpy.mock.calls + expect(updatedCredentialRecord).toMatchObject(expectedCredentialRecord) + expect(returnedCredentialRecord).toMatchObject(expectedCredentialRecord) + }) + + test(`emits stateChange event from ${CredentialState.OfferReceived} to ${CredentialState.None}`, async () => { + const eventListenerMock = jest.fn() + eventEmitter.on(CredentialEventTypes.CredentialStateChanged, eventListenerMock) + + // given + mockFunction(credentialRepository.getSingleByQuery).mockReturnValue(Promise.resolve(credential)) + + // when + await credentialService.processProblemReport(messageContext) + + // then + expect(eventListenerMock).toHaveBeenCalledWith({ + type: 'CredentialStateChanged', + payload: { + previousState: CredentialState.OfferReceived, + credentialRecord: expect.objectContaining({ + state: CredentialState.None, + }), + }, + }) + }) + }) + describe('repository methods', () => { it('getById should return value from credentialRepository.getById', async () => { const expected = mockCredentialRecord() diff --git a/packages/core/src/modules/credentials/errors/CredentialProblemReportError.ts b/packages/core/src/modules/credentials/errors/CredentialProblemReportError.ts new file mode 100644 index 0000000000..4b10c3fa2c --- /dev/null +++ b/packages/core/src/modules/credentials/errors/CredentialProblemReportError.ts @@ -0,0 +1,23 @@ +import type { ProblemReportErrorOptions } from '../../problem-reports' +import type { CredentialProblemReportReason } from './CredentialProblemReportReason' + +import { CredentialProblemReportMessage } from '../messages' + +import { ProblemReportError } from './../../problem-reports/errors/ProblemReportError' + +interface CredentialProblemReportErrorOptions extends ProblemReportErrorOptions { + problemCode: CredentialProblemReportReason +} +export class CredentialProblemReportError extends ProblemReportError { + public problemReport: CredentialProblemReportMessage + + public constructor(message: string, { problemCode }: CredentialProblemReportErrorOptions) { + super(message, { problemCode }) + this.problemReport = new CredentialProblemReportMessage({ + description: { + en: message, + code: problemCode, + }, + }) + } +} diff --git a/packages/core/src/modules/credentials/errors/CredentialProblemReportReason.ts b/packages/core/src/modules/credentials/errors/CredentialProblemReportReason.ts new file mode 100644 index 0000000000..cf8bdb95bf --- /dev/null +++ b/packages/core/src/modules/credentials/errors/CredentialProblemReportReason.ts @@ -0,0 +1,8 @@ +/** + * Credential error code in RFC 0036. + * + * @see https://github.com/hyperledger/aries-rfcs/blob/main/features/0036-issue-credential/README.md + */ +export enum CredentialProblemReportReason { + IssuanceAbandoned = 'issuance-abandoned', +} diff --git a/packages/core/src/modules/credentials/errors/index.ts b/packages/core/src/modules/credentials/errors/index.ts new file mode 100644 index 0000000000..3d5c266524 --- /dev/null +++ b/packages/core/src/modules/credentials/errors/index.ts @@ -0,0 +1,2 @@ +export * from './CredentialProblemReportError' +export * from './CredentialProblemReportReason' diff --git a/packages/core/src/modules/credentials/handlers/CredentialProblemReportHandler.ts b/packages/core/src/modules/credentials/handlers/CredentialProblemReportHandler.ts new file mode 100644 index 0000000000..b89a620d07 --- /dev/null +++ b/packages/core/src/modules/credentials/handlers/CredentialProblemReportHandler.ts @@ -0,0 +1,17 @@ +import type { Handler, HandlerInboundMessage } from '../../../agent/Handler' +import type { CredentialService } from '../services' + +import { CredentialProblemReportMessage } from '../messages' + +export class CredentialProblemReportHandler implements Handler { + private credentialService: CredentialService + public supportedMessages = [CredentialProblemReportMessage] + + public constructor(credentialService: CredentialService) { + this.credentialService = credentialService + } + + public async handle(messageContext: HandlerInboundMessage) { + await this.credentialService.processProblemReport(messageContext) + } +} diff --git a/packages/core/src/modules/credentials/handlers/RequestCredentialHandler.ts b/packages/core/src/modules/credentials/handlers/RequestCredentialHandler.ts index c4a4d449c4..7043ddaea1 100644 --- a/packages/core/src/modules/credentials/handlers/RequestCredentialHandler.ts +++ b/packages/core/src/modules/credentials/handlers/RequestCredentialHandler.ts @@ -39,7 +39,6 @@ export class RequestCredentialHandler implements Handler { ) const { message, credentialRecord } = await this.credentialService.createCredential(record) - if (messageContext.connection) { return createOutboundMessage(messageContext.connection, message) } else if (credentialRecord.requestMessage?.service && credentialRecord.offerMessage?.service) { @@ -57,7 +56,6 @@ export class RequestCredentialHandler implements Handler { senderKey: ourService.recipientKeys[0], }) } - this.agentConfig.logger.error(`Could not automatically create credential request`) } } diff --git a/packages/core/src/modules/credentials/handlers/index.ts b/packages/core/src/modules/credentials/handlers/index.ts index fefbf8d9ad..6f732d6413 100644 --- a/packages/core/src/modules/credentials/handlers/index.ts +++ b/packages/core/src/modules/credentials/handlers/index.ts @@ -3,3 +3,4 @@ export * from './IssueCredentialHandler' export * from './OfferCredentialHandler' export * from './ProposeCredentialHandler' export * from './RequestCredentialHandler' +export * from './CredentialProblemReportHandler' diff --git a/packages/core/src/modules/credentials/messages/CredentialProblemReportMessage.ts b/packages/core/src/modules/credentials/messages/CredentialProblemReportMessage.ts new file mode 100644 index 0000000000..2ceec3d788 --- /dev/null +++ b/packages/core/src/modules/credentials/messages/CredentialProblemReportMessage.ts @@ -0,0 +1,24 @@ +import type { ProblemReportMessageOptions } from '../../problem-reports/messages/ProblemReportMessage' + +import { Equals } from 'class-validator' + +import { ProblemReportMessage } from '../../problem-reports/messages/ProblemReportMessage' + +export type CredentialProblemReportMessageOptions = ProblemReportMessageOptions + +/** + * @see https://github.com/hyperledger/aries-rfcs/blob/main/features/0035-report-problem/README.md + */ +export class CredentialProblemReportMessage extends ProblemReportMessage { + /** + * Create new CredentialProblemReportMessage instance. + * @param options + */ + public constructor(options: CredentialProblemReportMessageOptions) { + super(options) + } + + @Equals(CredentialProblemReportMessage.type) + public readonly type = CredentialProblemReportMessage.type + public static readonly type = 'https://didcomm.org/issue-credential/1.0/problem-report' +} diff --git a/packages/core/src/modules/credentials/messages/index.ts b/packages/core/src/modules/credentials/messages/index.ts index 2979876a4a..60e1acf335 100644 --- a/packages/core/src/modules/credentials/messages/index.ts +++ b/packages/core/src/modules/credentials/messages/index.ts @@ -4,3 +4,4 @@ export * from './RequestCredentialMessage' export * from './IssueCredentialMessage' export * from './OfferCredentialMessage' export * from './ProposeCredentialMessage' +export * from './CredentialProblemReportMessage' diff --git a/packages/core/src/modules/credentials/repository/CredentialRecord.ts b/packages/core/src/modules/credentials/repository/CredentialRecord.ts index 159a854469..ee35a523bd 100644 --- a/packages/core/src/modules/credentials/repository/CredentialRecord.ts +++ b/packages/core/src/modules/credentials/repository/CredentialRecord.ts @@ -33,6 +33,7 @@ export interface CredentialRecordProps { credentialAttributes?: CredentialPreviewAttribute[] autoAcceptCredential?: AutoAcceptCredential linkedAttachments?: Attachment[] + errorMsg?: string } export type CustomCredentialTags = TagsBase @@ -49,6 +50,7 @@ export class CredentialRecord extends BaseRecord ProposeCredentialMessage) @@ -88,6 +90,7 @@ export class CredentialRecord extends BaseRecord('_internal/indyRequest') if (!credentialRequestMetadata) { - throw new AriesFrameworkError(`Missing required request metadata for credential with id ${credentialRecord.id}`) + throw new CredentialProblemReportError( + `Missing required request metadata for credential with id ${credentialRecord.id}`, + { problemCode: CredentialProblemReportReason.IssuanceAbandoned } + ) } const indyCredential = issueCredentialMessage.indyCredential if (!indyCredential) { - throw new AriesFrameworkError( - `Missing required base64 encoded attachment data for credential with thread id ${issueCredentialMessage.threadId}` + throw new CredentialProblemReportError( + `Missing required base64 encoded attachment data for credential with thread id ${issueCredentialMessage.threadId}`, + { problemCode: CredentialProblemReportReason.IssuanceAbandoned } ) } @@ -713,6 +723,31 @@ export class CredentialService { return credentialRecord } + /** + * Process a received {@link ProblemReportMessage}. + * + * @param messageContext The message context containing a credential problem report message + * @returns credential record associated with the credential problem report message + * + */ + public async processProblemReport( + messageContext: InboundMessageContext + ): Promise { + const { message: credentialProblemReportMessage, connection } = messageContext + + this.logger.debug(`Processing problem report with id ${credentialProblemReportMessage.id}`) + + const credentialRecord = await this.getByThreadAndConnectionId( + credentialProblemReportMessage.threadId, + connection?.id + ) + + // Update record + credentialRecord.errorMsg = `${credentialProblemReportMessage.description.code}: ${credentialProblemReportMessage.description.en}` + await this.updateState(credentialRecord, CredentialState.None) + return credentialRecord + } + /** * Retrieve all credential records * diff --git a/packages/core/src/modules/problem-reports/errors/ProblemReportError.ts b/packages/core/src/modules/problem-reports/errors/ProblemReportError.ts new file mode 100644 index 0000000000..708e694d59 --- /dev/null +++ b/packages/core/src/modules/problem-reports/errors/ProblemReportError.ts @@ -0,0 +1,20 @@ +import { AriesFrameworkError } from '../../../error/AriesFrameworkError' +import { ProblemReportMessage } from '../messages/ProblemReportMessage' + +export interface ProblemReportErrorOptions { + problemCode: string +} + +export class ProblemReportError extends AriesFrameworkError { + public problemReport: ProblemReportMessage + + public constructor(message: string, { problemCode }: ProblemReportErrorOptions) { + super(message) + this.problemReport = new ProblemReportMessage({ + description: { + en: message, + code: problemCode, + }, + }) + } +} diff --git a/packages/core/src/modules/problem-reports/errors/index.ts b/packages/core/src/modules/problem-reports/errors/index.ts new file mode 100644 index 0000000000..1eb23b7c6b --- /dev/null +++ b/packages/core/src/modules/problem-reports/errors/index.ts @@ -0,0 +1 @@ +export * from './ProblemReportError' diff --git a/packages/core/src/modules/problem-reports/index.ts b/packages/core/src/modules/problem-reports/index.ts new file mode 100644 index 0000000000..52cb42ded3 --- /dev/null +++ b/packages/core/src/modules/problem-reports/index.ts @@ -0,0 +1,2 @@ +export * from './errors' +export * from './messages' diff --git a/packages/core/src/modules/problem-reports/messages/ProblemReportMessage.ts b/packages/core/src/modules/problem-reports/messages/ProblemReportMessage.ts new file mode 100644 index 0000000000..db62673913 --- /dev/null +++ b/packages/core/src/modules/problem-reports/messages/ProblemReportMessage.ts @@ -0,0 +1,122 @@ +// Create a base ProblemReportMessage message class and add it to the messages directory +import { Expose } from 'class-transformer' +import { Equals, IsEnum, IsOptional, IsString } from 'class-validator' + +import { AgentMessage } from '../../../agent/AgentMessage' +import { CommonMessageType } from '../../common/messages/CommonMessageType' + +export enum WhoRetriesStatus { + You = 'YOU', + Me = 'ME', + Both = 'BOTH', + None = 'NONE', +} + +export enum ImpactStatus { + Message = 'MESSAGE', + Thread = 'THREAD', + Connection = 'CONNECTION', +} + +export enum WhereStatus { + Cloud = 'CLOUD', + Edge = 'EDGE', + Wire = 'WIRE', + Agency = 'AGENCY', +} + +export enum OtherStatus { + You = 'YOU', + Me = 'ME', + Other = 'OTHER', +} + +export interface DescriptionOptions { + en: string + code: string +} + +export interface FixHintOptions { + en: string +} + +export interface ProblemReportMessageOptions { + id?: string + description: DescriptionOptions + problemItems?: string[] + whoRetries?: WhoRetriesStatus + fixHint?: FixHintOptions + impact?: ImpactStatus + where?: WhereStatus + noticedTime?: string + trackingUri?: string + escalationUri?: string +} + +/** + * @see https://github.com/hyperledger/aries-rfcs/blob/main/features/0035-report-problem/README.md + */ +export class ProblemReportMessage extends AgentMessage { + /** + * Create new ReportProblem instance. + * @param options + */ + public constructor(options: ProblemReportMessageOptions) { + super() + + if (options) { + this.id = options.id || this.generateId() + this.description = options.description + this.problemItems = options.problemItems + this.whoRetries = options.whoRetries + this.fixHint = options.fixHint + this.impact = options.impact + this.where = options.where + this.noticedTime = options.noticedTime + this.trackingUri = options.trackingUri + this.escalationUri = options.escalationUri + } + } + + @Equals(ProblemReportMessage.type) + public readonly type: string = ProblemReportMessage.type + public static readonly type: string = CommonMessageType.ProblemReport + + public description!: DescriptionOptions + + @IsOptional() + @Expose({ name: 'problem_items' }) + public problemItems?: string[] + + @IsOptional() + @IsEnum(WhoRetriesStatus) + @Expose({ name: 'who_retries' }) + public whoRetries?: WhoRetriesStatus + + @IsOptional() + @Expose({ name: 'fix_hint' }) + public fixHint?: FixHintOptions + + @IsOptional() + @IsEnum(WhereStatus) + public where?: WhereStatus + + @IsOptional() + @IsEnum(ImpactStatus) + public impact?: ImpactStatus + + @IsOptional() + @IsString() + @Expose({ name: 'noticed_time' }) + public noticedTime?: string + + @IsOptional() + @IsString() + @Expose({ name: 'tracking_uri' }) + public trackingUri?: string + + @IsOptional() + @IsString() + @Expose({ name: 'escalation_uri' }) + public escalationUri?: string +} diff --git a/packages/core/src/modules/problem-reports/messages/index.ts b/packages/core/src/modules/problem-reports/messages/index.ts new file mode 100644 index 0000000000..57670e5421 --- /dev/null +++ b/packages/core/src/modules/problem-reports/messages/index.ts @@ -0,0 +1 @@ +export * from './ProblemReportMessage' diff --git a/packages/core/src/modules/proofs/ProofState.ts b/packages/core/src/modules/proofs/ProofState.ts index 73869e80aa..95c32dffaa 100644 --- a/packages/core/src/modules/proofs/ProofState.ts +++ b/packages/core/src/modules/proofs/ProofState.ts @@ -12,4 +12,5 @@ export enum ProofState { PresentationReceived = 'presentation-received', Declined = 'declined', Done = 'done', + None = 'none', } diff --git a/packages/core/src/modules/proofs/ProofsModule.ts b/packages/core/src/modules/proofs/ProofsModule.ts index 92e59a4944..2a3b0d395f 100644 --- a/packages/core/src/modules/proofs/ProofsModule.ts +++ b/packages/core/src/modules/proofs/ProofsModule.ts @@ -16,12 +16,15 @@ import { ConnectionService } from '../connections/services/ConnectionService' import { MediationRecipientService } from '../routing/services/MediationRecipientService' import { ProofResponseCoordinator } from './ProofResponseCoordinator' +import { PresentationProblemReportReason } from './errors' import { ProposePresentationHandler, RequestPresentationHandler, PresentationAckHandler, PresentationHandler, + PresentationProblemReportHandler, } from './handlers' +import { PresentationProblemReportMessage } from './messages/PresentationProblemReportMessage' import { ProofRequest } from './models/ProofRequest' import { ProofService } from './services' @@ -368,6 +371,33 @@ export class ProofsModule { return this.proofService.autoSelectCredentialsForProofRequest(retrievedCredentials) } + /** + * Send problem report message for a proof record + * @param proofRecordId The id of the proof record for which to send problem report + * @param message message to send + * @returns proof record associated with the proof problem report message + */ + public async sendProblemReport(proofRecordId: string, message: string) { + const record = await this.proofService.getById(proofRecordId) + if (!record.connectionId) { + throw new AriesFrameworkError(`No connectionId found for proof record '${record.id}'.`) + } + const connection = await this.connectionService.getById(record.connectionId) + const presentationProblemReportMessage = new PresentationProblemReportMessage({ + description: { + en: message, + code: PresentationProblemReportReason.abandoned, + }, + }) + presentationProblemReportMessage.setThread({ + threadId: record.threadId, + }) + const outboundMessage = createOutboundMessage(connection, presentationProblemReportMessage) + await this.messageSender.sendMessage(outboundMessage) + + return record + } + /** * Retrieve all proof records * @@ -426,6 +456,7 @@ export class ProofsModule { new PresentationHandler(this.proofService, this.agentConfig, this.proofResponseCoordinator) ) dispatcher.registerHandler(new PresentationAckHandler(this.proofService)) + dispatcher.registerHandler(new PresentationProblemReportHandler(this.proofService)) } } diff --git a/packages/core/src/modules/proofs/__tests__/ProofService.test.ts b/packages/core/src/modules/proofs/__tests__/ProofService.test.ts new file mode 100644 index 0000000000..e7b8f02ea6 --- /dev/null +++ b/packages/core/src/modules/proofs/__tests__/ProofService.test.ts @@ -0,0 +1,281 @@ +import type { Wallet } from '../../../wallet/Wallet' +import type { CredentialRepository } from '../../credentials/repository' +import type { ProofStateChangedEvent } from '../ProofEvents' +import type { CustomProofTags } from './../repository/ProofRecord' + +import { getAgentConfig, getMockConnection, mockFunction } from '../../../../tests/helpers' +import { EventEmitter } from '../../../agent/EventEmitter' +import { InboundMessageContext } from '../../../agent/models/InboundMessageContext' +import { Attachment, AttachmentData } from '../../../decorators/attachment/Attachment' +import { ConnectionService, ConnectionState } from '../../connections' +import { IndyHolderService } from '../../indy/services/IndyHolderService' +import { IndyLedgerService } from '../../ledger/services' +import { ProofEventTypes } from '../ProofEvents' +import { ProofState } from '../ProofState' +import { PresentationProblemReportReason } from '../errors/PresentationProblemReportReason' +import { INDY_PROOF_REQUEST_ATTACHMENT_ID } from '../messages' +import { ProofRecord } from '../repository/ProofRecord' +import { ProofRepository } from '../repository/ProofRepository' +import { ProofService } from '../services' + +import { IndyVerifierService } from './../../indy/services/IndyVerifierService' +import { PresentationProblemReportMessage } from './../messages/PresentationProblemReportMessage' +import { RequestPresentationMessage } from './../messages/RequestPresentationMessage' +import { credDef } from './fixtures' + +// Mock classes +jest.mock('../repository/ProofRepository') +jest.mock('../../../modules/ledger/services/IndyLedgerService') +jest.mock('../../indy/services/IndyHolderService') +jest.mock('../../indy/services/IndyIssuerService') +jest.mock('../../indy/services/IndyVerifierService') +jest.mock('../../connections/services/ConnectionService') + +// Mock typed object +const ProofRepositoryMock = ProofRepository as jest.Mock +const IndyLedgerServiceMock = IndyLedgerService as jest.Mock +const IndyHolderServiceMock = IndyHolderService as jest.Mock +const IndyVerifierServiceMock = IndyVerifierService as jest.Mock +const connectionServiceMock = ConnectionService as jest.Mock + +const connection = getMockConnection({ + id: '123', + state: ConnectionState.Complete, +}) + +const requestAttachment = new Attachment({ + id: INDY_PROOF_REQUEST_ATTACHMENT_ID, + mimeType: 'application/json', + data: new AttachmentData({ + base64: + 'eyJuYW1lIjogIlByb29mIHJlcXVlc3QiLCAibm9uX3Jldm9rZWQiOiB7ImZyb20iOiAxNjQwOTk1MTk5LCAidG8iOiAxNjQwOTk1MTk5fSwgIm5vbmNlIjogIjEiLCAicmVxdWVzdGVkX2F0dHJpYnV0ZXMiOiB7ImFkZGl0aW9uYWxQcm9wMSI6IHsibmFtZSI6ICJmYXZvdXJpdGVEcmluayIsICJub25fcmV2b2tlZCI6IHsiZnJvbSI6IDE2NDA5OTUxOTksICJ0byI6IDE2NDA5OTUxOTl9LCAicmVzdHJpY3Rpb25zIjogW3siY3JlZF9kZWZfaWQiOiAiV2dXeHF6dHJOb29HOTJSWHZ4U1RXdjozOkNMOjIwOnRhZyJ9XX19LCAicmVxdWVzdGVkX3ByZWRpY2F0ZXMiOiB7fSwgInZlcnNpb24iOiAiMS4wIn0=', + }), +}) + +// A record is deserialized to JSON when it's stored into the storage. We want to simulate this behaviour for `offer` +// object to test our service would behave correctly. We use type assertion for `offer` attribute to `any`. +const mockProofRecord = ({ + state, + requestMessage, + threadId, + connectionId, + tags, + id, +}: { + state?: ProofState + requestMessage?: RequestPresentationMessage + tags?: CustomProofTags + threadId?: string + connectionId?: string + id?: string +} = {}) => { + const requestPresentationMessage = new RequestPresentationMessage({ + comment: 'some comment', + requestPresentationAttachments: [requestAttachment], + }) + + const proofRecord = new ProofRecord({ + requestMessage, + id, + state: state || ProofState.RequestSent, + threadId: threadId ?? requestPresentationMessage.id, + connectionId: connectionId ?? '123', + tags, + }) + + return proofRecord +} + +describe('ProofService', () => { + let proofRepository: ProofRepository + let proofService: ProofService + let ledgerService: IndyLedgerService + let wallet: Wallet + let indyVerifierService: IndyVerifierService + let indyHolderService: IndyHolderService + let eventEmitter: EventEmitter + let credentialRepository: CredentialRepository + let connectionService: ConnectionService + + beforeEach(() => { + const agentConfig = getAgentConfig('ProofServiceTest') + proofRepository = new ProofRepositoryMock() + indyVerifierService = new IndyVerifierServiceMock() + indyHolderService = new IndyHolderServiceMock() + ledgerService = new IndyLedgerServiceMock() + eventEmitter = new EventEmitter(agentConfig) + connectionService = new connectionServiceMock() + + proofService = new ProofService( + proofRepository, + ledgerService, + wallet, + agentConfig, + indyHolderService, + indyVerifierService, + connectionService, + eventEmitter, + credentialRepository + ) + + mockFunction(ledgerService.getCredentialDefinition).mockReturnValue(Promise.resolve(credDef)) + }) + + describe('processProofRequest', () => { + let presentationRequest: RequestPresentationMessage + let messageContext: InboundMessageContext + + beforeEach(() => { + presentationRequest = new RequestPresentationMessage({ + comment: 'abcd', + requestPresentationAttachments: [requestAttachment], + }) + messageContext = new InboundMessageContext(presentationRequest, { + connection, + }) + }) + + test(`creates and return proof record in ${ProofState.PresentationReceived} state with offer, without thread ID`, async () => { + const repositorySaveSpy = jest.spyOn(proofRepository, 'save') + + // when + const returnedProofRecord = await proofService.processRequest(messageContext) + + // then + const expectedProofRecord = { + type: ProofRecord.name, + id: expect.any(String), + createdAt: expect.any(Date), + state: ProofState.RequestReceived, + threadId: presentationRequest.id, + connectionId: connection.id, + } + expect(repositorySaveSpy).toHaveBeenCalledTimes(1) + const [[createdProofRecord]] = repositorySaveSpy.mock.calls + expect(createdProofRecord).toMatchObject(expectedProofRecord) + expect(returnedProofRecord).toMatchObject(expectedProofRecord) + }) + + test(`emits stateChange event with ${ProofState.RequestReceived}`, async () => { + const eventListenerMock = jest.fn() + eventEmitter.on(ProofEventTypes.ProofStateChanged, eventListenerMock) + + // when + await proofService.processRequest(messageContext) + + // then + expect(eventListenerMock).toHaveBeenCalledWith({ + type: 'ProofStateChanged', + payload: { + previousState: null, + proofRecord: expect.objectContaining({ + state: ProofState.RequestReceived, + }), + }, + }) + }) + }) + + describe('createProblemReport', () => { + const threadId = 'fd9c5ddb-ec11-4acd-bc32-540736249746' + let proof: ProofRecord + + beforeEach(() => { + proof = mockProofRecord({ + state: ProofState.RequestReceived, + threadId, + connectionId: 'b1e2f039-aa39-40be-8643-6ce2797b5190', + }) + }) + + test('returns problem report message base once get error', async () => { + // given + mockFunction(proofRepository.getById).mockReturnValue(Promise.resolve(proof)) + + // when + const presentationProblemReportMessage = await new PresentationProblemReportMessage({ + description: { + en: 'Indy error', + code: PresentationProblemReportReason.abandoned, + }, + }) + + presentationProblemReportMessage.setThread({ threadId }) + // then + expect(presentationProblemReportMessage.toJSON()).toMatchObject({ + '@id': expect.any(String), + '@type': 'https://didcomm.org/present-proof/1.0/problem-report', + '~thread': { + thid: 'fd9c5ddb-ec11-4acd-bc32-540736249746', + }, + }) + }) + }) + + describe('processProblemReport', () => { + let proof: ProofRecord + let messageContext: InboundMessageContext + + beforeEach(() => { + proof = mockProofRecord({ + state: ProofState.RequestReceived, + }) + + const presentationProblemReportMessage = new PresentationProblemReportMessage({ + description: { + en: 'Indy error', + code: PresentationProblemReportReason.abandoned, + }, + }) + presentationProblemReportMessage.setThread({ threadId: 'somethreadid' }) + messageContext = new InboundMessageContext(presentationProblemReportMessage, { + connection, + }) + }) + + test(`updates state to ${ProofState.None} and returns proof record`, async () => { + const repositoryUpdateSpy = jest.spyOn(proofRepository, 'update') + + // given + mockFunction(proofRepository.getSingleByQuery).mockReturnValue(Promise.resolve(proof)) + + // when + const returnedCredentialRecord = await proofService.processProblemReport(messageContext) + + // then + const expectedCredentialRecord = { + state: ProofState.None, + } + expect(proofRepository.getSingleByQuery).toHaveBeenNthCalledWith(1, { + threadId: 'somethreadid', + connectionId: connection.id, + }) + expect(repositoryUpdateSpy).toHaveBeenCalledTimes(1) + const [[updatedCredentialRecord]] = repositoryUpdateSpy.mock.calls + expect(updatedCredentialRecord).toMatchObject(expectedCredentialRecord) + expect(returnedCredentialRecord).toMatchObject(expectedCredentialRecord) + }) + + test(`emits stateChange event from ${ProofState.RequestReceived} to ${ProofState.None}`, async () => { + const eventListenerMock = jest.fn() + eventEmitter.on(ProofEventTypes.ProofStateChanged, eventListenerMock) + + // given + mockFunction(proofRepository.getSingleByQuery).mockReturnValue(Promise.resolve(proof)) + + // when + await proofService.processProblemReport(messageContext) + + // then + expect(eventListenerMock).toHaveBeenCalledWith({ + type: 'ProofStateChanged', + payload: { + previousState: ProofState.RequestReceived, + proofRecord: expect.objectContaining({ + state: ProofState.None, + }), + }, + }) + }) + }) +}) diff --git a/packages/core/src/modules/proofs/__tests__/fixtures.ts b/packages/core/src/modules/proofs/__tests__/fixtures.ts new file mode 100644 index 0000000000..10606073b8 --- /dev/null +++ b/packages/core/src/modules/proofs/__tests__/fixtures.ts @@ -0,0 +1,17 @@ +export const credDef = { + ver: '1.0', + id: 'TL1EaPFCZ8Si5aUrqScBDt:3:CL:16:TAG', + schemaId: '16', + type: 'CL', + tag: 'TAG', + value: { + primary: { + n: '92498022445845202032348897620554299694896009176315493627722439892023558526259875239808280186111059586069456394012963552956574651629517633396592827947162983189649269173220440607665417484696688946624963596710652063849006738050417440697782608643095591808084344059908523401576738321329706597491345875134180790935098782801918369980296355919072827164363500681884641551147645504164254206270541724042784184712124576190438261715948768681331862924634233043594086219221089373455065715714369325926959533971768008691000560918594972006312159600845441063618991760512232714992293187779673708252226326233136573974603552763615191259713', + s: '10526250116244590830801226936689232818708299684432892622156345407187391699799320507237066062806731083222465421809988887959680863378202697458984451550048737847231343182195679453915452156726746705017249911605739136361885518044604626564286545453132948801604882107628140153824106426249153436206037648809856342458324897885659120708767794055147846459394129610878181859361616754832462886951623882371283575513182530118220334228417923423365966593298195040550255217053655606887026300020680355874881473255854564974899509540795154002250551880061649183753819902391970912501350100175974791776321455551753882483918632271326727061054', + r: [Object], + rctxt: + '46370806529776888197599056685386177334629311939451963919411093310852010284763705864375085256873240323432329015015526097014834809926159013231804170844321552080493355339505872140068998254185756917091385820365193200970156007391350745837300010513687490459142965515562285631984769068796922482977754955668569724352923519618227464510753980134744424528043503232724934196990461197793822566137436901258663918660818511283047475389958180983391173176526879694302021471636017119966755980327241734084462963412467297412455580500138233383229217300797768907396564522366006433982511590491966618857814545264741708965590546773466047139517', + z: '84153935869396527029518633753040092509512111365149323230260584738724940130382637900926220255597132853379358675015222072417404334537543844616589463419189203852221375511010886284448841979468767444910003114007224993233448170299654815710399828255375084265247114471334540928216537567325499206413940771681156686116516158907421215752364889506967984343660576422672840921988126699885304325384925457260272972771547695861942114712679509318179363715259460727275178310181122162544785290813713205047589943947592273130618286905125194410421355167030389500160371886870735704712739886223342214864760968555566496288314800410716250791012', + }, + }, +} diff --git a/packages/core/src/modules/proofs/errors/PresentationProblemReportError.ts b/packages/core/src/modules/proofs/errors/PresentationProblemReportError.ts new file mode 100644 index 0000000000..2869a026d5 --- /dev/null +++ b/packages/core/src/modules/proofs/errors/PresentationProblemReportError.ts @@ -0,0 +1,24 @@ +import type { ProblemReportErrorOptions } from '../../problem-reports' +import type { PresentationProblemReportReason } from './PresentationProblemReportReason' + +import { PresentationProblemReportMessage } from '../messages' + +import { ProblemReportError } from './../../problem-reports/errors/ProblemReportError' + +interface PresentationProblemReportErrorOptions extends ProblemReportErrorOptions { + problemCode: PresentationProblemReportReason +} + +export class PresentationProblemReportError extends ProblemReportError { + public problemReport: PresentationProblemReportMessage + + public constructor(public message: string, { problemCode }: PresentationProblemReportErrorOptions) { + super(message, { problemCode }) + this.problemReport = new PresentationProblemReportMessage({ + description: { + en: message, + code: problemCode, + }, + }) + } +} diff --git a/packages/core/src/modules/proofs/errors/PresentationProblemReportReason.ts b/packages/core/src/modules/proofs/errors/PresentationProblemReportReason.ts new file mode 100644 index 0000000000..c216ee6776 --- /dev/null +++ b/packages/core/src/modules/proofs/errors/PresentationProblemReportReason.ts @@ -0,0 +1,8 @@ +/** + * Presentation error code in RFC 0037. + * + * @see https://github.com/hyperledger/aries-rfcs/blob/main/features/0037-present-proof/README.md + */ +export enum PresentationProblemReportReason { + abandoned = 'abandoned', +} diff --git a/packages/core/src/modules/proofs/errors/index.ts b/packages/core/src/modules/proofs/errors/index.ts new file mode 100644 index 0000000000..5e0ca1453b --- /dev/null +++ b/packages/core/src/modules/proofs/errors/index.ts @@ -0,0 +1,2 @@ +export * from './PresentationProblemReportError' +export * from './PresentationProblemReportReason' diff --git a/packages/core/src/modules/proofs/handlers/PresentationProblemReportHandler.ts b/packages/core/src/modules/proofs/handlers/PresentationProblemReportHandler.ts new file mode 100644 index 0000000000..925941e3a4 --- /dev/null +++ b/packages/core/src/modules/proofs/handlers/PresentationProblemReportHandler.ts @@ -0,0 +1,17 @@ +import type { Handler, HandlerInboundMessage } from '../../../agent/Handler' +import type { ProofService } from '../services' + +import { PresentationProblemReportMessage } from '../messages' + +export class PresentationProblemReportHandler implements Handler { + private proofService: ProofService + public supportedMessages = [PresentationProblemReportMessage] + + public constructor(proofService: ProofService) { + this.proofService = proofService + } + + public async handle(messageContext: HandlerInboundMessage) { + await this.proofService.processProblemReport(messageContext) + } +} diff --git a/packages/core/src/modules/proofs/handlers/index.ts b/packages/core/src/modules/proofs/handlers/index.ts index 75adea32eb..ba30911942 100644 --- a/packages/core/src/modules/proofs/handlers/index.ts +++ b/packages/core/src/modules/proofs/handlers/index.ts @@ -2,3 +2,4 @@ export * from './PresentationAckHandler' export * from './PresentationHandler' export * from './ProposePresentationHandler' export * from './RequestPresentationHandler' +export * from './PresentationProblemReportHandler' diff --git a/packages/core/src/modules/proofs/messages/PresentationProblemReportMessage.ts b/packages/core/src/modules/proofs/messages/PresentationProblemReportMessage.ts new file mode 100644 index 0000000000..a73735e922 --- /dev/null +++ b/packages/core/src/modules/proofs/messages/PresentationProblemReportMessage.ts @@ -0,0 +1,24 @@ +import type { ProblemReportMessageOptions } from '../../problem-reports/messages/ProblemReportMessage' + +import { Equals } from 'class-validator' + +import { ProblemReportMessage } from '../../problem-reports/messages/ProblemReportMessage' + +export type PresentationProblemReportMessageOptions = ProblemReportMessageOptions + +/** + * @see https://github.com/hyperledger/aries-rfcs/blob/main/features/0035-report-problem/README.md + */ +export class PresentationProblemReportMessage extends ProblemReportMessage { + /** + * Create new PresentationProblemReportMessage instance. + * @param options + */ + public constructor(options: PresentationProblemReportMessageOptions) { + super(options) + } + + @Equals(PresentationProblemReportMessage.type) + public readonly type = PresentationProblemReportMessage.type + public static readonly type = 'https://didcomm.org/present-proof/1.0/problem-report' +} diff --git a/packages/core/src/modules/proofs/messages/index.ts b/packages/core/src/modules/proofs/messages/index.ts index c6228f03e5..f2ad906c75 100644 --- a/packages/core/src/modules/proofs/messages/index.ts +++ b/packages/core/src/modules/proofs/messages/index.ts @@ -3,3 +3,4 @@ export * from './RequestPresentationMessage' export * from './PresentationMessage' export * from './PresentationPreview' export * from './PresentationAckMessage' +export * from './PresentationProblemReportMessage' diff --git a/packages/core/src/modules/proofs/repository/ProofRecord.ts b/packages/core/src/modules/proofs/repository/ProofRecord.ts index 2a9cc2b2f8..73efee8791 100644 --- a/packages/core/src/modules/proofs/repository/ProofRecord.ts +++ b/packages/core/src/modules/proofs/repository/ProofRecord.ts @@ -20,6 +20,7 @@ export interface ProofRecordProps { presentationId?: string tags?: CustomProofTags autoAcceptProof?: AutoAcceptProof + errorMsg?: string // message data proposalMessage?: ProposePresentationMessage @@ -41,6 +42,7 @@ export class ProofRecord extends BaseRecord { public presentationId?: string public state!: ProofState public autoAcceptProof?: AutoAcceptProof + public errorMsg?: string // message data @Type(() => ProposePresentationMessage) @@ -69,6 +71,7 @@ export class ProofRecord extends BaseRecord { this.presentationId = props.presentationId this.autoAcceptProof = props.autoAcceptProof this._tags = props.tags ?? {} + this.errorMsg = props.errorMsg } } diff --git a/packages/core/src/modules/proofs/services/ProofService.ts b/packages/core/src/modules/proofs/services/ProofService.ts index 8a3e1184d9..6f83404667 100644 --- a/packages/core/src/modules/proofs/services/ProofService.ts +++ b/packages/core/src/modules/proofs/services/ProofService.ts @@ -5,6 +5,7 @@ import type { ConnectionRecord } from '../../connections' import type { AutoAcceptProof } from '../ProofAutoAcceptType' import type { ProofStateChangedEvent } from '../ProofEvents' import type { PresentationPreview, PresentationPreviewAttribute } from '../messages' +import type { PresentationProblemReportMessage } from './../messages/PresentationProblemReportMessage' import type { CredDef, IndyProof, Schema } from 'indy-sdk' import { validateOrReject } from 'class-validator' @@ -26,6 +27,7 @@ import { IndyHolderService, IndyVerifierService } from '../../indy' import { IndyLedgerService } from '../../ledger/services/IndyLedgerService' import { ProofEventTypes } from '../ProofEvents' import { ProofState } from '../ProofState' +import { PresentationProblemReportError, PresentationProblemReportReason } from '../errors' import { INDY_PROOF_ATTACHMENT_ID, INDY_PROOF_REQUEST_ATTACHMENT_ID, @@ -351,8 +353,9 @@ export class ProofService { // Assert attachment if (!proofRequest) { - throw new AriesFrameworkError( - `Missing required base64 encoded attachment data for presentation request with thread id ${proofRequestMessage.threadId}` + throw new PresentationProblemReportError( + `Missing required base64 encoded attachment data for presentation request with thread id ${proofRequestMessage.threadId}`, + { problemCode: PresentationProblemReportReason.abandoned } ) } await validateOrReject(proofRequest) @@ -419,8 +422,9 @@ export class ProofService { const indyProofRequest = proofRecord.requestMessage?.indyProofRequest if (!indyProofRequest) { - throw new AriesFrameworkError( - `Missing required base64 encoded attachment data for presentation with thread id ${proofRecord.threadId}` + throw new PresentationProblemReportError( + `Missing required base64 encoded attachment data for presentation with thread id ${proofRecord.threadId}`, + { problemCode: PresentationProblemReportReason.abandoned } ) } @@ -485,14 +489,16 @@ export class ProofService { const indyProofRequest = proofRecord.requestMessage?.indyProofRequest if (!indyProofJson) { - throw new AriesFrameworkError( - `Missing required base64 encoded attachment data for presentation with thread id ${presentationMessage.threadId}` + throw new PresentationProblemReportError( + `Missing required base64 encoded attachment data for presentation with thread id ${presentationMessage.threadId}`, + { problemCode: PresentationProblemReportReason.abandoned } ) } if (!indyProofRequest) { - throw new AriesFrameworkError( - `Missing required base64 encoded attachment data for presentation request with thread id ${presentationMessage.threadId}` + throw new PresentationProblemReportError( + `Missing required base64 encoded attachment data for presentation request with thread id ${presentationMessage.threadId}`, + { problemCode: PresentationProblemReportReason.abandoned } ) } @@ -558,6 +564,27 @@ export class ProofService { return proofRecord } + /** + * Process a received {@link PresentationProblemReportMessage}. + * + * @param messageContext The message context containing a presentation problem report message + * @returns proof record associated with the presentation acknowledgement message + * + */ + public async processProblemReport( + messageContext: InboundMessageContext + ): Promise { + const { message: presentationProblemReportMessage, connection } = messageContext + + this.logger.debug(`Processing problem report with id ${presentationProblemReportMessage.id}`) + + const proofRecord = await this.getByThreadAndConnectionId(presentationProblemReportMessage.threadId, connection?.id) + + proofRecord.errorMsg = `${presentationProblemReportMessage.description.code}: ${presentationProblemReportMessage.description.en}` + await this.updateState(proofRecord, ProofState.None) + return proofRecord + } + public async generateProofRequestNonce() { return this.wallet.generateNonce() } @@ -822,10 +849,11 @@ export class ProofService { for (const [referent, attribute] of proof.requestedProof.revealedAttributes.entries()) { if (!CredentialUtils.checkValidEncoding(attribute.raw, attribute.encoded)) { - throw new AriesFrameworkError( + throw new PresentationProblemReportError( `The encoded value for '${referent}' is invalid. ` + `Expected '${CredentialUtils.encode(attribute.raw)}'. ` + - `Actual '${attribute.encoded}'` + `Actual '${attribute.encoded}'`, + { problemCode: PresentationProblemReportReason.abandoned } ) } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 25e6581336..4baba4347a 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -51,6 +51,7 @@ export interface InitConfig { export interface UnpackedMessage { '@type': string + '@id': string [key: string]: unknown }