mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
feat(api): Endpoints for classroom - exchanging user data (#62063)
Co-authored-by: Newton Ly Chung <newtonlc@MSI> Co-authored-by: Carly Thomas <CarlyAThomas@users.noreply.github.com> Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
@@ -196,6 +196,7 @@ export const build = async (
|
|||||||
fastify.addHook('onRequest', fastify.send401IfNoUser);
|
fastify.addHook('onRequest', fastify.send401IfNoUser);
|
||||||
|
|
||||||
await fastify.register(protectedRoutes.userGetRoutes);
|
await fastify.register(protectedRoutes.userGetRoutes);
|
||||||
|
await fastify.register(protectedRoutes.classroomRoutes);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Routes that redirect if access is denied:
|
// Routes that redirect if access is denied:
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,5 +1,6 @@
|
|||||||
export * from './certificate.js';
|
export * from './certificate.js';
|
||||||
export * from './challenge.js';
|
export * from './challenge.js';
|
||||||
|
export * from './classroom.js';
|
||||||
export * from './donate.js';
|
export * from './donate.js';
|
||||||
export * from './settings.js';
|
export * from './settings.js';
|
||||||
export * from './user.js';
|
export * from './user.js';
|
||||||
|
|||||||
@@ -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() })
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user