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
34 changes: 34 additions & 0 deletions apps/api/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,37 @@ describe('GET /health', () => {
expect(errorLog).toHaveBeenCalledWith('API request failed');
});
});

describe('JSON request parsing', () => {
function createTestApp() {
return createApp({
authRouter: Router(),
exerciseRouter: Router(),
csrfErrorHandler: noOpErrorMiddleware,
csrfProtection: noOpMiddleware,
webOrigin: 'http://localhost:5173',
trustProxyHops: 0,
sessionMiddleware: noOpMiddleware,
checkDatabaseConnection: vi.fn().mockResolvedValue(undefined),
});
}

it('returns a client error for malformed JSON', async () => {
const response = await request(createTestApp())
.post('/exercises')
.set('Content-Type', 'application/json')
.send('{"name":');

expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'invalid_json' });
});

it('rejects JSON bodies larger than the configured limit', async () => {
const response = await request(createTestApp())
.post('/exercises')
.send({ description: 'a'.repeat(101 * 1024) });

expect(response.status).toBe(413);
expect(response.body).toEqual({ error: 'request_too_large' });
});
});
24 changes: 22 additions & 2 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@ import { healthResponseSchema } from '@goforlift/contracts';
import cors from 'cors';
import express, { type RequestHandler, type Router } from 'express';

const JSON_BODY_LIMIT = '100kb';

type ExpressBodyError = Error & {
type?: string;
};

function isExpressBodyError(error: unknown, type: string) {
return error instanceof Error && (error as ExpressBodyError).type === type;
}

type AppDependencies = {
authRouter: Router;
checkDatabaseConnection: () => Promise<void>;
Expand Down Expand Up @@ -32,7 +42,7 @@ export function createApp({
}
app.use(cors({ origin: webOrigin, credentials: true }));
app.use(sessionMiddleware);
app.use(express.json());
app.use(express.json({ limit: JSON_BODY_LIMIT }));
app.use(csrfProtection);
app.use('/auth', authRouter);
app.use('/exercises', exerciseRouter);
Expand All @@ -57,8 +67,18 @@ export function createApp({

app.use(csrfErrorHandler);
app.use(((error, _request, response, next) => {
void error;
void next;

if (isExpressBodyError(error, 'entity.parse.failed')) {
response.status(400).json({ error: 'invalid_json' });
return;
}

if (isExpressBodyError(error, 'entity.too.large')) {
response.status(413).json({ error: 'request_too_large' });
return;
}

console.error('API request failed');
response.status(500).json({ error: 'internal_server_error' });
}) satisfies express.ErrorRequestHandler);
Expand Down
18 changes: 13 additions & 5 deletions packages/contracts/src/exercises/exercise.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';

import {
createExerciseInputSchema,
EXERCISE_NAME_MAX_LENGTH,
exerciseListResponseSchema,
exerciseSummarySchema,
} from './exercise.contract.js';
Expand All @@ -22,13 +23,9 @@ describe('createExerciseInputSchema', () => {

it('rejects empty, oversized, and client-controlled fields', () => {
expect(() => createExerciseInputSchema.parse({ name: ' ' })).toThrow();
expect(() =>
createExerciseInputSchema.parse({ name: 'a'.repeat(101) }),
).toThrow();
expect(() =>
createExerciseInputSchema.parse({
name: 'Cable Fly',
description: 'a'.repeat(1001),
name: 'a'.repeat(EXERCISE_NAME_MAX_LENGTH + 1),
}),
).toThrow();
expect(() =>
Expand Down Expand Up @@ -72,4 +69,15 @@ describe('exercise contracts', () => {

expect(result.success).toBe(false);
});

it('rejects exercise names that exceed the product limit', () => {
expect(() =>
exerciseSummarySchema.parse({
id: 'bde77251-e433-4f39-b1cb-41a2f2ad5462',
name: 'a'.repeat(EXERCISE_NAME_MAX_LENGTH + 1),
description: null,
isCustom: false,
}),
).toThrow();
});
});
8 changes: 5 additions & 3 deletions packages/contracts/src/exercises/exercise.contract.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
/** Defines browser-safe exercise-list contracts shared across applications. */
import { z } from 'zod';

export const EXERCISE_NAME_MAX_LENGTH = 100;

export const createExerciseInputSchema = z
.object({
name: z.string().trim().min(1).max(100),
description: z.string().trim().max(1000).nullable().optional(),
name: z.string().trim().min(1).max(EXERCISE_NAME_MAX_LENGTH),
description: z.string().trim().nullable().optional(),
})
.strict();

export const exerciseSummarySchema = z.object({
id: z.uuid(),
name: z.string().min(1),
name: z.string().min(1).max(EXERCISE_NAME_MAX_LENGTH),
description: z.string().nullable(),
isCustom: z.boolean(),
});
Expand Down