Skip to content

Commit 948dbe9

Browse files
Merge pull request #125 from CodeForPhilly/fix/gate-test-routes-to-non-prod
fix(api): gate /api/_test/* routes to non-production (closes #116)
2 parents b0f56b6 + 9654fd5 commit 948dbe9

2 files changed

Lines changed: 90 additions & 43 deletions

File tree

apps/api/src/routes/health.ts

Lines changed: 53 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -118,53 +118,63 @@ export async function healthRoutes(fastify: FastifyInstance): Promise<void> {
118118
},
119119
);
120120

121-
// Stub route for testing validation errors
122-
fastify.post(
123-
'/api/_test/validation-error',
124-
{
125-
schema: {
126-
hide: true,
127-
body: {
128-
type: 'object',
129-
properties: {
130-
trigger: { type: 'string' },
121+
// ---------------------------------------------------------------------------
122+
// Test-harness routes — registered only when NODE_ENV !== 'production'.
123+
//
124+
// These exist purely to exercise the error-mapping + idempotency code
125+
// paths from CI tests. Gating them off in production is defense in
126+
// depth: there's no reason production callers should be able to hit
127+
// /api/_test/internal-error and force a 500. See issue #116.
128+
// ---------------------------------------------------------------------------
129+
if (fastify.config.NODE_ENV !== 'production') {
130+
// Stub route for testing validation errors
131+
fastify.post(
132+
'/api/_test/validation-error',
133+
{
134+
schema: {
135+
hide: true,
136+
body: {
137+
type: 'object',
138+
properties: {
139+
trigger: { type: 'string' },
140+
},
131141
},
132142
},
133143
},
134-
},
135-
async () => {
136-
const { ApiValidationError } = await import('../lib/errors.js');
137-
throw new ApiValidationError('Test validation failed', { field: 'required' });
138-
},
139-
);
144+
async () => {
145+
const { ApiValidationError } = await import('../lib/errors.js');
146+
throw new ApiValidationError('Test validation failed', { field: 'required' });
147+
},
148+
);
140149

141-
// Stub route for testing unknown/500 errors
142-
fastify.post(
143-
'/api/_test/internal-error',
144-
{ schema: { hide: true } },
145-
async () => {
146-
throw new Error('Deliberate internal error — should not leak to client');
147-
},
148-
);
150+
// Stub route for testing unknown/500 errors
151+
fastify.post(
152+
'/api/_test/internal-error',
153+
{ schema: { hide: true } },
154+
async () => {
155+
throw new Error('Deliberate internal error — should not leak to client');
156+
},
157+
);
149158

150-
// Stub route for testing idempotency
151-
fastify.post(
152-
'/api/_test/idempotency',
153-
{ schema: { hide: true } },
154-
async (request, reply) => {
155-
const idempotencyKey = request.headers['idempotency-key'];
156-
if (typeof idempotencyKey === 'string' && idempotencyKey.length > 0) {
157-
const personId = 'test-person';
158-
const cached = request.server.idempotency.check(personId, idempotencyKey);
159-
if (cached) {
160-
return reply.code(cached.status).send(cached.body);
161-
}
159+
// Stub route for testing idempotency
160+
fastify.post(
161+
'/api/_test/idempotency',
162+
{ schema: { hide: true } },
163+
async (request, reply) => {
164+
const idempotencyKey = request.headers['idempotency-key'];
165+
if (typeof idempotencyKey === 'string' && idempotencyKey.length > 0) {
166+
const personId = 'test-person';
167+
const cached = request.server.idempotency.check(personId, idempotencyKey);
168+
if (cached) {
169+
return reply.code(cached.status).send(cached.body);
170+
}
162171

163-
const body = ok({ echoed: idempotencyKey, at: new Date().toISOString() });
164-
request.server.idempotency.store(personId, idempotencyKey, { status: 200, body });
165-
return reply.code(200).send(body);
166-
}
167-
return ok({ echoed: null });
168-
},
169-
);
172+
const body = ok({ echoed: idempotencyKey, at: new Date().toISOString() });
173+
request.server.idempotency.store(personId, idempotencyKey, { status: 200, body });
174+
return reply.code(200).send(body);
175+
}
176+
return ok({ echoed: null });
177+
},
178+
);
179+
}
170180
}

apps/api/tests/api-skeleton.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,43 @@ describe('error mapper', () => {
127127
});
128128
});
129129

130+
// ---------------------------------------------------------------------------
131+
// /api/_test/* gating (issue #116)
132+
//
133+
// The test-harness routes exist only to exercise the error-mapping +
134+
// idempotency code paths from CI. In NODE_ENV=production they must NOT
135+
// be registered — no reason a prod caller should be able to hit
136+
// /api/_test/internal-error and force a 500.
137+
// ---------------------------------------------------------------------------
138+
139+
describe('/api/_test/* route gating', () => {
140+
it('returns 404 for all three test-harness routes when NODE_ENV=production', async () => {
141+
// Close the default (NODE_ENV=test) app so the prod-mode app gets
142+
// a clean fixture. The base afterEach takes care of the rest.
143+
if (app) {
144+
await app.close();
145+
app = undefined;
146+
}
147+
const prodApp = await buildTestApp({ NODE_ENV: 'production' });
148+
try {
149+
const paths = [
150+
'/api/_test/validation-error',
151+
'/api/_test/internal-error',
152+
'/api/_test/idempotency',
153+
];
154+
for (const url of paths) {
155+
const res = await prodApp.inject({ method: 'POST', url });
156+
expect(res.statusCode, `expected ${url} to 404 in production`).toBe(404);
157+
}
158+
// Sanity: real routes still respond.
159+
const health = await prodApp.inject({ method: 'GET', url: '/api/health' });
160+
expect(health.statusCode).toBe(200);
161+
} finally {
162+
await prodApp.close();
163+
}
164+
});
165+
});
166+
130167
// ---------------------------------------------------------------------------
131168
// Rate limiting
132169
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)