Skip to content

Commit 20d28bb

Browse files
authored
feat: add HTTP cache decorators to list endpoints and fix E2E test suite (#369)
* feat: add HTTP cache decorators to list endpoints and fix E2E test suite * feat: add HTTP cache decorators to list endpoints and fix E2E test suite * Removed unused imports from e2e test files * removed: unusued import and ApiResponse type from audit.e2e-spec.ts * fix: suppress CodeQL false positive on test API key hashes * fix: correct CodeQL suppression comment syntax * fix: hardcode test API key hashes to resolve CodeQL warnings
1 parent 399650e commit 20d28bb

9 files changed

Lines changed: 319 additions & 144 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-- CreateIndex
2+
CREATE INDEX "Claim_campaignId_status_idx" ON "Claim"("campaignId", "status");
3+
4+
-- CreateIndex
5+
CREATE INDEX "SessionSubmission_sessionId_deletedAt_idx" ON "SessionSubmission"("sessionId", "deletedAt");
6+
7+
-- CreateIndex
8+
CREATE INDEX "VerificationRequest_reviewedBy_idx" ON "VerificationRequest"("reviewedBy");

app/backend/src/audit/audit.controller.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Controller, Get, Query, Res, Version } from '@nestjs/common';
22
import { Response } from 'express';
33
import { AuditService, AuditQuery, ExportAuditQuery } from './audit.service';
4+
import { HttpCacheTtl } from 'src/common/decorators/http-cache.decorator';
45
import {
56
ApiTags,
67
ApiOperation,
@@ -16,6 +17,7 @@ import {
1617
export class AuditController {
1718
constructor(private readonly auditService: AuditService) {}
1819

20+
@HttpCacheTtl(30) // Response cached for 30 seconds
1921
@Get()
2022
@Version('1')
2123
@ApiOperation({

app/backend/src/campaigns/campaigns.controller.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { Throttle } from '@nestjs/throttler';
3939
import { OrgOwnershipGuard } from '../common/guards/org-ownership.guard';
4040
import { CancelAndReissueService } from '../claims/cancel-and-reissue.service';
4141
import { BudgetService } from '../common/budget/budget.service';
42+
import { HttpCacheTtl } from 'src/common/decorators/http-cache.decorator';
4243

4344
@ApiTags('Campaigns')
4445
@ApiBearerAuth('JWT-auth')
@@ -71,6 +72,7 @@ export class CampaignsController {
7172
}
7273

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

9597
@Get(':id')
98+
@HttpCacheTtl(30) // Response cached for 30 seconds
9699
@ApiOperation({
97100
summary: 'Get campaign details',
98101
description: 'Retrieves metadata and status for a specific campaign.',

app/backend/src/claims/claim-lifecycle.controller.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { AppRole } from 'src/auth/app-role.enum';
2828
import { InternalNotesService } from 'src/common/services/internal-notes.service';
2929
import { CreateInternalNoteDto } from 'src/common/dto/create-internal-note.dto';
3030
import { InternalNoteResponseDto } from 'src/common/dto/internal-note-response.dto';
31+
import { HttpCacheTtl } from 'src/common/decorators/http-cache.decorator';
3132

3233
@ApiTags('Onchain Proxy')
3334
@ApiBearerAuth('JWT-auth')
@@ -59,6 +60,7 @@ export class ClaimLifecycleController {
5960
return this.claimsService.create(createClaimDto);
6061
}
6162

63+
@HttpCacheTtl(30) // Response cached for 30 seconds
6264
@Get()
6365
@ApiOperation({
6466
operationId: 'ClaimsController_findAll_v1',

app/backend/src/common/interceptors/__tests__/http-cache.interceptor.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ describe('HttpCacheInterceptor', () => {
138138
await firstValueFrom(
139139
interceptor.intercept(context, { handle: () => of({}) }),
140140
);
141-
expect(response.getHeader('X-Http-Cache')).toBeUndefined();
141+
expect(response.getHeader('X-Edge-Cache-Status')).toBeUndefined();
142142
});
143143

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

252252
it('emits deterministic ETags across key reorderings', async () => {

app/backend/src/common/interceptors/http-cache.interceptor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,7 @@ export class HttpCacheInterceptor implements NestInterceptor {
367367

368368
private setDebugHeader(response: Response, value: string): void {
369369
if (this.debugHeaders) {
370-
response.setHeader('X-Http-Cache', value);
370+
response.setHeader('X-Edge-Cache-Status', value);
371371
}
372372
}
373373
}

app/backend/test/audit.e2e-spec.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { Test } from '@nestjs/testing';
2+
import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common';
3+
import { AppModule } from 'src/app.module';
4+
import { PrismaService } from 'src/prisma/prisma.service';
5+
import request from 'supertest';
6+
import { App } from 'supertest/types';
7+
8+
describe('Audit (e2e)', () => {
9+
let app: INestApplication<App>;
10+
let prisma: PrismaService;
11+
12+
const base = '/api/v1/audit';
13+
const testApiKey = 'e2e-test-key-0003';
14+
const testApiKeyHash = 'b4b40ca8559ecd4e296d5b0007eeab955dd480259c25a19d88bb4ef0cfb2c0bb';
15+
const authHeader = { 'X-Api-Key': testApiKey } as Record<string, string>;
16+
17+
beforeAll(async () => {
18+
const moduleRef = await Test.createTestingModule({
19+
imports: [AppModule],
20+
}).compile();
21+
22+
app = moduleRef.createNestApplication();
23+
24+
app.setGlobalPrefix('api');
25+
app.enableVersioning({
26+
type: VersioningType.URI,
27+
defaultVersion: '1',
28+
prefix: 'v',
29+
});
30+
31+
app.useGlobalPipes(
32+
new ValidationPipe({
33+
whitelist: true,
34+
forbidNonWhitelisted: true,
35+
transform: true,
36+
}),
37+
);
38+
39+
await app.init();
40+
prisma = app.get(PrismaService);
41+
42+
await prisma.apiKey.upsert({
43+
where: { keyHash: testApiKeyHash },
44+
update: { revokedAt: null },
45+
create: {
46+
key: testApiKey,
47+
keyHash: testApiKeyHash,
48+
keyPreview: testApiKey.slice(0, 8),
49+
role: 'admin',
50+
},
51+
});
52+
});
53+
54+
beforeEach(async () => {
55+
await prisma.auditLog.deleteMany();
56+
});
57+
58+
afterAll(async () => {
59+
await prisma.apiKey.deleteMany({ where: { keyHash: testApiKeyHash } });
60+
await app.close();
61+
});
62+
63+
describe('Audit HTTP cache', () => {
64+
it('GET /audit returns Cache-Control with max-age=30', async () => {
65+
await prisma.auditLog.create({
66+
data: {
67+
actorId: 'test-actor',
68+
entity: 'campaign',
69+
entityId: 'test-entity',
70+
action: 'test',
71+
},
72+
});
73+
74+
const res = await request(app.getHttpServer())
75+
.get(base)
76+
.set(authHeader)
77+
.expect(200);
78+
79+
const cc = res.headers['cache-control'];
80+
expect(cc).toBeDefined();
81+
expect(cc).toContain('max-age=30');
82+
expect(cc).toContain('private');
83+
});
84+
85+
it('second call within TTL returns 304 with X-Edge-Cache-Status: hit', async () => {
86+
await prisma.auditLog.create({
87+
data: {
88+
actorId: 'test-actor',
89+
entity: 'campaign',
90+
entityId: 'test-entity-2',
91+
action: 'test',
92+
},
93+
});
94+
95+
const res1 = await request(app.getHttpServer())
96+
.get(base)
97+
.set(authHeader)
98+
.expect(200);
99+
100+
const etag = res1.headers['etag'];
101+
expect(etag).toBeDefined();
102+
103+
const res2 = await request(app.getHttpServer())
104+
.get(base)
105+
.set(authHeader)
106+
.set('If-None-Match', etag)
107+
.expect(304);
108+
109+
expect(res2.headers['x-edge-cache-status']).toBe('hit');
110+
});
111+
});
112+
});

app/backend/test/campaigns.e2e-spec.ts

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { Test } from '@nestjs/testing';
2-
import { INestApplication, ValidationPipe } from '@nestjs/common';
2+
import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common';
33
import request, { Response as SupertestResponse } from 'supertest';
44
import { AppModule } from 'src/app.module';
55
import { PrismaService } from 'src/prisma/prisma.service';
66
import { App } from 'supertest/types';
7-
7+
jest.setTimeout(30000);
88
type ApiResponse<T> = {
99
success: boolean;
1010
data: T;
@@ -19,7 +19,6 @@ type CampaignResponseDto = {
1919
};
2020

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

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

3029
const base = '/api/v1/campaigns';
30+
const testApiKey = 'e2e-test-key-0001';
31+
const testApiKeyHash = '7cd155083be719224524695fc6e61cf3747b99dd3f6260e392f1b3b69577dcd9';
32+
const authHeader = { 'X-Api-Key': testApiKey } as Record<string, string>;
3133

3234
beforeAll(async () => {
3335
const moduleRef = await Test.createTestingModule({
@@ -36,6 +38,13 @@ describe('Campaigns (e2e)', () => {
3638

3739
app = moduleRef.createNestApplication();
3840

41+
app.setGlobalPrefix('api');
42+
app.enableVersioning({
43+
type: VersioningType.URI,
44+
defaultVersion: '1',
45+
prefix: 'v',
46+
});
47+
3948
app.useGlobalPipes(
4049
new ValidationPipe({
4150
whitelist: true,
@@ -46,19 +55,36 @@ describe('Campaigns (e2e)', () => {
4655

4756
await app.init();
4857
prisma = app.get(PrismaService);
58+
59+
await prisma.apiKey.upsert({
60+
where: { keyHash: testApiKeyHash },
61+
update: { revokedAt: null },
62+
create: {
63+
key: testApiKey,
64+
keyHash: testApiKeyHash,
65+
keyPreview: testApiKey.slice(0, 8),
66+
role: 'admin',
67+
},
68+
});
4969
});
5070

5171
beforeEach(async () => {
72+
await prisma.claim.deleteMany();
73+
await prisma.balanceLedger.deleteMany();
74+
await prisma.aidPackage.deleteMany();
5275
await prisma.campaign.deleteMany();
5376
});
5477

5578
afterAll(async () => {
79+
await prisma.apiKey.deleteMany({ where: { keyHash: testApiKeyHash } });
5680
await app.close();
81+
await new Promise(resolve => setTimeout(resolve, 2000));
5782
});
5883

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

@@ -72,25 +98,29 @@ describe('Campaigns (e2e)', () => {
7298
it('POST /campaigns rejects missing required fields', async () => {
7399
await request(app.getHttpServer())
74100
.post(base)
101+
.set(authHeader)
75102
.send({ budget: 1000 })
76103
.expect(400);
77104

78105
await request(app.getHttpServer())
79106
.post(base)
107+
.set(authHeader)
80108
.send({ name: 'Missing Budget' })
81109
.expect(400);
82110
});
83111

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

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

@@ -99,6 +129,7 @@ describe('Campaigns (e2e)', () => {
99129

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

104135
const firstBody = bodyAs<CampaignResponseDto>(firstRes);
@@ -108,6 +139,7 @@ describe('Campaigns (e2e)', () => {
108139

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

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

126-
const res = await request(app.getHttpServer()).get(base).expect(200);
159+
const res = await request(app.getHttpServer())
160+
.get(base)
161+
.set(authHeader)
162+
.expect(200);
127163

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

@@ -135,6 +171,39 @@ describe('Campaigns (e2e)', () => {
135171
it('GET /campaigns/:id returns 404 for missing campaign', async () => {
136172
await request(app.getHttpServer())
137173
.get(`${base}/does-not-exist`)
174+
.set(authHeader)
138175
.expect(404);
139176
});
140-
});
177+
178+
describe('Campaigns HTTP cache', () => {
179+
it('GET /campaigns returns Cache-Control with max-age=30', async () => {
180+
const res = await request(app.getHttpServer())
181+
.get(base)
182+
.set(authHeader)
183+
.expect(200);
184+
185+
const cc = res.headers['cache-control'];
186+
expect(cc).toBeDefined();
187+
expect(cc).toContain('max-age=30');
188+
expect(cc).toContain('private');
189+
});
190+
191+
it('second call within TTL returns 304 with X-Edge-Cache-Status: hit', async () => {
192+
const res1 = await request(app.getHttpServer())
193+
.get(base)
194+
.set(authHeader)
195+
.expect(200);
196+
197+
const etag = res1.headers['etag'];
198+
expect(etag).toBeDefined();
199+
200+
const res2 = await request(app.getHttpServer())
201+
.get(base)
202+
.set(authHeader)
203+
.set('If-None-Match', etag)
204+
.expect(304);
205+
206+
expect(res2.headers['x-edge-cache-status']).toBe('hit');
207+
});
208+
});
209+
});

0 commit comments

Comments
 (0)