From 0ec847e829c781c4aabc6646165bce56910cd222 Mon Sep 17 00:00:00 2001 From: Adeolu01 Date: Thu, 18 Jun 2026 14:08:40 -0700 Subject: [PATCH 1/3] fix: enforce campaign update ownership and audit trail --- src/campaigns/campaigns.controller.ts | 4 +- src/campaigns/campaigns.service.spec.ts | 105 ++++++++++++++++++++++++ src/campaigns/campaigns.service.ts | 32 +++++++- 3 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 src/campaigns/campaigns.service.spec.ts 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; } /** From d290a07a6c6f08ee79ee0e98311637108e9ba70c Mon Sep 17 00:00:00 2001 From: Adeolu01 <191980889+Adeolu01@users.noreply.github.com> Date: Wed, 1 Jul 2026 08:21:41 +0100 Subject: [PATCH 2/3] ci: remove duplicate env key in e2e job The Jest e2e step declared two env mappings, which is invalid workflow syntax and caused GitHub Actions to reject the whole file so no jobs ran. Merge them into a single env block that points at the postgres and redis service containers and keeps the PORT value. --- .github/workflows/ci.yml | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14277bc..f41971a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,20 +135,15 @@ jobs: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/orbitchain?schema=public - name: Jest e2e - # E2e tests need environment variables for Prisma and other services - # Using placeholder values for CI - tests should mock external dependencies - env: - DATABASE_URL: postgresql://test:test@localhost:5432/orbitchain_test - REDIS_URL: redis://localhost:6379 - JWT_SECRET: test-secret-for-ci - NODE_ENV: test - PORT: 3001 + # E2e tests need environment variables for Prisma and other services. + # These point at the postgres/redis service containers defined above. run: npm run test:e2e env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/orbitchain?schema=public REDIS_URL: redis://localhost:6379 JWT_SECRET: test-secret NODE_ENV: test + PORT: 3001 prisma-validate: name: Prisma validate (optional) From f0428b97c726298dfd38f6960b6fe8f01033b9f9 Mon Sep 17 00:00:00 2001 From: Adeolu01 <191980889+Adeolu01@users.noreply.github.com> Date: Wed, 1 Jul 2026 08:38:58 +0100 Subject: [PATCH 3/3] ci: force jest to exit after e2e run completes The e2e suite passes, but the booted Nest app holds open handles (Redis, Bull queues, schedulers) so jest never exits on its own. The job then hung until the 15-minute timeout and was reported as cancelled. Enable forceExit in the e2e jest config so the process ends once the tests finish. --- test/jest-e2e.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/jest-e2e.json b/test/jest-e2e.json index e9d912f..8e224df 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -5,5 +5,6 @@ "testRegex": ".e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" - } + }, + "forceExit": true }