Skip to content
Open
1 change: 1 addition & 0 deletions api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@
export const build = async (
options: FastifyHttpOptions<RawServerDefault, FastifyBaseLogger> = {}
): Promise<FastifyInstanceWithTypeProvider> => {
// TODO: Old API returns 403s for failed validation. We now return 400 (default) from AJV.

Check notice on line 103 in api/src/app.ts

View check run for this annotation

codefactor.io / CodeFactor

api/src/app.ts#L103

Unresolved 'todo' comment. (eslint/no-warning-comments)
// Watch when implementing in client
const fastify = Fastify(options).withTypeProvider<TypeBoxTypeProvider>();

Expand Down Expand Up @@ -178,7 +178,7 @@
fastify.addHook('onRequest', fastify.authorize);
// CSRF protection enabled:
await fastify.register(async function (fastify, _opts) {
// TODO: bounce unauthed requests before checking CSRF token. This will

Check notice on line 181 in api/src/app.ts

View check run for this annotation

codefactor.io / CodeFactor

api/src/app.ts#L181

Unresolved 'todo' comment. (eslint/no-warning-comments)
// mean moving csrfProtection into custom plugin and testing separately,
// because it's a pain to mess around with other cookies/hook order.
// eslint-disable-next-line @typescript-eslint/unbound-method
Expand All @@ -197,6 +197,7 @@
fastify.addHook('onRequest', fastify.send401IfNoUser);

await fastify.register(protectedRoutes.userGetRoutes);
await fastify.register(protectedRoutes.classroomRoutes);
});

// Routes that redirect if access is denied:
Expand All @@ -207,13 +208,13 @@
});
});

// TODO: The route should not handle its own AuthZ

Check notice on line 211 in api/src/app.ts

View check run for this annotation

codefactor.io / CodeFactor

api/src/app.ts#L211

Unresolved 'todo' comment. (eslint/no-warning-comments)
await fastify.register(protectedRoutes.challengeTokenRoutes);

