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
4 changes: 3 additions & 1 deletion src/campaigns/campaigns.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
105 changes: 105 additions & 0 deletions src/campaigns/campaigns.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(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);
});
});
32 changes: 31 additions & 1 deletion src/campaigns/campaigns.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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,
Expand All @@ -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;
}

/**
Expand Down
Loading