diff --git a/backend/src/__snapshots__/openapi.snapshot.spec.ts.snap b/backend/src/__snapshots__/openapi.snapshot.spec.ts.snap index 428dd5c..c18584d 100644 --- a/backend/src/__snapshots__/openapi.snapshot.spec.ts.snap +++ b/backend/src/__snapshots__/openapi.snapshot.spec.ts.snap @@ -1974,7 +1974,7 @@ exports[`OpenAPI document matches the checked-in snapshot 1`] = ` }, "/escrows/{id}/dispute": { "post": { - "description": "Raises a dispute for an escrow. Triggers webhook events and Discord notifications to alert jurors.", + "description": "Raises a dispute for an active escrow as the authenticated depositor or beneficiary. Starts the dispute saga, which freezes the escrow and notifies jurors.", "operationId": "EscrowController_raiseDispute", "parameters": [ { @@ -2011,35 +2011,30 @@ exports[`OpenAPI document matches the checked-in snapshot 1`] = ` "content": { "application/json": { "schema": { - "properties": { - "disputeReason": { - "type": "string", - }, - "disputedAt": { - "format": "date-time", - "type": "string", - }, - "id": { - "type": "string", - }, - "status": { - "example": "disputed", - "type": "string", - }, - }, - "type": "object", + "$ref": "#/components/schemas/DisputeSagaResponseDto", }, }, }, - "description": "Dispute raised successfully. Discord notification sent if configured.", + "description": "Dispute saga started successfully.", }, "400": { - "description": "Escrow already disputed or released", + "description": "Escrow is not active", + }, + "401": { + "description": "Authentication required", + }, + "403": { + "description": "Only the depositor or beneficiary can raise a dispute", }, "404": { "description": "Escrow not found", }, }, + "security": [ + { + "JWT-auth": [], + }, + ], "summary": "Raise a dispute", "tags": [ "Escrow", diff --git a/backend/src/dispute/dispute-saga.service.spec.ts b/backend/src/dispute/dispute-saga.service.spec.ts index c6d0ad6..83a6735 100644 --- a/backend/src/dispute/dispute-saga.service.spec.ts +++ b/backend/src/dispute/dispute-saga.service.spec.ts @@ -1,5 +1,10 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { NotFoundException, BadRequestException, ConflictException } from '@nestjs/common'; +import { + NotFoundException, + BadRequestException, + ConflictException, + ForbiddenException, +} from '@nestjs/common'; import { DisputeSagaService } from './dispute-saga.service'; import { DisputeStep, DisputeVerdict } from './dispute.types'; import { EscrowService } from '../escrow/escrow.service'; @@ -130,6 +135,7 @@ describe('DisputeSagaService', () => { const firstSaga = await service.escalate('esc-001', ESCALATE_DTO); // Simulate completion firstSaga.currentStep = DisputeStep.COMPLETED; + escrow.status = 'active'; const secondSaga = await service.escalate('esc-001', ESCALATE_DTO); expect(secondSaga.sagaId).not.toBe(firstSaga.sagaId); @@ -144,6 +150,7 @@ describe('DisputeSagaService', () => { const firstSaga = await service.escalate('esc-001', ESCALATE_DTO); // Simulate failure firstSaga.currentStep = DisputeStep.FAILED; + escrow.status = 'active'; const secondSaga = await service.escalate('esc-001', ESCALATE_DTO); expect(secondSaga.sagaId).not.toBe(firstSaga.sagaId); @@ -186,6 +193,27 @@ describe('DisputeSagaService', () => { await expect(service.escalate('esc-001', ESCALATE_DTO)).rejects.toThrow(BadRequestException); }); + it.each(['pending', 'disputed', 'cancelled'] as const)( + 'rejects API escalation when escrow status is %s', + async status => { + escrowService.findById.mockResolvedValueOnce(makeEscrow({ status })); + await expect(service.escalate('esc-001', ESCALATE_DTO)).rejects.toThrow( + BadRequestException, + ); + expect(escrowService.raiseDispute).not.toHaveBeenCalled(); + }, + ); + + it('rejects an initiator who is not a party to the escrow', async () => { + await expect( + service.escalate('esc-001', { + ...ESCALATE_DTO, + initiator: 'GOTHERWALLETAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }), + ).rejects.toThrow(ForbiddenException); + expect(escrowService.raiseDispute).not.toHaveBeenCalled(); + }); + it('throws ConflictException when an active saga already exists', async () => { await service.escalate('esc-001', ESCALATE_DTO); // Reset the mock so raiseDispute doesn't double-throw diff --git a/backend/src/dispute/dispute-saga.service.ts b/backend/src/dispute/dispute-saga.service.ts index eb1d6a1..81203ef 100644 --- a/backend/src/dispute/dispute-saga.service.ts +++ b/backend/src/dispute/dispute-saga.service.ts @@ -186,6 +186,9 @@ export class DisputeSagaService implements OnModuleInit { if (escrow.status === 'released') { throw new BadRequestException('Cannot dispute a released escrow'); } + if (origin === 'api' && escrow.status !== 'active') { + throw new BadRequestException('Only active escrows can start a dispute'); + } // Verify that initiator is either depositor or beneficiary, unless this is a // chain-originated dispute with no reliable initiator (#463): the sentinel diff --git a/backend/src/dispute/dispute.module.ts b/backend/src/dispute/dispute.module.ts index 450e7f8..b6400e9 100644 --- a/backend/src/dispute/dispute.module.ts +++ b/backend/src/dispute/dispute.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { DisputeSagaService } from './dispute-saga.service'; import { DisputeSagaController } from './dispute-saga.controller'; import { EscrowModule } from '../escrow/escrow.module'; @@ -8,7 +8,13 @@ import { NotificationModule } from '../notification/notification.module'; import { MonitoringModule } from '../monitoring/monitoring.module'; @Module({ - imports: [EscrowModule, WebhookModule, ReputationModule, NotificationModule, MonitoringModule], + imports: [ + forwardRef(() => EscrowModule), + WebhookModule, + ReputationModule, + NotificationModule, + MonitoringModule, + ], controllers: [DisputeSagaController], providers: [DisputeSagaService], exports: [DisputeSagaService], diff --git a/backend/src/escrow/escrow.controller.spec.ts b/backend/src/escrow/escrow.controller.spec.ts index 78a7598..81470eb 100644 --- a/backend/src/escrow/escrow.controller.spec.ts +++ b/backend/src/escrow/escrow.controller.spec.ts @@ -1,13 +1,16 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { NotFoundException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; import { EscrowController } from './escrow.controller'; import { EscrowService, Escrow } from './escrow.service'; import { EscrowReleaseTransactionBuilderService } from '../escrow-write/escrow-release-transaction-builder.service'; +import { DisputeSagaService } from '../dispute/dispute-saga.service'; +import { JwtAuthGuard } from '../auth/auth.guard'; // ─── Fixtures ───────────────────────────────────────────────────────────────── const DEPOSITOR = 'GDEPOSITORAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; const BENEFICIARY = 'GBENEFICIARYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const OTHER_WALLET = 'GOTHERWALLETAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; const AMOUNT = '100'; function makeEscrow(overrides: Partial = {}): Escrow { @@ -40,6 +43,9 @@ function buildMocks() { }), }; + const disputeSagaService = { + escalate: jest.fn().mockResolvedValue({ sagaId: 'saga-001', escrowId: escrow.id }), + }; const txBuilderService = { buildRelease: jest.fn().mockResolvedValue({ @@ -54,6 +60,7 @@ function buildMocks() { return { escrow, escrowService, + disputeSagaService, txBuilderService, }; } @@ -76,6 +83,7 @@ describe('EscrowController', () => { provide: EscrowReleaseTransactionBuilderService, useValue: mocks.txBuilderService, }, + { provide: DisputeSagaService, useValue: mocks.disputeSagaService }, ], }).compile(); @@ -175,58 +183,74 @@ describe('EscrowController', () => { // ─── POST /escrows/:id/dispute ──────────────────────────────────────────── describe('raiseDispute()', () => { - it('calls EscrowService.raiseDispute(), dispatches webhook, sends Discord notification, and returns the escrow', async () => { - const disputed = makeEscrow({ - status: 'disputed', - disputeReason: 'Work not delivered', - disputedAt: new Date().toISOString(), - }); - mocks.escrowService.raiseDispute.mockResolvedValue(disputed); + it('requires JWT authentication', () => { + const guards = Reflect.getMetadata('__guards__', EscrowController.prototype.raiseDispute); + expect(guards).toContain(JwtAuthGuard); + }); - const result = await controller.raiseDispute('esc-001', { reason: 'Work not delivered' }); + it('delegates to the saga with the authenticated wallet, ignoring a caller-supplied initiator', async () => { + const saga = { sagaId: 'saga-001', escrowId: 'esc-001' }; + mocks.disputeSagaService.escalate.mockResolvedValue(saga); - expect(mocks.escrowService.raiseDispute).toHaveBeenCalledWith( + const result = await controller.raiseDispute( 'esc-001', - 'Work not delivered', + { reason: 'Work not delivered', initiator: OTHER_WALLET } as any, + { user: { address: DEPOSITOR, sub: DEPOSITOR } }, ); - expect(result).toEqual(disputed); + expect(mocks.disputeSagaService.escalate).toHaveBeenCalledWith('esc-001', { + initiator: DEPOSITOR, + reason: 'Work not delivered', + }); + expect(mocks.escrowService.raiseDispute).not.toHaveBeenCalled(); + expect(result).toEqual(saga); }); - it('works when no reason is provided in the dto', async () => { - mocks.escrowService.raiseDispute.mockResolvedValue(makeEscrow({ status: 'disputed' })); - - await controller.raiseDispute('esc-001', {}); + it('supplies a default reason when none is provided', async () => { + await controller.raiseDispute('esc-001', {}, { + user: { address: DEPOSITOR, sub: DEPOSITOR }, + }); - expect(mocks.escrowService.raiseDispute).toHaveBeenCalledWith('esc-001', undefined); + expect(mocks.disputeSagaService.escalate).toHaveBeenCalledWith('esc-001', { + initiator: DEPOSITOR, + reason: 'No reason provided', + }); }); - it('propagates BadRequestException when escrow is already released', async () => { - const { BadRequestException } = jest.requireActual('@nestjs/common'); - mocks.escrowService.raiseDispute.mockRejectedValue( - new BadRequestException('Cannot dispute a released escrow'), + it('propagates ForbiddenException for a caller who is not an escrow party', async () => { + mocks.disputeSagaService.escalate.mockRejectedValue( + new ForbiddenException('Only the depositor or beneficiary can escalate a dispute'), ); - await expect(controller.raiseDispute('esc-001', { reason: 'too late' })).rejects.toThrow( - BadRequestException, - ); + await expect( + controller.raiseDispute('esc-001', { reason: 'wrong party' }, { + user: { address: OTHER_WALLET, sub: OTHER_WALLET }, + }), + ).rejects.toThrow(ForbiddenException); + expect(mocks.disputeSagaService.escalate).toHaveBeenCalledWith('esc-001', { + initiator: OTHER_WALLET, + reason: 'wrong party', + }); }); - it('propagates BadRequestException when escrow is already disputed', async () => { - const { BadRequestException } = jest.requireActual('@nestjs/common'); - mocks.escrowService.raiseDispute.mockRejectedValue( - new BadRequestException('Escrow is already disputed'), + it('propagates BadRequestException when the escrow cannot start a dispute', async () => { + mocks.disputeSagaService.escalate.mockRejectedValue( + new BadRequestException('Only active escrows can start a dispute'), ); - await expect(controller.raiseDispute('esc-001', { reason: 'dupe' })).rejects.toThrow( + await expect(controller.raiseDispute('esc-001', { reason: 'not active' }, { + user: { address: DEPOSITOR, sub: DEPOSITOR }, + })).rejects.toThrow( BadRequestException, ); }); it('propagates exception when service throws', async () => { - mocks.escrowService.raiseDispute.mockRejectedValue(new Error('unexpected')); + mocks.disputeSagaService.escalate.mockRejectedValue(new Error('unexpected')); - await expect(controller.raiseDispute('esc-001', {})).rejects.toThrow(); + await expect(controller.raiseDispute('esc-001', {}, { + user: { address: DEPOSITOR, sub: DEPOSITOR }, + })).rejects.toThrow(); }); }); diff --git a/backend/src/escrow/escrow.controller.ts b/backend/src/escrow/escrow.controller.ts index 2494eda..3bd7044 100644 --- a/backend/src/escrow/escrow.controller.ts +++ b/backend/src/escrow/escrow.controller.ts @@ -9,10 +9,13 @@ import { Body, Param, Query, + Req, + UseGuards, } from '@nestjs/common'; import { ZodError } from 'zod'; import { ApiBody, + ApiBearerAuth, ApiHeader, ApiOperation, ApiParam, @@ -21,6 +24,9 @@ import { ApiTags, } from '@nestjs/swagger'; import { EscrowService } from './escrow.service'; +import { DisputeSagaService } from '../dispute/dispute-saga.service'; +import { DisputeSagaResponseDto, EscalateDisputeDto } from '../dispute/dispute.dto'; +import { JwtAuthGuard } from '../auth/auth.guard'; import { ReputationService } from '../reputation/reputation.service'; import { EscrowReleaseTransactionBuilderService } from '../escrow-write/escrow-release-transaction-builder.service'; import { BuildReleaseTransactionQueryDto } from '../escrow-write/escrow-write.dto'; @@ -36,12 +42,17 @@ import { // Reference to ensure ReleaseEscrowSchema is considered used (dead-code check requires import) void ReleaseEscrowSchema; +interface AuthenticatedRequest { + user: { address: string; sub: string }; +} + @ApiTags('Escrow') @Controller('escrows') export class EscrowController { constructor( private readonly escrowService: EscrowService, private readonly escrowReleaseTransactionBuilderService: EscrowReleaseTransactionBuilderService, + private readonly disputeSagaService: DisputeSagaService, ) {} @Post() @@ -312,11 +323,14 @@ export class EscrowController { } @Post(':id/dispute') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Raise a dispute', description: - 'Raises a dispute for an escrow. Triggers webhook events and Discord notifications to alert jurors.', + 'Raises a dispute for an active escrow as the authenticated depositor or beneficiary. ' + + 'Starts the dispute saga, which freezes the escrow and notifies jurors.', }) @ApiParam({ name: 'id', @@ -338,23 +352,23 @@ export class EscrowController { }) @ApiResponse({ status: 200, - description: 'Dispute raised successfully. Discord notification sent if configured.', - schema: { - type: 'object', - properties: { - id: { type: 'string' }, - status: { type: 'string', example: 'disputed' }, - disputeReason: { type: 'string' }, - disputedAt: { type: 'string', format: 'date-time' }, - }, - }, + description: 'Dispute saga started successfully.', + type: DisputeSagaResponseDto, }) - @ApiResponse({ status: 400, description: 'Escrow already disputed or released' }) + @ApiResponse({ status: 400, description: 'Escrow is not active' }) + @ApiResponse({ status: 401, description: 'Authentication required' }) + @ApiResponse({ status: 403, description: 'Only the depositor or beneficiary can raise a dispute' }) @ApiResponse({ status: 404, description: 'Escrow not found' }) - async raiseDispute(@Param('id') id: string, @Body() dto: RaiseDisputeDto) { + async raiseDispute( + @Param('id') id: string, + @Body() dto: RaiseDisputeDto, + @Req() req: AuthenticatedRequest, + ) { const validated = RaiseDisputeSchema.parse(dto); - const escrow = await this.escrowService.raiseDispute(id, validated.reason); - - return escrow; + const escalateDto: EscalateDisputeDto = { + initiator: req.user.address, + reason: validated.reason ?? 'No reason provided', + }; + return this.disputeSagaService.escalate(id, escalateDto); } } diff --git a/backend/src/escrow/escrow.module.ts b/backend/src/escrow/escrow.module.ts index 7603544..cae44c1 100644 --- a/backend/src/escrow/escrow.module.ts +++ b/backend/src/escrow/escrow.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { EscrowController } from './escrow.controller'; import { EscrowService } from './escrow.service'; import { EscrowEventsConsumer } from './escrow-events.consumer'; @@ -7,9 +7,17 @@ import { ReputationModule } from '../reputation/reputation.module'; import { EscrowWriteModule } from '../escrow-write/escrow-write.module'; import { MonitoringModule } from '../monitoring/monitoring.module'; import { OutboxModule } from '../outbox/outbox.module'; +import { DisputeModule } from '../dispute/dispute.module'; @Module({ - imports: [WebhookModule, ReputationModule, EscrowWriteModule, MonitoringModule, OutboxModule], + imports: [ + WebhookModule, + ReputationModule, + EscrowWriteModule, + MonitoringModule, + OutboxModule, + forwardRef(() => DisputeModule), + ], controllers: [EscrowController], providers: [EscrowService, EscrowEventsConsumer], exports: [EscrowService],