// Routes for signed out users:
void fastify.register(async function (fastify) {
fastify.addHook('onRequest', fastify.authorize);
// TODO(Post-MVP): add the redirectIfSignedIn hook here, rather than in the

Check notice on line 217 in api/src/app.ts

View check run for this annotation

codefactor.io / CodeFactor

api/src/app.ts#L217

Unresolved 'todo' comment. (eslint/no-warning-comments)
// mobileAuth0Routes and authRoutes plugins.
await fastify.register(publicRoutes.mobileAuth0Routes);
if (FCC_ENABLE_DEV_LOGIN_MODE) {
Expand Down
217 changes: 217 additions & 0 deletions api/src/routes/protected/classroom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { describe, test, expect, beforeAll, afterEach, vi } from 'vitest';

import { createUserInput } from '../../utils/create-user.js';
import {
createSuperRequest,
defaultUserEmail,
defaultUserId,
devLogin,
resetDefaultUser,
setupServer,
superRequest
} from '../../../vitest.utils.js';

describe('classroom routes', () => {
setupServer();

describe('Authenticated user', () => {
let setCookies: string[];
let superPost: ReturnType<typeof createSuperRequest>;

const classroomUserEmail = 'student1@example.com';
const nonClassroomUserEmail = 'student2@example.com';
const classroomUserId = '000000000000000000000001';
const nonClassroomUserId = '000000000000000000000002';

beforeAll(async () => {
setCookies = await devLogin();
superPost = createSuperRequest({ method: 'POST', setCookies });
});

afterEach(async () => {
vi.restoreAllMocks();

// Cleanup users created by these tests
await fastifyTestInstance.prisma.user.deleteMany({
where: { email: { in: [classroomUserEmail, nonClassroomUserEmail] } }
});

// Reset default user to a clean state
await resetDefaultUser();
});

describe('POST /api/protected/classroom/get-user-id', () => {
test('returns 400 for missing email', async () => {
const missingRes = await superPost(
'/api/protected/classroom/get-user-id'
).send({});

expect(missingRes.status).toBe(400);
});

test('returns 200 with empty userId for invalid email format', async () => {
const invalidRes = await superPost(
'/api/protected/classroom/get-user-id'
).send({ email: 'not-an-email' });

expect(invalidRes.status).toBe(200);
expect(invalidRes.body).toStrictEqual({ userId: '' });
});

test('returns 200 with empty userId when no classroom account matches email', async () => {
// Default user is not a classroom account by default
const res = await superPost(
'/api/protected/classroom/get-user-id'
).send({ email: defaultUserEmail });

expect(res.status).toBe(200);
expect(res.body).toStrictEqual({ userId: '' });
});

test('returns 200 with userId for a classroom account', async () => {
// Make the default user a classroom account
await fastifyTestInstance.prisma.user.update({
where: { id: defaultUserId },
data: { isClassroomAccount: true }
});

const res = await superPost(
'/api/protected/classroom/get-user-id'
).send({ email: defaultUserEmail });

expect(res.status).toBe(200);
expect(res.body).toStrictEqual({ userId: defaultUserId });
});

test('returns 500 when the database query fails', async () => {
vi.spyOn(
fastifyTestInstance.prisma.user,
'findFirst'
).mockRejectedValue(new Error('test'));

const res = await superPost(
'/api/protected/classroom/get-user-id'
).send({ email: defaultUserEmail });

expect(res.status).toBe(500);
expect(res.body).toStrictEqual({ error: 'Failed to retrieve user id' });
});
});

describe('POST /api/protected/classroom/get-user-data', () => {
test('returns 400 when more than 50 userIds are provided', async () => {
const tooMany = Array.from({ length: 51 }, (_, i) => `id-${i}`);

const res = await superPost(
'/api/protected/classroom/get-user-data'
).send({ userIds: tooMany });

expect(res.status).toBe(400);
expect(res.body).toStrictEqual({
error: 'Too many users requested. Maximum 50 allowed.'
});
});

test('returns data only for classroom accounts', async () => {
const now = Date.now();

// Make default user a classroom account with one completed challenge
await fastifyTestInstance.prisma.user.update({
where: { id: defaultUserId },
data: {
isClassroomAccount: true,
completedChallenges: [
{
id: 'challenge-default',
completedDate: now,
files: []
}
]
}
});

// Create an additional classroom user
await fastifyTestInstance.prisma.user.create({
data: {
...createUserInput(classroomUserEmail),
id: classroomUserId,
isClassroomAccount: true,
completedChallenges: [
{
id: 'challenge-student',
completedDate: now + 1,
files: []
}
]
}
});

// Create a non-classroom user that should be filtered out
await fastifyTestInstance.prisma.user.create({
data: {
...createUserInput(nonClassroomUserEmail),
id: nonClassroomUserId,
isClassroomAccount: false,
completedChallenges: []
}
});

const res = await superPost(
'/api/protected/classroom/get-user-data'
).send({
userIds: [defaultUserId, classroomUserId, nonClassroomUserId]
});

expect(res.status).toBe(200);
const responseBody = res.body as {
data: Record<
string,
Array<{ id: string; completedDate: number }> | undefined
>;
};
expect(Object.keys(responseBody.data)).toEqual(
expect.arrayContaining([defaultUserId, classroomUserId])
);
expect(responseBody.data).not.toHaveProperty(nonClassroomUserId);

expect(responseBody.data[defaultUserId]?.[0]).toMatchObject({
id: 'challenge-default',
completedDate: now
});
expect(responseBody.data[classroomUserId]?.[0]).toMatchObject({
id: 'challenge-student',
completedDate: now + 1
});
});

test('returns 500 when the database query fails', async () => {
vi.spyOn(fastifyTestInstance.prisma.user, 'findMany').mockRejectedValue(
new Error('test')
);

const res = await superPost(
'/api/protected/classroom/get-user-data'
).send({ userIds: [defaultUserId] });

expect(res.status).toBe(500);
expect(res.body).toStrictEqual({
error: 'Failed to retrieve user data'
});
});
});
});

describe('Unauthenticated user', () => {
test('POST requests are rejected with 401', async () => {
const res = await superRequest(
'/api/protected/classroom/get-user-id',
{
method: 'POST'
},
{ sendCSRFToken: false }
).send({ email: 'someone@example.com' });

expect(res.status).toBe(401);
});
});
});
111 changes: 111 additions & 0 deletions api/src/routes/protected/classroom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import {
normalizeChallenges,
NormalizedChallenge
} from '../../utils/normalize.js';
import * as schemas from '../../schemas/classroom/classroom.js';

/**
* Fastify plugin for classroom-related protected routes.
* Provides endpoint for retrieving user data for classrooms.
* @param fastify - The Fastify instance.
* @param _options - Plugin options (unused).
* @param done - Callback to signal plugin registration is complete.
*/
export const classroomRoutes: FastifyPluginCallbackTypebox = (
fastify,
_options,
done
) => {
// Endpoint to retrieve a user's ID from a user's email.
// If we send a 404 error here, it will stop the entire classroom process from working.
// Instead, we indicate that the user was not found through a null response and continue.
fastify.post(
'/api/protected/classroom/get-user-id',
{
schema: schemas.classroomGetUserIdSchema
},
async (request, reply) => {
const { email } = request.body;

// Basic email validation - return empty userId for invalid emails
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return reply.send({ userId: '' });
}

try {
// Find the user by email
const user = await fastify.prisma.user.findFirst({
where: { email, isClassroomAccount: true },
select: { id: true }
});

if (!user) {
return reply.send({ userId: '' });
}

return reply.send({
userId: user.id
});
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: 'Failed to retrieve user id' });
}
}
);

// Endpoint to retrieve user(s) data from a list of user ids
fastify.post(
'/api/protected/classroom/get-user-data',
{
schema: schemas.classroomGetUserDataSchema
},
async (request, reply) => {
const { userIds = [] } = request.body;

// Limit number of users per request for performance
// Send custom error message if this is exceeded
if (userIds.length > 50) {
return reply.code(400).send({
error: 'Too many users requested. Maximum 50 allowed.'
});
}

try {
// Find all the requested users by user id
const users = await fastify.prisma.user.findMany({
where: {
id: { in: userIds },
isClassroomAccount: true
},
select: {
id: true,
completedChallenges: true
}
});

// Map to transform user data into the required format
const userData: Record<string, NormalizedChallenge[]> = {};

users.forEach(user => {
// Normalize challenges
const normalizedChallenges = normalizeChallenges(
user.completedChallenges
);

userData[user.id] = normalizedChallenges;
});

return reply.send({
data: userData
});
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: 'Failed to retrieve user data' });
}
}
);

done();
};
1 change: 1 addition & 0 deletions api/src/routes/protected/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './certificate.js';
export * from './challenge.js';
export * from './classroom.js';
export * from './donate.js';
export * from './settings.js';
export * from './user.js';
30 changes: 30 additions & 0 deletions api/src/schemas/classroom/classroom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Type } from '@fastify/type-provider-typebox';
export const classroomGetUserIdSchema = {
body: Type.Object({
email: Type.String({ maxLength: 1024 })
}),
response: {
200: Type.Object({ userId: Type.String() }),
500: Type.Object({ error: Type.String() })
}
};
export const classroomGetUserDataSchema = {
body: Type.Object({
userIds: Type.Array(Type.String())
}),
response: {
200: Type.Object({
data: Type.Record(
Type.String(),
Type.Array(
Type.Object({
id: Type.String(),
completedDate: Type.Number()
})
)
)
}),
400: Type.Object({ error: Type.String() }),
500: Type.Object({ error: Type.String() })
}
};
Loading