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
37 changes: 34 additions & 3 deletions src/routes/feature-flags.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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 };
}
Expand Down Expand Up @@ -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();
});
Expand All @@ -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')
Expand Down Expand Up @@ -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}"$/);
});
});
27 changes: 15 additions & 12 deletions src/routes/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, boolean>;
Expand All @@ -25,22 +26,24 @@ const defaultFlags: Record<string, boolean> = {
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));
});

Expand Down
Loading