Skip to content
Merged
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
13 changes: 5 additions & 8 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ import { messageQueue } from './services/queue.js';
import { registerDefaultProcessors } from './services/queue-producers.js';
import { slaTrackingMiddleware } from './middleware/slaTracking.js';
import { requestIdMiddleware, REQUEST_ID_HEADER } from './middleware/requestId.js';
import { traceMiddleware } from './middleware/trace.js';
import { cacheControlNoStore } from './middleware/cache-control.js';
import { httpLogger, correlationMiddleware } from './middleware/logger.js';
import { validateEnv, config as getConfig } from './config/env.js';
import { flagsRouter } from './routes/flags.js';
Expand Down Expand Up @@ -132,6 +134,7 @@ if (env.IP_ALLOWLIST_ENABLED || env.IP_ALLOWLIST) {
console.log(`[IP Allowlist] Enabled with ${allowedIps.length} IP(s)`);
}


const app = express();

// Security stack: headers, sanitization, payload limits
Expand Down Expand Up @@ -171,6 +174,7 @@ app.use(
);

app.use(requestIdMiddleware);
app.use(traceMiddleware);
app.use(correlationMiddleware);
app.use(httpLogger);

Expand All @@ -190,14 +194,7 @@ app.use(

app.use(slaTrackingMiddleware);
app.use(sessionMiddleware);

app.use((req: Request, res: Response, next: NextFunction) => {
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.setHeader('Cache-Control', 'no-store');
}
res.setHeader('Vary', 'Accept-Encoding');
next();
});
app.use(cacheControlNoStore);

app.use(healthRouter);
app.use('/docs', docsRouter);
Expand Down
99 changes: 99 additions & 0 deletions backend/src/middleware/__tests__/cache-control.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, it, expect, vi } from 'vitest';
import type { Request, Response } from 'express';
import { cacheControlNoStore, CACHE_NOSTORE_HEADER, VARY_HEADER } from '../cache-control.js';

function makeReq(overrides: Partial<Request> = {}): Request {
return { method: 'GET', ...overrides } as unknown as Request;
}

function makeRes(): { res: Response; headers: Record<string, string> } {
const headers: Record<string, string> = {};
const res = {
setHeader: vi.fn((name: string, value: string) => { headers[name] = value; }),
} as unknown as Response;
return { res, headers };
}

describe('cacheControlNoStore', () => {
it('sets Cache-Control: no-store for POST requests', () => {
const req = makeReq({ method: 'POST' });
const { res, headers } = makeRes();
const next = vi.fn();

cacheControlNoStore(req, res, next);

expect(headers[CACHE_NOSTORE_HEADER]).toBe('no-store');
expect(next).toHaveBeenCalledOnce();
});

it('sets Cache-Control: no-store for PUT requests', () => {
const req = makeReq({ method: 'PUT' });
const { res, headers } = makeRes();
const next = vi.fn();

cacheControlNoStore(req, res, next);

expect(headers[CACHE_NOSTORE_HEADER]).toBe('no-store');
});

it('sets Cache-Control: no-store for DELETE requests', () => {
const req = makeReq({ method: 'DELETE' });
const { res, headers } = makeRes();
const next = vi.fn();

cacheControlNoStore(req, res, next);

expect(headers[CACHE_NOSTORE_HEADER]).toBe('no-store');
});

it('does NOT set Cache-Control: no-store for GET requests', () => {
const req = makeReq({ method: 'GET' });
const { res, headers } = makeRes();
const next = vi.fn();

cacheControlNoStore(req, res, next);

expect(headers[CACHE_NOSTORE_HEADER]).toBeUndefined();
});

it('does NOT set Cache-Control: no-store for HEAD requests', () => {
const req = makeReq({ method: 'HEAD' });
const { res, headers } = makeRes();
const next = vi.fn();

cacheControlNoStore(req, res, next);

expect(headers[CACHE_NOSTORE_HEADER]).toBeUndefined();
});

it('always sets Vary: Accept-Encoding header', () => {
const req = makeReq({ method: 'GET' });
const { res, headers } = makeRes();
const next = vi.fn();

cacheControlNoStore(req, res, next);

expect(headers[VARY_HEADER]).toBe('Accept-Encoding');
});

it('sets both headers for mutations', () => {
const req = makeReq({ method: 'PATCH' });
const { res, headers } = makeRes();
const next = vi.fn();

cacheControlNoStore(req, res, next);

expect(headers[CACHE_NOSTORE_HEADER]).toBe('no-store');
expect(headers[VARY_HEADER]).toBe('Accept-Encoding');
});

it('calls next()', () => {
const req = makeReq();
const { res } = makeRes();
const next = vi.fn();

cacheControlNoStore(req, res, next);

expect(next).toHaveBeenCalledOnce();
});
});
139 changes: 139 additions & 0 deletions backend/src/middleware/__tests__/compose.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { describe, it, expect, vi } from 'vitest';
import type { Request, Response } from 'express';
import { composeMiddleware, createMiddlewareChain } from '../compose.js';

