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
4 changes: 4 additions & 0 deletions apps/api/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ describe('GET /health', () => {
const response = await request(
createApp({
authRouter: Router(),
exerciseRouter: Router(),
csrfErrorHandler: noOpErrorMiddleware,
csrfProtection: noOpMiddleware,
webOrigin: 'http://localhost:5173',
Expand All @@ -34,6 +35,7 @@ describe('GET /health', () => {
const response = await request(
createApp({
authRouter: Router(),
exerciseRouter: Router(),
csrfErrorHandler: noOpErrorMiddleware,
csrfProtection: noOpMiddleware,
webOrigin: 'http://localhost:5173',
Expand All @@ -56,6 +58,7 @@ describe('GET /health', () => {
const response = await request(
createApp({
authRouter: Router(),
exerciseRouter: Router(),
csrfErrorHandler: noOpErrorMiddleware,
csrfProtection: noOpMiddleware,
webOrigin: 'http://localhost:5173',
Expand All @@ -78,6 +81,7 @@ describe('GET /health', () => {
const response = await request(
createApp({
authRouter: Router(),
exerciseRouter: Router(),
csrfErrorHandler: noOpErrorMiddleware,
csrfProtection: noOpMiddleware,
webOrigin: 'http://localhost:5173',
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type AppDependencies = {
checkDatabaseConnection: () => Promise<void>;
csrfErrorHandler: express.ErrorRequestHandler;
csrfProtection: RequestHandler;
exerciseRouter: Router;
sessionMiddleware: RequestHandler;
trustProxyHops: number;
webOrigin: string;
Expand All @@ -18,6 +19,7 @@ export function createApp({
checkDatabaseConnection,
csrfErrorHandler,
csrfProtection,
exerciseRouter,
sessionMiddleware,
trustProxyHops,
webOrigin,
Expand All @@ -33,6 +35,7 @@ export function createApp({
app.use(express.json());
app.use(csrfProtection);
app.use('/auth', authRouter);
app.use('/exercises', exerciseRouter);

app.get('/health', async (_request, response) => {
try {
Expand Down
66 changes: 66 additions & 0 deletions apps/api/src/exercises/exercise.routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/** Verifies authentication and response behavior for the exercise list route. */
import express from 'express';
import session from 'express-session';
import request from 'supertest';
import { describe, expect, it, vi } from 'vitest';

import { createSessionMiddleware } from '../auth/session/session.middleware.js';
import { establishAuthenticatedTestSession } from '../test/authenticated-session.test-helper.js';
import { testSessionConfiguration } from '../test/fixtures/session-configuration.fixture.js';
import { createExerciseRouter } from './exercise.routes.js';

const userId = '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486';
const exerciseList = [
{
id: 'bde77251-e433-4f39-b1cb-41a2f2ad5462',
name: 'Bench Press',
description: 'Press a barbell from chest level.',
isCustom: false,
},
{
id: 'f29f209d-d1f9-4988-b693-69b291917b0f',
name: 'Custom Carry',
description: null,
isCustom: true,
},
];

function createTestApp(
listExercises = vi.fn(() => Promise.resolve(exerciseList)),
) {
const app = express();
app.use(
createSessionMiddleware(
new session.MemoryStore(),
testSessionConfiguration,
),
);
app.use(establishAuthenticatedTestSession());
app.use('/exercises', createExerciseRouter({ listExercises }));

return { app, listExercises };
}

describe('GET /exercises', () => {
it('rejects anonymous requests', async () => {
const { app, listExercises } = createTestApp();

const response = await request(app).get('/exercises');

expect(response.status).toBe(401);
expect(response.body).toEqual({ error: 'authentication_required' });
expect(listExercises).not.toHaveBeenCalled();
});

it('returns exercises visible to the authenticated user', async () => {
const { app, listExercises } = createTestApp();

const response = await request(app)
.get('/exercises')
.set('x-test-authenticated-user-id', userId);

expect(response.status).toBe(200);
expect(response.body).toEqual({ exercises: exerciseList });
expect(listExercises).toHaveBeenCalledWith(userId);
});
});
38 changes: 38 additions & 0 deletions apps/api/src/exercises/exercise.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/** Defines authenticated exercise-list API routes. */
import {
exerciseListResponseSchema,
type ExerciseSummary,
} from '@goforlift/contracts';
import { Router } from 'express';

import {
getAuthenticatedUserId,
requireAuthentication,
} from '../auth/guards/authentication.middleware.js';

type ExerciseRouterDependencies = {
listExercises: (userId: string) => Promise<ExerciseSummary[]>;
};

export function createExerciseRouter({
listExercises,
}: ExerciseRouterDependencies) {
const router = Router();

router.get('/', requireAuthentication, async (request, response, next) => {
try {
const userId = getAuthenticatedUserId(request);
const exercises = await listExercises(userId);

response.status(200).json(
exerciseListResponseSchema.parse({
exercises,
}),
);
} catch (error) {
next(error);
}
});

return router;
}
56 changes: 56 additions & 0 deletions apps/api/src/exercises/exercise.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/** Verifies that exercise listing applies ownership visibility rules. */
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { describe, expect, it, vi } from 'vitest';

import { exercises } from '../db/schema/index.js';
import { listExercisesForUser } from './exercise.service.js';

describe('listExercisesForUser', () => {
it('selects the safe projection and identifies custom exercises', async () => {
const builtInExercise = {
id: 'bde77251-e433-4f39-b1cb-41a2f2ad5462',
name: 'Bench Press',
description: null,
ownerUserId: null,
};
const customExercise = {
id: 'f29f209d-d1f9-4988-b693-69b291917b0f',
name: 'Custom Carry',
description: 'A custom loaded carry.',
ownerUserId: '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486',
};
const rows = [builtInExercise, customExercise];
const orderBy = vi.fn().mockResolvedValue(rows);
const where = vi.fn(() => ({ orderBy }));
const from = vi.fn(() => ({ where }));
const select = vi.fn(() => ({ from }));
const database = { select } as unknown as NodePgDatabase;

const result = await listExercisesForUser(
database,
'26d34dc0-8e4c-4bd0-9e3b-7b839b44e486',
);

expect(select).toHaveBeenCalledWith({
id: exercises.id,
name: exercises.name,
description: exercises.description,
ownerUserId: exercises.ownerUserId,
});
expect(where).toHaveBeenCalledOnce();
expect(result).toEqual([
{
id: builtInExercise.id,
name: builtInExercise.name,
description: builtInExercise.description,
isCustom: false,
},
{
id: customExercise.id,
name: customExercise.name,
description: customExercise.description,
isCustom: true,
},
]);
});
});
27 changes: 27 additions & 0 deletions apps/api/src/exercises/exercise.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/** Lists exercises visible to the authenticated user. */
import type { ExerciseSummary } from '@goforlift/contracts';
import { asc, eq, isNull, or } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';

import { exercises } from '../db/schema/index.js';

export async function listExercisesForUser(
database: NodePgDatabase,
userId: string,
): Promise<ExerciseSummary[]> {
const visibleExercises = await database
.select({
id: exercises.id,
name: exercises.name,
description: exercises.description,
ownerUserId: exercises.ownerUserId,
})
.from(exercises)
.where(or(isNull(exercises.ownerUserId), eq(exercises.ownerUserId, userId)))
.orderBy(asc(exercises.name));

return visibleExercises.map(({ ownerUserId, ...exercise }) => ({
...exercise,
isCustom: ownerUserId !== null,
}));
}
9 changes: 9 additions & 0 deletions apps/api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { createSessionRouter } from './auth/session/session.routes.js';
import { findPublicUserById } from './auth/user/current-user.service.js';
import { env } from './config/env.js';
import { createDatabase } from './db/client.js';
import { createExerciseRouter } from './exercises/exercise.routes.js';
import { listExercisesForUser } from './exercises/exercise.service.js';

const { db, pool } = createDatabase(env.DATABASE_URL);
const { middleware: sessionMiddleware, store: sessionStore } =
Expand All @@ -31,17 +33,24 @@ authRouter.use(
provisionUser: (profile) => provisionGoogleUser(db, profile),
}),
);

authRouter.use(
createSessionRouter({
cookieName: env.SESSION_COOKIE.name,
cookiePath: env.SESSION_COOKIE.path,
findPublicUser: (userId) => findPublicUserById(db, userId),
}),
);

const exerciseRouter = createExerciseRouter({
listExercises: (userId) => listExercisesForUser(db, userId),
});

const app = createApp({
authRouter,
csrfErrorHandler,
csrfProtection,
exerciseRouter,
webOrigin: env.WEB_ORIGIN,
trustProxyHops: env.TRUST_PROXY_HOPS,
sessionMiddleware,
Expand Down
41 changes: 41 additions & 0 deletions packages/contracts/src/exercises/exercise.contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/** Verifies exercise-list contracts accept public data and reject malformed payloads. */
import { describe, expect, it } from 'vitest';

import {
exerciseListResponseSchema,
exerciseSummarySchema,
} from './exercise.contract.js';

describe('exercise contracts', () => {
it('accepts built-in and custom exercise summaries', () => {
const result = exerciseListResponseSchema.safeParse({
exercises: [
{
id: '9f4b5a8e-2c3d-4f10-8a11-000000000001',
name: 'Bicep Curl',
description: null,
isCustom: false,
},
{
id: '0d10f788-a39b-4f97-9927-5b28fec83aef',
name: 'My Custom Press',
description: 'A custom pressing movement.',
isCustom: true,
},
],
});

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

it('rejects malformed exercise summaries', () => {
const result = exerciseSummarySchema.safeParse({
id: 'not-a-uuid',
name: '',
description: null,
isCustom: 'yes',
});

expect(result.success).toBe(false);
});
});
16 changes: 16 additions & 0 deletions packages/contracts/src/exercises/exercise.contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/** Defines browser-safe exercise-list contracts shared across applications. */
import { z } from 'zod';

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

export const exerciseListResponseSchema = z.object({
exercises: z.array(exerciseSummarySchema),
});

export type ExerciseSummary = z.infer<typeof exerciseSummarySchema>;
export type ExerciseListResponse = z.infer<typeof exerciseListResponseSchema>;
1 change: 1 addition & 0 deletions packages/contracts/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/** Exposes the public runtime-validated contracts shared across GoForLift. */
export * from './auth/auth.contract.js';
export * from './exercises/exercise.contract.js';
export * from './health/health.contract.js';