diff --git a/src/routes/feature-flags.test.ts b/src/routes/feature-flags.test.ts index 784aae7f..4d6170b3 100644 --- a/src/routes/feature-flags.test.ts +++ b/src/routes/feature-flags.test.ts @@ -1,8 +1,9 @@ import express from 'express'; import request from 'supertest'; import { createFeatureFlagsRouter, type FeatureFlagsRouterDeps } from './feature-flags.js'; -import { InMemoryRateLimiter } from '../middleware/rateLimit.js'; +import { createRateLimitMiddleware, InMemoryRateLimiter } from '../middleware/rateLimit.js'; import { requestIdMiddleware } from '../middleware/requestId.js'; +import { errorHandler } from '../middleware/errorHandler.js'; function buildApp(deps: FeatureFlagsRouterDeps = {}, windowMs = 60_000, maxRequests = 3) { const app = express(); @@ -11,8 +12,10 @@ function buildApp(deps: FeatureFlagsRouterDeps = {}, windowMs = 60_000, maxReque const limiter = deps.rateLimiter ?? new InMemoryRateLimiter(windowMs, maxRequests); app.use('/api/feature-flags', createFeatureFlagsRouter({ ...deps, + rateLimit: deps.rateLimit ?? createRateLimitMiddleware({ windowMs, maxRequests }, limiter), rateLimiter: limiter, })); + app.use(errorHandler); return { app, limiter }; } @@ -52,7 +55,6 @@ describe('GET /api/feature-flags', () => { expect(res.body.success).toBe(false); expect(res.body.error.code).toBe('TOO_MANY_REQUESTS'); expect(res.body.error.message).toBe('Too Many Requests'); - expect(res.body.error.details.retryAfterMs).toBeGreaterThan(0); expect(res.body.requestId).toBe('req-rate-limited'); expect(res.body.timestamp).toBeDefined(); }); @@ -73,7 +75,7 @@ describe('GET /api/feature-flags', () => { const limiter = new InMemoryRateLimiter(windowMs, 1); const { app } = buildApp({ rateLimiter: limiter }, windowMs, 1); - limiter.check('user:user-reset', 0); + limiter.check('user:user-reset'); const blocked = await request(app) .get('/api/feature-flags') @@ -110,4 +112,33 @@ describe('GET /api/feature-flags', () => { expect(typeof res.body.data.flags['sso-login']).toBe('boolean'); expect(typeof res.body.data.flags['dark-mode']).toBe('boolean'); }); + + it('returns a strong ETag and 304 for an unchanged conditional request', async () => { + const { app } = buildApp({ flags: { stable: true } }, 60_000, 10); + + const first = await request(app).get('/api/feature-flags').set('x-user-id', 'etag-user'); + expect(first.status).toBe(200); + expect(first.headers.etag).toMatch(/^"[a-f0-9]{64}"$/); + + const second = await request(app) + .get('/api/feature-flags') + .set('x-user-id', 'etag-user') + .set('If-None-Match', first.headers.etag); + + expect(second.status).toBe(304); + expect(second.text).toBe(''); + }); + + it('returns the full response when the conditional ETag is stale', async () => { + const { app } = buildApp({ flags: { stable: true } }, 60_000, 10); + + const response = await request(app) + .get('/api/feature-flags') + .set('x-user-id', 'stale-etag-user') + .set('If-None-Match', '"stale"'); + + expect(response.status).toBe(200); + expect(response.body.data.flags).toEqual({ stable: true }); + expect(response.headers.etag).toMatch(/^"[a-f0-9]{64}"$/); + }); }); diff --git a/src/routes/feature-flags.ts b/src/routes/feature-flags.ts index d3cbb22b..ae52c87e 100644 --- a/src/routes/feature-flags.ts +++ b/src/routes/feature-flags.ts @@ -3,6 +3,7 @@ import type { RequestHandler } from 'express'; import { successEnvelope, getRequestId } from '../lib/envelope.js'; import { createRateLimitMiddleware, InMemoryRateLimiter } from '../middleware/rateLimit.js'; import { config } from '../config/index.js'; +import { etagMiddleware, generateETag } from '../middleware/etag.js'; export interface FeatureFlags { flags: Record; @@ -25,22 +26,24 @@ const defaultFlags: Record = { export function createFeatureFlagsRouter(deps: FeatureFlagsRouterDeps = {}): Router { const router = Router(); - const rateLimiter = deps.rateLimiter ?? new InMemoryRateLimiter( - config.featureFlagsRateLimit.windowMs, - config.featureFlagsRateLimit.maxRequests, - ); - const rateLimit = deps.rateLimit ?? createRateLimitMiddleware( - { - windowMs: config.featureFlagsRateLimit.windowMs, - maxRequests: config.featureFlagsRateLimit.maxRequests, - }, - rateLimiter, - ); + const rateLimit = deps.rateLimit ?? (() => { + // Feature flags use the standard REST defaults until a dedicated limit is + // configured, while callers can still inject a route-specific limiter. + const options = config.restRateLimit; + const rateLimiter = deps.rateLimiter ?? new InMemoryRateLimiter( + options.windowMs, + options.maxRequests, + ); + return createRateLimitMiddleware(options, rateLimiter); + })(); const flags = deps.flags ?? defaultFlags; - router.get('/', rateLimit, (req, res) => { + router.get('/', rateLimit, etagMiddleware, (req, res) => { const requestId = getRequestId(req); const data: FeatureFlags = { flags }; + // Keep the validator stable across requests; the success envelope's + // timestamp and request ID are intentionally excluded from the ETag. + res.setHeader('ETag', generateETag(JSON.stringify(data))); res.json(successEnvelope(data, requestId)); });