feat(api): classroom service-to-service endpoints

This commit is contained in:
Mrugesh Mohapatra 2026-05-29 09:07:37 +05:30
parent fba95576a3
commit fff6213b6c
No known key found for this signature in database
10 changed files with 593 additions and 2 deletions

View File

@ -29,9 +29,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,
@ -172,6 +174,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) {
@ -234,6 +237,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);
}

View 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' });
});
});

View 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' });

View 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'
});
});
});
});
});

View 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();
};

View File

@ -54,3 +54,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';

View File

@ -0,0 +1,37 @@
import { Type } from '@fastify/type-provider-typebox';
export const classroomGetUserIdSchema = {
body: Type.Object({
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({ format: 'objectid', maxLength: 24, minLength: 24 }),
{ maxItems: 50 }
)
}),
response: {
200: Type.Object({
data: Type.Record(
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() })
}
};

View File

@ -166,6 +166,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;
@ -228,6 +237,7 @@ export const GROWTHBOOK_FASTIFY_CLIENT_KEY =
process.env.GROWTHBOOK_FASTIFY_CLIENT_KEY;
export const SOCRATES_API_KEY = process.env.SOCRATES_API_KEY;
export const SOCRATES_ENDPOINT = process.env.SOCRATES_ENDPOINT;
export const TPA_API_BEARER_TOKEN = process.env.TPA_API_BEARER_TOKEN;
function undefinedOrBool(val: string | undefined): undefined | boolean {
if (!val) {

View File

@ -40,7 +40,8 @@
"SHOW_UPCOMING_CHANGES",
"SOCRATES_API_KEY",
"SOCRATES_ENDPOINT",
"STRIPE_SECRET_KEY"
"STRIPE_SECRET_KEY",
"TPA_API_BEARER_TOKEN"
]
},
"test": {
@ -82,7 +83,8 @@
"SHOW_UPCOMING_CHANGES",
"SOCRATES_API_KEY",
"SOCRATES_ENDPOINT",
"STRIPE_SECRET_KEY"
"STRIPE_SECRET_KEY",
"TPA_API_BEARER_TOKEN"
]
}
}

View File

@ -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