Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 3 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion src/campaigns/campaigns.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
109 changes: 108 additions & 1 deletion src/campaigns/campaigns.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(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 = {
Expand Down
32 changes: 31 additions & 1 deletion src/campaigns/campaigns.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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,
Expand All @@ -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;
}

/**
Expand Down
3 changes: 2 additions & 1 deletion test/jest-e2e.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
},
"forceExit": true
}
Loading