function makeReq(overrides: Partial<Request> = {}): Request {
return { method: 'GET', headers: {}, ...overrides } as unknown as Request;
}

function makeRes(): Response {
return { status: vi.fn().mockReturnThis(), json: vi.fn(), setHeader: vi.fn() } as unknown as Response;
}

describe('composeMiddleware', () => {
it('executes middleware in order', () => {
const order: number[] = [];
const mw1 = (_req: Request, _res: Response, next: any) => { order.push(1); next(); };
const mw2 = (_req: Request, _res: Response, next: any) => { order.push(2); next(); };
const mw3 = (_req: Request, _res: Response, next: any) => { order.push(3); next(); };

const composed = composeMiddleware(mw1, mw2, mw3);
const req = makeReq();
const res = makeRes();
const next = vi.fn();

composed(req, res, next);

expect(order).toEqual([1, 2, 3]);
expect(next).toHaveBeenCalledOnce();
});

it('handles async middleware', async () => {
const order: number[] = [];
const mw1 = async (_req: Request, _res: Response, next: any) => { order.push(1); next(); };
const mw2 = async (_req: Request, _res: Response, next: any) => { order.push(2); next(); };

const composed = composeMiddleware(mw1, mw2);
const req = makeReq();
const res = makeRes();
const next = vi.fn();

composed(req, res, next);

await new Promise(process.nextTick);
expect(order).toEqual([1, 2]);
});

it('passes errors to the outer next() callback', () => {
const testError = new Error('middleware error');
const mw1 = (_req: Request, _res: Response, next: any) => { next(testError); };

const composed = composeMiddleware(mw1);
const req = makeReq();
const res = makeRes();
const next = vi.fn();

composed(req, res, next);

expect(next).toHaveBeenCalledWith(testError);
});

it('catches thrown errors and forwards to next()', () => {
const mw1 = (_req: Request, _res: Response, _next: any) => { throw new Error('thrown error'); };

const composed = composeMiddleware(mw1);
const req = makeReq();
const res = makeRes();
const next = vi.fn();

composed(req, res, next);

expect(next).toHaveBeenCalledWith(expect.any(Error));
});

it('catches async rejections and forwards to next()', async () => {
const mw1 = async (_req: Request, _res: Response, _next: any) => {
throw new Error('async rejection');
};

const composed = composeMiddleware(mw1);
const req = makeReq();
const res = makeRes();
const next = vi.fn();

composed(req, res, next);

await new Promise(process.nextTick);
expect(next).toHaveBeenCalledWith(expect.any(Error));
});

it('calls next() when no middleware is provided', () => {
const composed = composeMiddleware();
const req = makeReq();
const res = makeRes();
const next = vi.fn();

composed(req, res, next);

expect(next).toHaveBeenCalledOnce();
});
});

describe('createMiddlewareChain', () => {
it('builds and executes a chain', () => {
const order: number[] = [];
const chain = createMiddlewareChain()
.use(
(_req: Request, _res: Response, next: any) => { order.push(1); next(); },
(_req: Request, _res: Response, next: any) => { order.push(2); next(); },
)
.use(
(_req: Request, _res: Response, next: any) => { order.push(3); next(); },
);

const req = makeReq();
const res = makeRes();
const next = vi.fn();

chain.execute(req, res, next);

expect(order).toEqual([1, 2, 3]);
expect(next).toHaveBeenCalledOnce();
});

it('supports chaining .use() multiple times on returned chain', () => {
const order: number[] = [];
const chain = createMiddlewareChain()
.use((_req, _res, next) => { order.push(1); next(); });

const chain2 = chain.use((_req, _res, next) => { order.push(2); next(); });

const req = makeReq();
const res = makeRes();
const next = vi.fn();

chain2.execute(req, res, next);

expect(order).toEqual([1, 2]);
});
});
Loading