diff --git a/src/campaigns/campaigns.controller.ts b/src/campaigns/campaigns.controller.ts index 00b02e0..02e8bf5 100644 --- a/src/campaigns/campaigns.controller.ts +++ b/src/campaigns/campaigns.controller.ts @@ -83,7 +83,9 @@ export class CampaignsController { ); } - return this.campaignsService.updateCampaign(req.user.id, id, body); + const userId = req.user?.sub as string; + const isAdmin = req.user?.role === 'ADMIN'; + return this.campaignsService.updateCampaign(userId, id, body, isAdmin); } @Get() diff --git a/src/campaigns/campaigns.service.spec.ts b/src/campaigns/campaigns.service.spec.ts new file mode 100644 index 0000000..fb07554 --- /dev/null +++ b/src/campaigns/campaigns.service.spec.ts @@ -0,0 +1,105 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { CampaignsService } from './campaigns.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { StellarTransactionsService } from '../stellar/stellar-transactions.service'; + +describe('CampaignsService.updateCampaign (access control)', () => { + let service: CampaignsService; + let prisma: { + campaign: { findUnique: jest.Mock; update: jest.Mock }; + auditLog: { create: jest.Mock }; + }; + + const OWNER_ID = 'wallet-b-user-id'; + const ATTACKER_ID = 'wallet-a-user-id'; + const CAMPAIGN_ID = '11111111-1111-1111-1111-111111111111'; + + const existingCampaign = { + id: CAMPAIGN_ID, + title: 'Original title', + description: 'Original description', + story: 'Original story', + imageUrl: 'https://cdn.example.com/original.png', + creatorId: OWNER_ID, + }; + + beforeEach(async () => { + prisma = { + campaign: { + findUnique: jest.fn().mockResolvedValue(existingCampaign), + update: jest + .fn() + .mockImplementation(({ data }) => ({ ...existingCampaign, ...data })), + }, + auditLog: { create: jest.fn().mockResolvedValue({}) }, + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CampaignsService, + { provide: PrismaService, useValue: prisma }, + { provide: StellarTransactionsService, useValue: {} }, + ], + }).compile(); + + service = module.get(CampaignsService); + }); + + it('throws NotFoundException when the campaign does not exist', async () => { + prisma.campaign.findUnique.mockResolvedValueOnce(null); + + await expect( + service.updateCampaign(OWNER_ID, CAMPAIGN_ID, { title: 'x' }), + ).rejects.toBeInstanceOf(NotFoundException); + expect(prisma.campaign.update).not.toHaveBeenCalled(); + }); + + it('rejects a non-owner, non-admin caller with 403 (IDOR regression)', async () => { + await expect( + service.updateCampaign(ATTACKER_ID, CAMPAIGN_ID, { title: 'Defaced' }), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(prisma.campaign.update).not.toHaveBeenCalled(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); + + it('rejects a non-owner attempting to inject an imageUrl with 403 (phishing regression)', async () => { + await expect( + service.updateCampaign(ATTACKER_ID, CAMPAIGN_ID, { + coverImageUrl: 'https://phishing.example.com/steal.png', + }), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(prisma.campaign.update).not.toHaveBeenCalled(); + }); + + it('allows the campaign owner to update and writes an audit log', async () => { + const result = await service.updateCampaign(OWNER_ID, CAMPAIGN_ID, { + title: 'Updated by owner', + }); + + expect(result.title).toBe('Updated by owner'); + expect(prisma.campaign.update).toHaveBeenCalledTimes(1); + expect(prisma.auditLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId: OWNER_ID, + action: 'CAMPAIGN_UPDATED', + resourceType: 'campaign', + resourceId: CAMPAIGN_ID, + }), + }), + ); + }); + + it('allows an admin override even when they are not the owner', async () => { + const result = await service.updateCampaign( + ATTACKER_ID, + CAMPAIGN_ID, + { title: 'Updated by admin' }, + true, + ); + + expect(result.title).toBe('Updated by admin'); + expect(prisma.campaign.update).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/campaigns/campaigns.service.ts b/src/campaigns/campaigns.service.ts index 473f8e7..51de190 100644 --- a/src/campaigns/campaigns.service.ts +++ b/src/campaigns/campaigns.service.ts @@ -62,10 +62,16 @@ export class CampaignsService { }); } + /** + * Update campaign metadata. Only the campaign creator or an admin may update. + * Enforces per-resource ownership to prevent IDOR (OWASP A01:2021) and writes + * an AuditLog row for every successful update. + */ async updateCampaign( userId: string, campaignId: string, dto: UpdateCampaignDto, + isAdmin = false, ) { const campaign = await this.prisma.campaign.findUnique({ where: { id: campaignId }, @@ -75,7 +81,11 @@ export class CampaignsService { throw new NotFoundException('Campaign not found'); } - return this.prisma.campaign.update({ + if (campaign.creatorId !== userId && !isAdmin) { + throw new ForbiddenException('Not authorized to update this campaign'); + } + + const updated = await this.prisma.campaign.update({ where: { id: campaignId }, data: { title: dto.title ?? campaign.title, @@ -84,6 +94,26 @@ export class CampaignsService { imageUrl: dto.coverImageUrl ?? campaign.imageUrl, }, }); + + await this.prisma.auditLog.create({ + data: { + userId, + action: 'CAMPAIGN_UPDATED', + resourceType: 'campaign', + resourceId: campaignId, + details: JSON.stringify({ + isAdmin, + changes: { + title: dto.title, + description: dto.description, + story: dto.story, + coverImageUrl: dto.coverImageUrl, + }, + }), + }, + }); + + return updated; } /**