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) diff --git a/src/campaigns/campaigns.controller.ts b/src/campaigns/campaigns.controller.ts index 7114490..a7c694d 100644 --- a/src/campaigns/campaigns.controller.ts +++ b/src/campaigns/campaigns.controller.ts @@ -89,7 +89,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 index 73184e8..685614d 100644 --- a/src/campaigns/campaigns.service.spec.ts +++ b/src/campaigns/campaigns.service.spec.ts @@ -1,5 +1,112 @@ -import { BadRequestException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { + BadRequestException, + 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); + }); +}); describe('CampaignsService milestone target validation', () => { const prisma = { diff --git a/src/campaigns/campaigns.service.ts b/src/campaigns/campaigns.service.ts index 861de84..2ae55a0 100644 --- a/src/campaigns/campaigns.service.ts +++ b/src/campaigns/campaigns.service.ts @@ -66,10 +66,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 }, @@ -79,7 +85,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, @@ -88,6 +98,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; } /** 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 }