mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-03-31 18:01:36 -04:00
refactor: replace with service-to-service token
This commit is contained in:
@@ -30,9 +30,11 @@ import csrf from './plugins/csrf.js';
|
||||
import notFound from './plugins/not-found.js';
|
||||
import shadowCapture from './plugins/shadow-capture.js';
|
||||
import growthBook from './plugins/growth-book.js';
|
||||
import serviceBearerAuth from './plugins/service-bearer-auth.js';
|
||||
|
||||
import * as publicRoutes from './routes/public/index.js';
|
||||
import * as protectedRoutes from './routes/protected/index.js';
|
||||
import { classroomRoutes } from './routes/apps/classroom.js';
|
||||
|
||||
import {
|
||||
API_LOCATION,
|
||||
@@ -171,6 +173,7 @@ export const build = async (
|
||||
void fastify.register(notFound);
|
||||
void fastify.register(prismaPlugin);
|
||||
void fastify.register(bouncer);
|
||||
await fastify.register(serviceBearerAuth);
|
||||
|
||||
// Routes requiring authentication:
|
||||
void fastify.register(async function (fastify, _opts) {
|
||||
@@ -196,7 +199,6 @@ export const build = async (
|
||||
fastify.addHook('onRequest', fastify.send401IfNoUser);
|
||||
|
||||
await fastify.register(protectedRoutes.userGetRoutes);
|
||||
await fastify.register(protectedRoutes.classroomRoutes);
|
||||
});
|
||||
|
||||
// Routes that redirect if access is denied:
|
||||
@@ -232,6 +234,12 @@ export const build = async (
|
||||
});
|
||||
void fastify.register(examEnvironmentOpenRoutes);
|
||||
|
||||
// Service-to-service app routes (API key auth):
|
||||
void fastify.register(async function (fastify) {
|
||||
fastify.addHook('onRequest', fastify.validateBearerToken);
|
||||
await fastify.register(classroomRoutes, { prefix: '/apps/classroom' });
|
||||
});
|
||||
|
||||
if (FCC_ENABLE_SENTRY_ROUTES ?? fastify.gb.isOn('sentry-routes')) {
|
||||
void fastify.register(publicRoutes.sentryRoutes);
|
||||
}
|
||||
|
||||
91
api/src/plugins/service-bearer-auth.test.ts
Normal file
91
api/src/plugins/service-bearer-auth.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
|
||||
vi.mock('../utils/env', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('../utils/env.js')>();
|
||||
return {
|
||||
...actual,
|
||||
TPA_API_BEARER_TOKEN: 'test-api-secret-key'
|
||||
};
|
||||
});
|
||||
|
||||
import serviceBearerAuth from './service-bearer-auth.js';
|
||||
|
||||
describe('service-bearer-auth plugin', () => {
|
||||
let fastify: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
fastify = Fastify();
|
||||
await fastify.register(serviceBearerAuth);
|
||||
fastify.addHook('onRequest', fastify.validateBearerToken);
|
||||
fastify.get('/test', (_req, reply) => {
|
||||
void reply.send({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastify.close();
|
||||
});
|
||||
|
||||
test('should allow request with valid bearer token', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
headers: {
|
||||
authorization: 'Bearer test-api-secret-key'
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.statusCode).toEqual(200);
|
||||
expect(res.json()).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
test('should return 401 when authorization header is missing', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test'
|
||||
});
|
||||
|
||||
expect(res.statusCode).toEqual(401);
|
||||
expect(res.json()).toEqual({ error: 'Bearer token is required' });
|
||||
});
|
||||
|
||||
test('should return 401 when authorization header has no Bearer prefix', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
headers: {
|
||||
authorization: 'test-api-secret-key'
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.statusCode).toEqual(401);
|
||||
expect(res.json()).toEqual({ error: 'Bearer token is required' });
|
||||
});
|
||||
|
||||
test('should return 401 when bearer token is empty', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
headers: {
|
||||
authorization: 'Bearer '
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.statusCode).toEqual(401);
|
||||
expect(res.json()).toEqual({ error: 'Invalid bearer token' });
|
||||
});
|
||||
|
||||
test('should return 401 when bearer token is wrong', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
headers: {
|
||||
authorization: 'Bearer wrong-key'
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.statusCode).toEqual(401);
|
||||
expect(res.json()).toEqual({ error: 'Invalid bearer token' });
|
||||
});
|
||||
});
|
||||
54
api/src/plugins/service-bearer-auth.ts
Normal file
54
api/src/plugins/service-bearer-auth.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type {
|
||||
FastifyPluginCallback,
|
||||
FastifyRequest,
|
||||
FastifyReply
|
||||
} from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
|
||||
import { TPA_API_BEARER_TOKEN } from '../utils/env.js';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
validateBearerToken: (
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) => Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
const plugin: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
fastify.decorate(
|
||||
'validateBearerToken',
|
||||
async function (req: FastifyRequest, reply: FastifyReply) {
|
||||
const secret = TPA_API_BEARER_TOKEN ?? '';
|
||||
if (secret.length === 0) {
|
||||
fastify.log.error('TPA_API_BEARER_TOKEN is not configured');
|
||||
await reply
|
||||
.status(500)
|
||||
.send({ error: 'Service authentication not configured' });
|
||||
return;
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
await reply.status(401).send({ error: 'Bearer token is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
if (
|
||||
token.length !== secret.length ||
|
||||
!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(secret))
|
||||
) {
|
||||
await reply.status(401).send({ error: 'Invalid bearer token' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
export default fp(plugin, { name: 'service-bearer-auth' });
|
||||
293
api/src/routes/apps/classroom.test.ts
Normal file
293
api/src/routes/apps/classroom.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { describe, test, expect, afterEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../utils/env', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('../../utils/env.js')>();
|
||||
return {
|
||||
...actual,
|
||||
TPA_API_BEARER_TOKEN: 'test-classroom-api-secret'
|
||||
};
|
||||
});
|
||||
|
||||
import request from 'supertest';
|
||||
|
||||
import { createUserInput } from '../../utils/create-user.js';
|
||||
import {
|
||||
defaultUserEmail,
|
||||
defaultUserId,
|
||||
resetDefaultUser,
|
||||
setupServer
|
||||
} from '../../../vitest.utils.js';
|
||||
|
||||
const BEARER_TOKEN = 'test-classroom-api-secret';
|
||||
|
||||
const classroomUserEmail = 'student1@example.com';
|
||||
const nonClassroomUserEmail = 'student2@example.com';
|
||||
const classroomUserId = '000000000000000000000001';
|
||||
const nonClassroomUserId = '000000000000000000000002';
|
||||
|
||||
function post(url: string) {
|
||||
return request(fastifyTestInstance.server)
|
||||
.post(url)
|
||||
.set('authorization', `Bearer ${BEARER_TOKEN}`);
|
||||
}
|
||||
|
||||
describe('classroom routes', () => {
|
||||
setupServer();
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
|
||||
await fastifyTestInstance.prisma.user.deleteMany({
|
||||
where: { email: { in: [classroomUserEmail, nonClassroomUserEmail] } }
|
||||
});
|
||||
|
||||
await resetDefaultUser();
|
||||
});
|
||||
|
||||
describe('Without bearer token', () => {
|
||||
test('POST get-user-id returns 401', async () => {
|
||||
const res = await request(fastifyTestInstance.server)
|
||||
.post('/apps/classroom/get-user-id')
|
||||
.send({ email: 'someone@example.com' });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toStrictEqual({ error: 'Bearer token is required' });
|
||||
});
|
||||
|
||||
test('POST get-user-data returns 401', async () => {
|
||||
const res = await request(fastifyTestInstance.server)
|
||||
.post('/apps/classroom/get-user-data')
|
||||
.send({ userIds: [defaultUserId] });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toStrictEqual({ error: 'Bearer token is required' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('With wrong bearer token', () => {
|
||||
test('POST get-user-id returns 401', async () => {
|
||||
const res = await request(fastifyTestInstance.server)
|
||||
.post('/apps/classroom/get-user-id')
|
||||
.set('authorization', 'Bearer wrong-key')
|
||||
.send({ email: 'someone@example.com' });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toStrictEqual({ error: 'Invalid bearer token' });
|
||||
});
|
||||
|
||||
test('POST get-user-data returns 401', async () => {
|
||||
const res = await request(fastifyTestInstance.server)
|
||||
.post('/apps/classroom/get-user-data')
|
||||
.set('authorization', 'Bearer wrong-key')
|
||||
.send({ userIds: [defaultUserId] });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toStrictEqual({ error: 'Invalid bearer token' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authenticated with API key', () => {
|
||||
describe('POST /apps/classroom/get-user-id', () => {
|
||||
test('returns 400 for missing email', async () => {
|
||||
const res = await post('/apps/classroom/get-user-id').send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('returns 400 for invalid email format', async () => {
|
||||
const res = await post('/apps/classroom/get-user-id').send({
|
||||
email: 'not-an-email'
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('returns 200 with empty userId when no classroom account matches email', async () => {
|
||||
const res = await post('/apps/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 () => {
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: { isClassroomAccount: true }
|
||||
});
|
||||
|
||||
const res = await post('/apps/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 () => {
|
||||
const original = fastifyTestInstance.prisma.user.findFirst;
|
||||
fastifyTestInstance.prisma.user.findFirst = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('test')) as typeof original;
|
||||
|
||||
const res = await post('/apps/classroom/get-user-id').send({
|
||||
email: defaultUserEmail
|
||||
});
|
||||
|
||||
fastifyTestInstance.prisma.user.findFirst = original;
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body).toStrictEqual({
|
||||
error: 'Failed to retrieve user id'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /apps/classroom/get-user-data', () => {
|
||||
test('returns 400 when more than 50 userIds are provided', async () => {
|
||||
const tooMany = Array.from(
|
||||
{ length: 51 },
|
||||
(_, i) => `${String(i).padStart(24, '0')}`
|
||||
);
|
||||
|
||||
const res = await post('/apps/classroom/get-user-data').send({
|
||||
userIds: tooMany
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('returns 200 with empty data for empty userIds array', async () => {
|
||||
const res = await post('/apps/classroom/get-user-data').send({
|
||||
userIds: []
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toStrictEqual({ data: {} });
|
||||
});
|
||||
|
||||
test('returns data only for classroom accounts', async () => {
|
||||
const now = Date.now();
|
||||
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: {
|
||||
isClassroomAccount: true,
|
||||
completedChallenges: [
|
||||
{
|
||||
id: 'challenge-default',
|
||||
completedDate: now,
|
||||
files: []
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
await fastifyTestInstance.prisma.user.create({
|
||||
data: {
|
||||
...createUserInput(classroomUserEmail),
|
||||
id: classroomUserId,
|
||||
isClassroomAccount: true,
|
||||
completedChallenges: [
|
||||
{
|
||||
id: 'challenge-student',
|
||||
completedDate: now + 1,
|
||||
files: []
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
await fastifyTestInstance.prisma.user.create({
|
||||
data: {
|
||||
...createUserInput(nonClassroomUserEmail),
|
||||
id: nonClassroomUserId,
|
||||
isClassroomAccount: false,
|
||||
completedChallenges: []
|
||||
}
|
||||
});
|
||||
|
||||
const res = await post('/apps/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]).toStrictEqual({
|
||||
id: 'challenge-default',
|
||||
completedDate: now
|
||||
});
|
||||
expect(responseBody.data[classroomUserId]?.[0]).toStrictEqual({
|
||||
id: 'challenge-student',
|
||||
completedDate: now + 1
|
||||
});
|
||||
});
|
||||
|
||||
test('response contains only id and completedDate', async () => {
|
||||
const now = Date.now();
|
||||
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: {
|
||||
isClassroomAccount: true,
|
||||
completedChallenges: [
|
||||
{
|
||||
id: 'challenge-shape-test',
|
||||
completedDate: now,
|
||||
solution: 'http://example.com/solution',
|
||||
files: [
|
||||
{
|
||||
contents: 'some code',
|
||||
ext: 'js',
|
||||
key: 'indexjs',
|
||||
name: 'index'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const res = await post('/apps/classroom/get-user-data').send({
|
||||
userIds: [defaultUserId]
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const responseBody = res.body as {
|
||||
data: Record<string, Array<Record<string, unknown>>>;
|
||||
};
|
||||
const challenge = responseBody.data[defaultUserId]![0]!;
|
||||
expect(Object.keys(challenge)).toStrictEqual(['id', 'completedDate']);
|
||||
});
|
||||
|
||||
test('returns 500 when the database query fails', async () => {
|
||||
const original = fastifyTestInstance.prisma.user.findMany;
|
||||
fastifyTestInstance.prisma.user.findMany = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('test')) as typeof original;
|
||||
|
||||
const res = await post('/apps/classroom/get-user-data').send({
|
||||
userIds: [defaultUserId]
|
||||
});
|
||||
|
||||
fastifyTestInstance.prisma.user.findMany = original;
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body).toStrictEqual({
|
||||
error: 'Failed to retrieve user data'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
88
api/src/routes/apps/classroom.ts
Normal file
88
api/src/routes/apps/classroom.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
import { normalizeDate } from '../../utils/normalize.js';
|
||||
import * as schemas from '../../schemas.js';
|
||||
|
||||
/**
|
||||
* Routes for the classroom app integration.
|
||||
*
|
||||
* @param fastify The Fastify instance.
|
||||
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
|
||||
* @param done The callback to signal that the plugin is ready.
|
||||
*/
|
||||
export const classroomRoutes: FastifyPluginCallbackTypebox = (
|
||||
fastify,
|
||||
_options,
|
||||
done
|
||||
) => {
|
||||
fastify.post(
|
||||
'/get-user-id',
|
||||
{
|
||||
schema: schemas.classroomGetUserIdSchema
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { email } = request.body;
|
||||
|
||||
try {
|
||||
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' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/get-user-data',
|
||||
{
|
||||
schema: schemas.classroomGetUserDataSchema
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { userIds } = request.body;
|
||||
|
||||
try {
|
||||
const users = await fastify.prisma.user.findMany({
|
||||
where: {
|
||||
id: { in: userIds },
|
||||
isClassroomAccount: true
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
completedChallenges: true
|
||||
}
|
||||
});
|
||||
|
||||
const userData: Record<
|
||||
string,
|
||||
{ id: string; completedDate: number }[]
|
||||
> = {};
|
||||
|
||||
users.forEach(user => {
|
||||
userData[user.id] = user.completedChallenges.map(challenge => ({
|
||||
id: challenge.id,
|
||||
completedDate: normalizeDate(challenge.completedDate)
|
||||
}));
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
data: userData
|
||||
});
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.code(500).send({ error: 'Failed to retrieve user data' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
done();
|
||||
};
|
||||
@@ -1,217 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
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,6 +1,5 @@
|
||||
export * from './certificate.js';
|
||||
export * from './challenge.js';
|
||||
export * from './classroom.js';
|
||||
export * from './donate.js';
|
||||
export * from './settings.js';
|
||||
export * from './user.js';
|
||||
|
||||
@@ -51,3 +51,7 @@ export {
|
||||
} from './schemas/user/exam-environment-token.js';
|
||||
export { sentryPostEvent } from './schemas/sentry/event.js';
|
||||
export { signout } from './schemas/signout/signout.js';
|
||||
export {
|
||||
classroomGetUserIdSchema,
|
||||
classroomGetUserDataSchema
|
||||
} from './schemas/classroom/classroom.js';
|
||||
|
||||
@@ -1,30 +1,37 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
export const classroomGetUserIdSchema = {
|
||||
body: Type.Object({
|
||||
email: Type.String({ maxLength: 1024 })
|
||||
email: Type.String({ format: 'email', maxLength: 1024 })
|
||||
}),
|
||||
response: {
|
||||
200: Type.Object({ userId: Type.String() }),
|
||||
400: Type.Object({ error: Type.String() }),
|
||||
401: Type.Object({ error: Type.String() }),
|
||||
500: Type.Object({ error: Type.String() })
|
||||
}
|
||||
};
|
||||
export const classroomGetUserDataSchema = {
|
||||
body: Type.Object({
|
||||
userIds: Type.Array(Type.String())
|
||||
userIds: Type.Array(
|
||||
Type.String({ format: 'objectid', maxLength: 24, minLength: 24 }),
|
||||
{ maxItems: 50 }
|
||||
)
|
||||
}),
|
||||
response: {
|
||||
200: Type.Object({
|
||||
data: Type.Record(
|
||||
Type.String(),
|
||||
Type.String({ maxLength: 24 }),
|
||||
Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String(),
|
||||
completedDate: Type.Number()
|
||||
})
|
||||
)
|
||||
),
|
||||
{ propertyNames: { maxLength: 24 } }
|
||||
)
|
||||
}),
|
||||
400: Type.Object({ error: Type.String() }),
|
||||
401: Type.Object({ error: Type.String() }),
|
||||
500: Type.Object({ error: Type.String() })
|
||||
}
|
||||
};
|
||||
|
||||
@@ -158,6 +158,15 @@ if (process.env.FREECODECAMP_NODE_ENV !== 'development') {
|
||||
'fastify_api_sdk_client_key_from_growthbook_dashboard',
|
||||
'The GROWTHBOOK_FASTIFY_CLIENT_KEY env should be changed from the default value.'
|
||||
);
|
||||
assert.ok(
|
||||
process.env.TPA_API_BEARER_TOKEN,
|
||||
'TPA_API_BEARER_TOKEN should be set.'
|
||||
);
|
||||
assert.notEqual(
|
||||
process.env.TPA_API_BEARER_TOKEN,
|
||||
'tpa_api_bearer_token_from_dashboard',
|
||||
'The TPA_API_BEARER_TOKEN env should be changed from the default value.'
|
||||
);
|
||||
}
|
||||
|
||||
export const HOME_LOCATION = process.env.HOME_LOCATION;
|
||||
@@ -217,6 +226,7 @@ export const GROWTHBOOK_FASTIFY_API_HOST =
|
||||
process.env.GROWTHBOOK_FASTIFY_API_HOST;
|
||||
export const GROWTHBOOK_FASTIFY_CLIENT_KEY =
|
||||
process.env.GROWTHBOOK_FASTIFY_CLIENT_KEY;
|
||||
export const TPA_API_BEARER_TOKEN = process.env.TPA_API_BEARER_TOKEN;
|
||||
|
||||
function undefinedOrBool(val: string | undefined): undefined | boolean {
|
||||
if (!val) {
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
"SES_REGION",
|
||||
"SES_SECRET",
|
||||
"SHOW_UPCOMING_CHANGES",
|
||||
"STRIPE_SECRET_KEY"
|
||||
"STRIPE_SECRET_KEY",
|
||||
"TPA_API_BEARER_TOKEN"
|
||||
]
|
||||
},
|
||||
"test": {
|
||||
@@ -72,7 +73,8 @@
|
||||
"SES_REGION",
|
||||
"SES_SECRET",
|
||||
"SHOW_UPCOMING_CHANGES",
|
||||
"STRIPE_SECRET_KEY"
|
||||
"STRIPE_SECRET_KEY",
|
||||
"TPA_API_BEARER_TOKEN"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ ALGOLIA_API_KEY=api_key_from_algolia_dashboard
|
||||
STRIPE_PUBLIC_KEY=pk_from_stripe_dashboard
|
||||
STRIPE_SECRET_KEY=sk_from_stripe_dashboard
|
||||
|
||||
# Third-party App API
|
||||
TPA_API_BEARER_TOKEN=tpa_api_bearer_token_from_dashboard
|
||||
|
||||
# PayPal
|
||||
PAYPAL_CLIENT_ID=id_from_paypal_dashboard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user