Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- CreateIndex
CREATE INDEX "Claim_campaignId_status_idx" ON "Claim"("campaignId", "status");

-- CreateIndex
CREATE INDEX "SessionSubmission_sessionId_deletedAt_idx" ON "SessionSubmission"("sessionId", "deletedAt");

-- CreateIndex
CREATE INDEX "VerificationRequest_reviewedBy_idx" ON "VerificationRequest"("reviewedBy");
2 changes: 2 additions & 0 deletions app/backend/src/audit/audit.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Controller, Get, Query, Res, Version } from '@nestjs/common';
import { Response } from 'express';
import { AuditService, AuditQuery, ExportAuditQuery } from './audit.service';
import { HttpCacheTtl } from 'src/common/decorators/http-cache.decorator';
import {
ApiTags,
ApiOperation,
Expand All @@ -16,6 +17,7 @@ import {
export class AuditController {
constructor(private readonly auditService: AuditService) {}

@HttpCacheTtl(30) // Response cached for 30 seconds
@Get()
@Version('1')
@ApiOperation({
Expand Down
3 changes: 3 additions & 0 deletions app/backend/src/campaigns/campaigns.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { Throttle } from '@nestjs/throttler';
import { OrgOwnershipGuard } from '../common/guards/org-ownership.guard';
import { CancelAndReissueService } from '../claims/cancel-and-reissue.service';
import { BudgetService } from '../common/budget/budget.service';
import { HttpCacheTtl } from 'src/common/decorators/http-cache.decorator';

@ApiTags('Campaigns')
@ApiBearerAuth('JWT-auth')
Expand Down Expand Up @@ -71,6 +72,7 @@ export class CampaignsController {
}

@Throttle({ default: { ttl: 60000, limit: 10 } }) // Limit to 10 requests per minute for this endpoint
@HttpCacheTtl(30) // Response cached for 30 seconds
@Get()
@ApiOperation({
summary: 'List all campaigns',
Expand All @@ -93,6 +95,7 @@ export class CampaignsController {
}

@Get(':id')
@HttpCacheTtl(30) // Response cached for 30 seconds
@ApiOperation({
summary: 'Get campaign details',
description: 'Retrieves metadata and status for a specific campaign.',
Expand Down
2 changes: 2 additions & 0 deletions app/backend/src/claims/claims.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { AppRole } from 'src/auth/app-role.enum';
import { InternalNotesService } from 'src/common/services/internal-notes.service';
import { CreateInternalNoteDto } from 'src/common/dto/create-internal-note.dto';
import { InternalNoteResponseDto } from 'src/common/dto/internal-note-response.dto';
import { HttpCacheTtl } from 'src/common/decorators/http-cache.decorator';

@ApiTags('Onchain Proxy')
@ApiBearerAuth('JWT-auth')
Expand Down Expand Up @@ -70,6 +71,7 @@ export class ClaimsController {
return this.claimsService.create(createClaimDto);
}

@HttpCacheTtl(30) // Response cached for 30 seconds
@Get()
@ApiOperation({
summary: 'List all claims',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ describe('HttpCacheInterceptor', () => {
await firstValueFrom(
interceptor.intercept(context, { handle: () => of({}) }),
);
expect(response.getHeader('X-Http-Cache')).toBeUndefined();
expect(response.getHeader('X-Edge-Cache-Status')).toBeUndefined();
});

it('still applies no-store on a path that is otherwise always-skipped', async () => {
Expand Down Expand Up @@ -246,7 +246,7 @@ describe('HttpCacheInterceptor', () => {
'Authorization, Accept-Encoding',
);
expect(response.getHeader('ETag')).toMatch(/^"[a-f0-9]{64}"$/);
expect(response.getHeader('X-Http-Cache')).toBe('miss');
expect(response.getHeader('X-Edge-Cache-Status')).toBe('miss');
});

it('emits deterministic ETags across key reorderings', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ export class HttpCacheInterceptor implements NestInterceptor {

private setDebugHeader(response: Response, value: string): void {
if (this.debugHeaders) {
response.setHeader('X-Http-Cache', value);
response.setHeader('X-Edge-Cache-Status', value);
}
}
}
Expand Down
115 changes: 115 additions & 0 deletions app/backend/test/audit.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common';
import { AppModule } from 'src/app.module';
import { PrismaService } from 'src/prisma/prisma.service';
import { App } from 'supertest/types';
import { createHash } from 'node:crypto';




describe('Audit (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;

const base = '/api/v1/audit';
const testApiKey = 'e2e-test-key-0003';
const testApiKeyHash = createHash('sha256').update(testApiKey).digest('hex');
const authHeader = { 'X-Api-Key': testApiKey } as Record<string, string>;

beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();

app = moduleRef.createNestApplication();

app.setGlobalPrefix('api');
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: '1',
prefix: 'v',
});

app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);

await app.init();
prisma = app.get(PrismaService);

await prisma.apiKey.upsert({
where: { keyHash: testApiKeyHash },
update: { revokedAt: null },
create: {
key: testApiKey,
keyHash: testApiKeyHash,
keyPreview: testApiKey.slice(0, 8),
role: 'admin',
},
});
});

beforeEach(async () => {
await prisma.auditLog.deleteMany();
});

afterAll(async () => {
await prisma.apiKey.deleteMany({ where: { keyHash: testApiKeyHash } });
await app.close();
});

describe('Audit HTTP cache', () => {
it('GET /audit returns Cache-Control with max-age=30', async () => {
await prisma.auditLog.create({
data: {
actorId: 'test-actor',
entity: 'campaign',
entityId: 'test-entity',
action: 'test',
},
});

const res = await request(app.getHttpServer())
.get(base)
.set(authHeader)
.expect(200);

const cc = res.headers['cache-control'];
expect(cc).toBeDefined();
expect(cc).toContain('max-age=30');
expect(cc).toContain('private');
});

it('second call within TTL returns 304 with X-Edge-Cache-Status: hit', async () => {
await prisma.auditLog.create({
data: {
actorId: 'test-actor',
entity: 'campaign',
entityId: 'test-entity-2',
action: 'test',
},
});

const res1 = await request(app.getHttpServer())
.get(base)
.set(authHeader)
.expect(200);

const etag = res1.headers['etag'];
expect(etag).toBeDefined();

const res2 = await request(app.getHttpServer())
.get(base)
.set(authHeader)
.set('If-None-Match', etag)
.expect(304);

expect(res2.headers['x-edge-cache-status']).toBe('hit');
});
});
});
80 changes: 75 additions & 5 deletions app/backend/test/campaigns.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common';
import request, { Response as SupertestResponse } from 'supertest';
import { AppModule } from 'src/app.module';
import { PrismaService } from 'src/prisma/prisma.service';
import { App } from 'supertest/types';

import { createHash } from 'node:crypto';
jest.setTimeout(30000);
type ApiResponse<T> = {
success: boolean;
data: T;
Expand All @@ -19,7 +20,6 @@ type CampaignResponseDto = {
};

function bodyAs<T>(res: SupertestResponse): ApiResponse<T> {
// supertest Response.body is `any`; we cast once here to satisfy strict ESLint rules
return res.body as ApiResponse<T>;
}

Expand All @@ -28,6 +28,9 @@ describe('Campaigns (e2e)', () => {
let prisma: PrismaService;

const base = '/api/v1/campaigns';
const testApiKey = 'e2e-test-key-0001';
const testApiKeyHash = createHash('sha256').update(testApiKey).digest('hex');
const authHeader = { 'X-Api-Key': testApiKey } as Record<string, string>;

beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
Expand All @@ -36,6 +39,13 @@ describe('Campaigns (e2e)', () => {

app = moduleRef.createNestApplication();

app.setGlobalPrefix('api');
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: '1',
prefix: 'v',
});

app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
Expand All @@ -46,19 +56,36 @@ describe('Campaigns (e2e)', () => {

await app.init();
prisma = app.get(PrismaService);

await prisma.apiKey.upsert({
where: { keyHash: testApiKeyHash },
update: { revokedAt: null },
create: {
key: testApiKey,
keyHash: testApiKeyHash,
keyPreview: testApiKey.slice(0, 8),
role: 'admin',
},
});
});

beforeEach(async () => {
await prisma.claim.deleteMany();
await prisma.balanceLedger.deleteMany();
await prisma.aidPackage.deleteMany();
await prisma.campaign.deleteMany();
});

afterAll(async () => {
await prisma.apiKey.deleteMany({ where: { keyHash: testApiKeyHash } });
await app.close();
await new Promise(resolve => setTimeout(resolve, 2000));
});

it('POST /campaigns creates a campaign', async () => {
const res = await request(app.getHttpServer())
.post(base)
.set(authHeader)
.send({ name: 'Test Campaign', budget: 1000 })
.expect(201);

Expand All @@ -72,25 +99,29 @@ describe('Campaigns (e2e)', () => {
it('POST /campaigns rejects missing required fields', async () => {
await request(app.getHttpServer())
.post(base)
.set(authHeader)
.send({ budget: 1000 })
.expect(400);

await request(app.getHttpServer())
.post(base)
.set(authHeader)
.send({ name: 'Missing Budget' })
.expect(400);
});

it('POST /campaigns rejects invalid budgets', async () => {
await request(app.getHttpServer())
.post(base)
.set(authHeader)
.send({ name: 'Bad Budget', budget: -1 })
.expect(400);
});

it('PATCH /campaigns/:id/archive is idempotent', async () => {
const createdRes = await request(app.getHttpServer())
.post(base)
.set(authHeader)
.send({ name: 'Archive Me', budget: 10 })
.expect(201);

Expand All @@ -99,6 +130,7 @@ describe('Campaigns (e2e)', () => {

const firstRes = await request(app.getHttpServer())
.patch(`${base}/${id}/archive`)
.set(authHeader)
.expect(200);

const firstBody = bodyAs<CampaignResponseDto>(firstRes);
Expand All @@ -108,6 +140,7 @@ describe('Campaigns (e2e)', () => {

const secondRes = await request(app.getHttpServer())
.patch(`${base}/${id}/archive`)
.set(authHeader)
.expect(200);

const secondBody = bodyAs<CampaignResponseDto>(secondRes);
Expand All @@ -120,10 +153,14 @@ describe('Campaigns (e2e)', () => {
it('GET /campaigns returns a list', async () => {
await request(app.getHttpServer())
.post(base)
.set(authHeader)
.send({ name: 'List Me', budget: 5 })
.expect(201);

const res = await request(app.getHttpServer()).get(base).expect(200);
const res = await request(app.getHttpServer())
.get(base)
.set(authHeader)
.expect(200);

const body = bodyAs<CampaignResponseDto[]>(res);

Expand All @@ -135,6 +172,39 @@ describe('Campaigns (e2e)', () => {
it('GET /campaigns/:id returns 404 for missing campaign', async () => {
await request(app.getHttpServer())
.get(`${base}/does-not-exist`)
.set(authHeader)
.expect(404);
});
});

describe('Campaigns HTTP cache', () => {
it('GET /campaigns returns Cache-Control with max-age=30', async () => {
const res = await request(app.getHttpServer())
.get(base)
.set(authHeader)
.expect(200);

const cc = res.headers['cache-control'];
expect(cc).toBeDefined();
expect(cc).toContain('max-age=30');
expect(cc).toContain('private');
});

it('second call within TTL returns 304 with X-Edge-Cache-Status: hit', async () => {
const res1 = await request(app.getHttpServer())
.get(base)
.set(authHeader)
.expect(200);

const etag = res1.headers['etag'];
expect(etag).toBeDefined();

const res2 = await request(app.getHttpServer())
.get(base)
.set(authHeader)
.set('If-None-Match', etag)
.expect(304);

expect(res2.headers['x-edge-cache-status']).toBe('hit');
});
});
});
Loading
Loading