mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-03-26 08:01:08 -04:00
feat: base64 encode file contents when making api requests (#62006)
Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
This commit is contained in:
committed by
GitHub
parent
10c565828e
commit
8cd2efe570
@@ -7,7 +7,10 @@ import type {
|
||||
import { createFetchMock } from '../../../vitest.utils';
|
||||
import {
|
||||
canSubmitCodeRoadCertProject,
|
||||
verifyTrophyWithMicrosoft
|
||||
verifyTrophyWithMicrosoft,
|
||||
decodeFiles,
|
||||
decodeBase64,
|
||||
encodeBase64
|
||||
} from './challenge-helpers';
|
||||
|
||||
const id = 'abc';
|
||||
@@ -166,4 +169,83 @@ describe('Challenge Helpers', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeFiles', () => {
|
||||
test('decodes base64 encoded file contents', () => {
|
||||
const encodedFiles = [
|
||||
{
|
||||
contents: btoa('console.log("Hello, world!");')
|
||||
},
|
||||
{
|
||||
contents: btoa('<h1>Hello, world!</h1>')
|
||||
}
|
||||
];
|
||||
|
||||
const decodedFiles = decodeFiles(encodedFiles);
|
||||
|
||||
expect(decodedFiles).toEqual([
|
||||
{
|
||||
contents: 'console.log("Hello, world!");'
|
||||
},
|
||||
{
|
||||
contents: '<h1>Hello, world!</h1>'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test('leaves all other file properties unchanged', () => {
|
||||
const encodedFiles = [
|
||||
{
|
||||
contents: btoa('console.log("Hello, world!");'),
|
||||
ext: '.js',
|
||||
history: [],
|
||||
key: 'file1',
|
||||
name: 'hello.js'
|
||||
}
|
||||
];
|
||||
|
||||
const decodedFiles = decodeFiles(encodedFiles);
|
||||
|
||||
expect(decodedFiles).toEqual([
|
||||
{
|
||||
contents: 'console.log("Hello, world!");',
|
||||
ext: '.js',
|
||||
history: [],
|
||||
key: 'file1',
|
||||
name: 'hello.js'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test('can handle unicode characters', () => {
|
||||
const encodedFiles = [
|
||||
{
|
||||
contents: encodeBase64('console.log("Hello, ✅🚀!");')
|
||||
}
|
||||
];
|
||||
|
||||
const decodedFiles = decodeFiles(encodedFiles);
|
||||
|
||||
expect(decodedFiles).toEqual([
|
||||
{
|
||||
contents: 'console.log("Hello, ✅🚀!");'
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeBase64', () => {
|
||||
test('decodes a base64 encoded string', () => {
|
||||
const encoded = encodeBase64('Hello, world!');
|
||||
const decoded = decodeBase64(encoded);
|
||||
expect(decoded).toBe('Hello, world!');
|
||||
});
|
||||
|
||||
test('can handle unicode characters', () => {
|
||||
const original = 'Hello, ✅🚀!';
|
||||
const encoded = encodeBase64(original);
|
||||
const decoded = decodeBase64(encoded);
|
||||
expect(decoded).toBe(original);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,3 +140,36 @@ export async function verifyTrophyWithMicrosoft({
|
||||
} as NoTrophyError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic helper to decode an array of base64 encoded file objects.
|
||||
*
|
||||
* @param files Array of file-like objects each having a base64 encoded `contents` string.
|
||||
* @returns The same array shape with `contents` decoded.
|
||||
*/
|
||||
export function decodeFiles<T extends { contents: string }>(files: T[]): T[] {
|
||||
return files.map(file => ({
|
||||
...file,
|
||||
contents: decodeBase64(file.contents)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a base64 encoded string into a UTF-8 string.
|
||||
*
|
||||
* @param str The base64 encoded string to decode.
|
||||
* @returns The decoded UTF-8 string.
|
||||
*/
|
||||
export function decodeBase64(str: string): string {
|
||||
return Buffer.from(str, 'base64').toString('utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes a UTF-8 string into a base64 encoded string.
|
||||
*
|
||||
* @param str The UTF-8 string to encode.
|
||||
* @returns The base64 encoded string.
|
||||
*/
|
||||
export function encodeBase64(str: string): string {
|
||||
return Buffer.from(str, 'utf8').toString('base64');
|
||||
}
|
||||
|
||||
@@ -105,60 +105,105 @@ const HtmlChallengeBody = {
|
||||
challengeType: challengeTypes.html,
|
||||
id: HtmlChallengeId
|
||||
};
|
||||
const JsProjectBody = {
|
||||
|
||||
const baseJsProjectBody = {
|
||||
challengeType: challengeTypes.jsProject,
|
||||
id: JsProjectId,
|
||||
files: [
|
||||
{
|
||||
contents: 'console.log("Hello There!")',
|
||||
key: 'scriptjs',
|
||||
ext: 'js',
|
||||
name: 'script',
|
||||
history: ['script.js']
|
||||
}
|
||||
]
|
||||
id: JsProjectId
|
||||
};
|
||||
const multiFileCertProjectBody = {
|
||||
|
||||
const jsFiles = [
|
||||
{
|
||||
contents: 'console.log("Hello There!")',
|
||||
key: 'scriptjs',
|
||||
ext: 'js',
|
||||
name: 'script',
|
||||
history: ['script.js']
|
||||
}
|
||||
];
|
||||
|
||||
const encodedJsFiles = [
|
||||
{
|
||||
contents: btoa('console.log("Hello There!")'),
|
||||
key: 'scriptjs',
|
||||
ext: 'js',
|
||||
name: 'script',
|
||||
history: ['script.js']
|
||||
}
|
||||
];
|
||||
|
||||
const baseMultiFileCertProjectBody = {
|
||||
challengeType: challengeTypes.multifileCertProject,
|
||||
id: multiFileCertProjectId,
|
||||
files: [
|
||||
{
|
||||
contents: '<h1>Multi File Project v1</h1>',
|
||||
key: 'indexhtml',
|
||||
ext: 'html',
|
||||
name: 'index',
|
||||
history: ['index.html']
|
||||
},
|
||||
{
|
||||
contents: '.hello-there { general: kenobi; }',
|
||||
key: 'stylescss',
|
||||
ext: 'css',
|
||||
name: 'styles',
|
||||
history: ['styles.css']
|
||||
}
|
||||
]
|
||||
};
|
||||
const updatedMultiFileCertProjectBody = {
|
||||
challengeType: challengeTypes.multifileCertProject,
|
||||
id: multiFileCertProjectId,
|
||||
files: [
|
||||
{
|
||||
contents: '<h1>Multi File Project v2</h1>',
|
||||
key: 'indexhtml',
|
||||
ext: 'html',
|
||||
name: 'index',
|
||||
history: ['index.html']
|
||||
},
|
||||
{
|
||||
contents: '.wibbly-wobbly { timey: wimey; }',
|
||||
key: 'stylescss',
|
||||
ext: 'css',
|
||||
name: 'styles',
|
||||
history: ['styles.css']
|
||||
}
|
||||
]
|
||||
id: multiFileCertProjectId
|
||||
};
|
||||
|
||||
const multiFiles = [
|
||||
{
|
||||
contents: '<h1>Multi File Project v1</h1>',
|
||||
key: 'indexhtml',
|
||||
ext: 'html',
|
||||
name: 'index',
|
||||
history: ['index.html']
|
||||
},
|
||||
{
|
||||
contents: '.hello-there { general: kenobi; }',
|
||||
key: 'stylescss',
|
||||
ext: 'css',
|
||||
name: 'styles',
|
||||
history: ['styles.css']
|
||||
}
|
||||
];
|
||||
|
||||
const updatedMultiFiles = [
|
||||
{
|
||||
contents: '<h1>Multi File Project v2</h1>',
|
||||
key: 'indexhtml',
|
||||
ext: 'html',
|
||||
name: 'index',
|
||||
history: ['index.html']
|
||||
},
|
||||
{
|
||||
contents: '.wibbly-wobbly { timey: wimey; }',
|
||||
key: 'stylescss',
|
||||
ext: 'css',
|
||||
name: 'styles',
|
||||
history: ['styles.css']
|
||||
}
|
||||
];
|
||||
|
||||
const encodedMultiFiles = [
|
||||
{
|
||||
contents: btoa('<h1>Multi File Project v1</h1>'),
|
||||
key: 'indexhtml',
|
||||
ext: 'html',
|
||||
name: 'index',
|
||||
history: ['index.html']
|
||||
},
|
||||
{
|
||||
contents: btoa('.hello-there { general: kenobi; }'),
|
||||
key: 'stylescss',
|
||||
ext: 'css',
|
||||
name: 'styles',
|
||||
history: ['styles.css']
|
||||
}
|
||||
];
|
||||
|
||||
const encodedUpdatedMultiFiles = [
|
||||
{
|
||||
contents: btoa('<h1>Multi File Project v2</h1>'),
|
||||
key: 'indexhtml',
|
||||
ext: 'html',
|
||||
name: 'index',
|
||||
history: ['index.html']
|
||||
},
|
||||
{
|
||||
contents: btoa('.wibbly-wobbly { timey: wimey; }'),
|
||||
key: 'stylescss',
|
||||
ext: 'css',
|
||||
name: 'styles',
|
||||
history: ['styles.css']
|
||||
}
|
||||
];
|
||||
|
||||
const dailyCodingChallengeId = '5900f36e1000cf542c50fe80';
|
||||
const dailyCodingChallengeBody = {
|
||||
id: dailyCodingChallengeId,
|
||||
@@ -755,6 +800,16 @@ describe('challengeRoutes', () => {
|
||||
});
|
||||
|
||||
describe('handling', () => {
|
||||
beforeEach(async () => {
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { email: 'foo@bar.com' },
|
||||
data: {
|
||||
completedChallenges: [],
|
||||
savedChallenges: [],
|
||||
progressTimestamps: []
|
||||
}
|
||||
});
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { email: 'foo@bar.com' },
|
||||
@@ -804,21 +859,22 @@ describe('challengeRoutes', () => {
|
||||
test('POST accepts challenges with files present', async () => {
|
||||
const now = Date.now();
|
||||
|
||||
const response = await superPost('/modern-challenge-completed').send(
|
||||
JsProjectBody
|
||||
);
|
||||
const response = await superPost('/modern-challenge-completed').send({
|
||||
...baseJsProjectBody,
|
||||
files: jsFiles
|
||||
});
|
||||
|
||||
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
|
||||
where: { email: 'foo@bar.com' }
|
||||
});
|
||||
|
||||
const file = omit(JsProjectBody.files[0], 'history');
|
||||
const file = omit(jsFiles[0], 'history');
|
||||
|
||||
expect(user).toMatchObject({
|
||||
completedChallenges: [
|
||||
{
|
||||
id: JsProjectId,
|
||||
challengeType: JsProjectBody.challengeType,
|
||||
challengeType: baseJsProjectBody.challengeType,
|
||||
files: [file],
|
||||
completedDate: expect.any(Number)
|
||||
}
|
||||
@@ -841,15 +897,16 @@ describe('challengeRoutes', () => {
|
||||
test('POST accepts challenges with saved solutions', async () => {
|
||||
const now = Date.now();
|
||||
|
||||
const response = await superPost('/modern-challenge-completed').send(
|
||||
multiFileCertProjectBody
|
||||
);
|
||||
const response = await superPost('/modern-challenge-completed').send({
|
||||
...baseMultiFileCertProjectBody,
|
||||
files: multiFiles
|
||||
});
|
||||
|
||||
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
|
||||
where: { email: 'foo@bar.com' }
|
||||
});
|
||||
|
||||
const testFiles = multiFileCertProjectBody.files.map(
|
||||
const testFiles = multiFiles.map(
|
||||
({ history: _history, ...rest }) => rest
|
||||
);
|
||||
|
||||
@@ -858,7 +915,7 @@ describe('challengeRoutes', () => {
|
||||
completedChallenges: [
|
||||
{
|
||||
id: multiFileCertProjectId,
|
||||
challengeType: multiFileCertProjectBody.challengeType,
|
||||
challengeType: baseMultiFileCertProjectBody.challengeType,
|
||||
files: testFiles,
|
||||
completedDate: expect.any(Number),
|
||||
isManuallyApproved: false
|
||||
@@ -868,7 +925,7 @@ describe('challengeRoutes', () => {
|
||||
{
|
||||
id: multiFileCertProjectId,
|
||||
lastSavedDate: expect.any(Number),
|
||||
files: multiFileCertProjectBody.files
|
||||
files: multiFiles
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -885,7 +942,7 @@ describe('challengeRoutes', () => {
|
||||
{
|
||||
id: multiFileCertProjectId,
|
||||
lastSavedDate: completedDate,
|
||||
files: multiFileCertProjectBody.files
|
||||
files: multiFiles
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -895,14 +952,14 @@ describe('challengeRoutes', () => {
|
||||
test('POST correctly handles multiple requests', async () => {
|
||||
const resOriginal = await superPost(
|
||||
'/modern-challenge-completed'
|
||||
).send(multiFileCertProjectBody);
|
||||
).send({ ...baseMultiFileCertProjectBody, files: multiFiles });
|
||||
|
||||
await superPost('/modern-challenge-completed').send(
|
||||
HtmlChallengeBody
|
||||
);
|
||||
|
||||
const resUpdate = await superPost('/modern-challenge-completed').send(
|
||||
updatedMultiFileCertProjectBody
|
||||
{ ...baseMultiFileCertProjectBody, files: updatedMultiFiles }
|
||||
);
|
||||
|
||||
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
|
||||
@@ -913,7 +970,7 @@ describe('challengeRoutes', () => {
|
||||
challenge => challenge.completedDate
|
||||
);
|
||||
|
||||
const testFiles = updatedMultiFileCertProjectBody.files.map(file =>
|
||||
const testFiles = updatedMultiFiles.map(file =>
|
||||
omit(file, 'history')
|
||||
);
|
||||
|
||||
@@ -922,7 +979,7 @@ describe('challengeRoutes', () => {
|
||||
completedChallenges: [
|
||||
{
|
||||
id: multiFileCertProjectId,
|
||||
challengeType: updatedMultiFileCertProjectBody.challengeType,
|
||||
challengeType: baseMultiFileCertProjectBody.challengeType,
|
||||
files: testFiles,
|
||||
completedDate: expect.any(Number),
|
||||
isManuallyApproved: false
|
||||
@@ -936,7 +993,7 @@ describe('challengeRoutes', () => {
|
||||
{
|
||||
id: multiFileCertProjectId,
|
||||
lastSavedDate: expect.any(Number),
|
||||
files: updatedMultiFileCertProjectBody.files
|
||||
files: updatedMultiFiles
|
||||
}
|
||||
],
|
||||
progressTimestamps: expectedProgressTimestamps
|
||||
@@ -957,7 +1014,7 @@ describe('challengeRoutes', () => {
|
||||
{
|
||||
id: multiFileCertProjectId,
|
||||
lastSavedDate: expect.any(Number),
|
||||
files: updatedMultiFileCertProjectBody.files
|
||||
files: updatedMultiFiles
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -966,6 +1023,197 @@ describe('challengeRoutes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('/encoded/modern-challenge-completed', () => {
|
||||
beforeEach(async () => {
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { email: 'foo@bar.com' },
|
||||
data: {
|
||||
completedChallenges: [],
|
||||
savedChallenges: [],
|
||||
progressTimestamps: []
|
||||
}
|
||||
});
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { email: 'foo@bar.com' },
|
||||
data: {
|
||||
completedChallenges: [],
|
||||
savedChallenges: [],
|
||||
progressTimestamps: []
|
||||
}
|
||||
});
|
||||
});
|
||||
// JS Project(5), Multi-file Cert Project(14)
|
||||
test('POST accepts challenges with files present', async () => {
|
||||
const now = Date.now();
|
||||
|
||||
const response = await superPost(
|
||||
'/encoded/modern-challenge-completed'
|
||||
).send({ ...baseJsProjectBody, files: encodedJsFiles });
|
||||
|
||||
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
|
||||
where: { email: 'foo@bar.com' }
|
||||
});
|
||||
|
||||
const file = omit(jsFiles[0], 'history');
|
||||
|
||||
expect(user).toMatchObject({
|
||||
completedChallenges: [
|
||||
{
|
||||
id: JsProjectId,
|
||||
challengeType: baseJsProjectBody.challengeType,
|
||||
files: [file],
|
||||
completedDate: expect.any(Number)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const completedDate = user.completedChallenges[0]?.completedDate;
|
||||
expect(completedDate).toBeGreaterThanOrEqual(now);
|
||||
expect(completedDate).toBeLessThanOrEqual(now + 1000);
|
||||
|
||||
expect(response.body).toStrictEqual({
|
||||
alreadyCompleted: false,
|
||||
points: 1,
|
||||
completedDate,
|
||||
savedChallenges: []
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
test('POST accepts challenges with saved solutions', async () => {
|
||||
const now = Date.now();
|
||||
|
||||
const response = await superPost(
|
||||
'/encoded/modern-challenge-completed'
|
||||
).send({
|
||||
...baseMultiFileCertProjectBody,
|
||||
files: encodedUpdatedMultiFiles
|
||||
});
|
||||
|
||||
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
|
||||
where: { email: 'foo@bar.com' }
|
||||
});
|
||||
|
||||
const testFiles = updatedMultiFiles.map(
|
||||
({ history: _history, ...rest }) => rest
|
||||
);
|
||||
|
||||
expect(user).toMatchObject({
|
||||
needsModeration: true,
|
||||
completedChallenges: [
|
||||
{
|
||||
id: multiFileCertProjectId,
|
||||
challengeType: baseMultiFileCertProjectBody.challengeType,
|
||||
files: testFiles,
|
||||
completedDate: expect.any(Number),
|
||||
isManuallyApproved: false
|
||||
}
|
||||
],
|
||||
savedChallenges: [
|
||||
{
|
||||
id: multiFileCertProjectId,
|
||||
lastSavedDate: expect.any(Number),
|
||||
files: updatedMultiFiles
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const completedDate = user.completedChallenges[0]?.completedDate;
|
||||
expect(completedDate).toBeGreaterThanOrEqual(now);
|
||||
expect(completedDate).toBeLessThanOrEqual(now + 1000);
|
||||
|
||||
expect(response.body).toStrictEqual({
|
||||
alreadyCompleted: false,
|
||||
points: 1,
|
||||
completedDate,
|
||||
savedChallenges: [
|
||||
{
|
||||
id: multiFileCertProjectId,
|
||||
lastSavedDate: completedDate,
|
||||
files: updatedMultiFiles
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
test('POST correctly handles multiple requests', async () => {
|
||||
const resOriginal = await superPost(
|
||||
'/encoded/modern-challenge-completed'
|
||||
).send({
|
||||
...baseMultiFileCertProjectBody,
|
||||
files: encodedMultiFiles
|
||||
});
|
||||
|
||||
await superPost('/encoded/modern-challenge-completed').send(
|
||||
HtmlChallengeBody
|
||||
);
|
||||
|
||||
const resUpdate = await superPost(
|
||||
'/encoded/modern-challenge-completed'
|
||||
).send({
|
||||
...baseMultiFileCertProjectBody,
|
||||
files: encodedUpdatedMultiFiles
|
||||
});
|
||||
|
||||
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
|
||||
where: { email: 'foo@bar.com' }
|
||||
});
|
||||
|
||||
const expectedProgressTimestamps = user.completedChallenges.map(
|
||||
challenge => challenge.completedDate
|
||||
);
|
||||
|
||||
const testFiles = updatedMultiFiles.map(file => omit(file, 'history'));
|
||||
|
||||
expect(user).toMatchObject({
|
||||
needsModeration: true,
|
||||
completedChallenges: [
|
||||
{
|
||||
id: multiFileCertProjectId,
|
||||
challengeType: baseMultiFileCertProjectBody.challengeType,
|
||||
files: testFiles,
|
||||
completedDate: expect.any(Number),
|
||||
isManuallyApproved: false
|
||||
},
|
||||
{
|
||||
id: HtmlChallengeId,
|
||||
completedDate: expect.any(Number)
|
||||
}
|
||||
],
|
||||
savedChallenges: [
|
||||
{
|
||||
id: multiFileCertProjectId,
|
||||
lastSavedDate: expect.any(Number),
|
||||
files: updatedMultiFiles
|
||||
}
|
||||
],
|
||||
progressTimestamps: expectedProgressTimestamps
|
||||
});
|
||||
|
||||
expect(resUpdate.body.savedChallenges[0].lastSavedDate).toBeGreaterThan(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
resOriginal.body.savedChallenges[0].lastSavedDate
|
||||
);
|
||||
|
||||
expect(resUpdate.body).toStrictEqual({
|
||||
alreadyCompleted: true,
|
||||
points: 2,
|
||||
completedDate: expect.any(Number),
|
||||
savedChallenges: [
|
||||
{
|
||||
id: multiFileCertProjectId,
|
||||
lastSavedDate: expect.any(Number),
|
||||
files: updatedMultiFiles
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(resUpdate.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/daily-coding-challenge-completed', () => {
|
||||
describe('validation', () => {
|
||||
test('POST rejects requests without an id', async () => {
|
||||
@@ -1171,7 +1419,7 @@ describe('challengeRoutes', () => {
|
||||
savedChallenges: {
|
||||
// valid mongo id, but not a saveable one
|
||||
id: 'aaaaaaaaaaaaaaaaaaaaaaa',
|
||||
files: multiFileCertProjectBody.files
|
||||
files: multiFiles
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1196,7 +1444,7 @@ describe('challengeRoutes', () => {
|
||||
test('rejects requests for challenges that cannot be saved', async () => {
|
||||
const response = await superPost('/save-challenge').send({
|
||||
id: '66ebd4ae2812430bb883c786',
|
||||
files: multiFileCertProjectBody.files
|
||||
files: multiFiles
|
||||
});
|
||||
|
||||
const { savedChallenges } =
|
||||
@@ -1212,7 +1460,7 @@ describe('challengeRoutes', () => {
|
||||
test('update the user savedchallenges and return them', async () => {
|
||||
const response = await superPost('/save-challenge').send({
|
||||
id: multiFileCertProjectId,
|
||||
files: updatedMultiFileCertProjectBody.files
|
||||
files: updatedMultiFiles
|
||||
});
|
||||
|
||||
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
|
||||
@@ -1226,7 +1474,7 @@ describe('challengeRoutes', () => {
|
||||
{
|
||||
id: multiFileCertProjectId,
|
||||
lastSavedDate: savedDate,
|
||||
files: updatedMultiFileCertProjectBody.files
|
||||
files: updatedMultiFiles
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -1235,7 +1483,7 @@ describe('challengeRoutes', () => {
|
||||
{
|
||||
id: multiFileCertProjectId,
|
||||
lastSavedDate: savedDate,
|
||||
files: updatedMultiFileCertProjectBody.files
|
||||
files: updatedMultiFiles
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -1244,6 +1492,57 @@ describe('challengeRoutes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /encoded/save-challenge', () => {
|
||||
test('rejects requests for challenges that cannot be saved', async () => {
|
||||
const response = await superPost('/encoded/save-challenge').send({
|
||||
id: '66ebd4ae2812430bb883c786',
|
||||
files: encodedUpdatedMultiFiles
|
||||
});
|
||||
|
||||
const { savedChallenges } =
|
||||
await fastifyTestInstance.prisma.user.findFirstOrThrow({
|
||||
where: { email: 'foo@bar.com' }
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.text).toEqual('That challenge type is not saveable.');
|
||||
expect(savedChallenges).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('update the user savedchallenges and return them', async () => {
|
||||
const response = await superPost('/encoded/save-challenge').send({
|
||||
id: multiFileCertProjectId,
|
||||
files: encodedUpdatedMultiFiles
|
||||
});
|
||||
|
||||
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
|
||||
where: { email: 'foo@bar.com' }
|
||||
});
|
||||
|
||||
const savedDate = user.savedChallenges[0]?.lastSavedDate;
|
||||
|
||||
expect(user).toMatchObject({
|
||||
savedChallenges: [
|
||||
{
|
||||
id: multiFileCertProjectId,
|
||||
lastSavedDate: savedDate,
|
||||
files: updatedMultiFiles
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(response.body).toEqual({
|
||||
savedChallenges: [
|
||||
{
|
||||
id: multiFileCertProjectId,
|
||||
lastSavedDate: savedDate,
|
||||
files: updatedMultiFiles
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /exam/:id', () => {
|
||||
beforeAll(async () => {
|
||||
await seedExam();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { uniqBy, matches } from 'lodash';
|
||||
import { CompletedExam, ExamResults } from '@prisma/client';
|
||||
import { CompletedExam, ExamResults, SavedChallengeFile } from '@prisma/client';
|
||||
import isURL from 'validator/lib/isURL';
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import type { FastifyBaseLogger, FastifyInstance, FastifyReply } from 'fastify';
|
||||
|
||||
import { challengeTypes } from '../../../../shared/config/challenge-types';
|
||||
import * as schemas from '../../schemas';
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { generateRandomExam, createExamResults } from '../../utils/exam';
|
||||
import {
|
||||
canSubmitCodeRoadCertProject,
|
||||
decodeFiles,
|
||||
verifyTrophyWithMicrosoft
|
||||
} from '../helpers/challenge-helpers';
|
||||
import { UpdateReqType } from '../../utils';
|
||||
@@ -237,44 +238,49 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
);
|
||||
|
||||
const { id, files, challengeType } = req.body;
|
||||
|
||||
const user = await fastify.prisma.user.findUniqueOrThrow({
|
||||
where: { id: req.user?.id },
|
||||
select: userChallengeSelect
|
||||
});
|
||||
const RawProgressTimestamp = user.progressTimestamps as
|
||||
| ProgressTimestamp[]
|
||||
| null;
|
||||
const points = getPoints(RawProgressTimestamp);
|
||||
|
||||
const completedChallenge: CompletedChallenge = {
|
||||
return await postModernChallengeCompleted(fastify, {
|
||||
id,
|
||||
files,
|
||||
completedDate: Date.now()
|
||||
};
|
||||
challengeType,
|
||||
userId: req.user!.id
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
if (challengeType === challengeTypes.multifileCertProject) {
|
||||
completedChallenge.isManuallyApproved = false;
|
||||
user.needsModeration = true;
|
||||
fastify.post(
|
||||
'/encoded/modern-challenge-completed',
|
||||
{
|
||||
schema: schemas.modernChallengeCompleted,
|
||||
errorHandler(error, req, reply) {
|
||||
if (error.validation) {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
// This is another highly used route, so debug log level is used to
|
||||
// avoid excessive logging
|
||||
logger.debug({ validationError: error.validation });
|
||||
void reply.code(400);
|
||||
return formatProjectCompletedValidation(error.validation);
|
||||
} else {
|
||||
fastify.errorHandler(error, req, reply);
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
// This is another highly used route, so debug log level is used to
|
||||
// avoid excessive logging
|
||||
logger.debug(
|
||||
{ userId: req.user?.id },
|
||||
'User submitted a modern challenge'
|
||||
);
|
||||
|
||||
if (
|
||||
jsCertProjectIds.includes(id) ||
|
||||
multifileCertProjectIds.includes(id) ||
|
||||
multifilePythonCertProjectIds.includes(id)
|
||||
) {
|
||||
completedChallenge.challengeType = challengeType;
|
||||
}
|
||||
|
||||
const { alreadyCompleted, userSavedChallenges: savedChallenges } =
|
||||
await updateUserChallengeData(fastify, user, id, completedChallenge);
|
||||
|
||||
return {
|
||||
alreadyCompleted,
|
||||
points: alreadyCompleted ? points : points + 1,
|
||||
completedDate: completedChallenge.completedDate,
|
||||
savedChallenges
|
||||
};
|
||||
const { id, files: encodedFiles, challengeType } = req.body;
|
||||
const files = encodedFiles ? decodeFiles(encodedFiles) : undefined;
|
||||
return await postModernChallengeCompleted(fastify, {
|
||||
id,
|
||||
files,
|
||||
challengeType,
|
||||
userId: req.user!.id
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -319,43 +325,42 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
logger.info({ userId: req.user?.id }, 'User saved a challenge');
|
||||
|
||||
const { files, id: challengeId } = req.body;
|
||||
const user = await fastify.prisma.user.findUniqueOrThrow({
|
||||
where: { id: req.user?.id }
|
||||
});
|
||||
const challenge = {
|
||||
id: challengeId,
|
||||
files
|
||||
};
|
||||
|
||||
if (
|
||||
!multifileCertProjectIds.includes(challengeId) &&
|
||||
!multifilePythonCertProjectIds.includes(challengeId)
|
||||
) {
|
||||
logger.warn(
|
||||
{
|
||||
challengeId
|
||||
},
|
||||
'User tried to save a challenge that is not saveable'
|
||||
);
|
||||
return void reply
|
||||
.code(400)
|
||||
.send('That challenge type is not saveable.');
|
||||
}
|
||||
|
||||
const userSavedChallenges = saveUserChallengeData(
|
||||
challengeId,
|
||||
user.savedChallenges,
|
||||
challenge
|
||||
await postSaveChallenge(
|
||||
fastify,
|
||||
{ challengeId, files, userId: req.user!.id },
|
||||
logger,
|
||||
reply
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
savedChallenges: userSavedChallenges
|
||||
fastify.post(
|
||||
'/encoded/save-challenge',
|
||||
{
|
||||
schema: schemas.saveChallenge,
|
||||
errorHandler(error, req, reply) {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
if (error.validation) {
|
||||
logger.warn({ validationError: error.validation });
|
||||
void reply.code(400);
|
||||
return formatProjectCompletedValidation(error.validation);
|
||||
} else {
|
||||
fastify.errorHandler(error, req, reply);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
logger.info({ userId: req.user?.id }, 'User saved a challenge');
|
||||
|
||||
void reply.send({ savedChallenges: userSavedChallenges });
|
||||
const { files: encodedFiles, id: challengeId } = req.body;
|
||||
const files = decodeFiles(encodedFiles);
|
||||
await postSaveChallenge(
|
||||
fastify,
|
||||
{ challengeId, files, userId: req.user!.id },
|
||||
logger,
|
||||
reply
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1098,3 +1103,107 @@ async function postDailyCodingChallengeCompleted(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function postSaveChallenge(
|
||||
fastify: FastifyInstance,
|
||||
{
|
||||
challengeId,
|
||||
userId,
|
||||
files
|
||||
}: {
|
||||
challengeId: string;
|
||||
userId: string;
|
||||
files: SavedChallengeFile[];
|
||||
},
|
||||
logger: FastifyBaseLogger,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const user = await fastify.prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId }
|
||||
});
|
||||
const challenge = {
|
||||
id: challengeId,
|
||||
files
|
||||
};
|
||||
|
||||
if (
|
||||
!multifileCertProjectIds.includes(challengeId) &&
|
||||
!multifilePythonCertProjectIds.includes(challengeId)
|
||||
) {
|
||||
logger.warn(
|
||||
{
|
||||
challengeId
|
||||
},
|
||||
'User tried to save a challenge that is not saveable'
|
||||
);
|
||||
return void reply.code(400).send('That challenge type is not saveable.');
|
||||
}
|
||||
|
||||
const userSavedChallenges = saveUserChallengeData(
|
||||
challengeId,
|
||||
user.savedChallenges,
|
||||
challenge
|
||||
);
|
||||
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
savedChallenges: userSavedChallenges
|
||||
}
|
||||
});
|
||||
|
||||
void reply.send({ savedChallenges: userSavedChallenges });
|
||||
}
|
||||
|
||||
async function postModernChallengeCompleted(
|
||||
fastify: FastifyInstance,
|
||||
{
|
||||
id,
|
||||
userId,
|
||||
challengeType,
|
||||
files
|
||||
}: {
|
||||
id: string;
|
||||
userId: string;
|
||||
challengeType: number;
|
||||
files: CompletedChallenge['files'];
|
||||
}
|
||||
) {
|
||||
const user = await fastify.prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: userChallengeSelect
|
||||
});
|
||||
const RawProgressTimestamp = user.progressTimestamps as
|
||||
| ProgressTimestamp[]
|
||||
| null;
|
||||
const points = getPoints(RawProgressTimestamp);
|
||||
|
||||
const completedChallenge: CompletedChallenge = {
|
||||
id,
|
||||
files,
|
||||
completedDate: Date.now()
|
||||
};
|
||||
|
||||
if (challengeType === challengeTypes.multifileCertProject) {
|
||||
completedChallenge.isManuallyApproved = false;
|
||||
user.needsModeration = true;
|
||||
}
|
||||
|
||||
if (
|
||||
jsCertProjectIds.includes(id) ||
|
||||
multifileCertProjectIds.includes(id) ||
|
||||
multifilePythonCertProjectIds.includes(id)
|
||||
) {
|
||||
completedChallenge.challengeType = challengeType;
|
||||
}
|
||||
|
||||
const { alreadyCompleted, userSavedChallenges: savedChallenges } =
|
||||
await updateUserChallengeData(fastify, user, id, completedChallenge);
|
||||
|
||||
return {
|
||||
alreadyCompleted,
|
||||
points: alreadyCompleted ? points : points + 1,
|
||||
completedDate: completedChallenge.completedDate,
|
||||
savedChallenges
|
||||
};
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ function submitModern(type, state) {
|
||||
}
|
||||
|
||||
update = {
|
||||
endpoint: '/modern-challenge-completed',
|
||||
endpoint: '/encoded/modern-challenge-completed',
|
||||
payload: body
|
||||
};
|
||||
}
|
||||
|
||||
@@ -308,7 +308,7 @@ export function postSaveChallenge(body: {
|
||||
id: string;
|
||||
files: ChallengeFiles;
|
||||
}): Promise<ResponseWithData<void>> {
|
||||
return post('/save-challenge', body);
|
||||
return post('/encoded/save-challenge', body);
|
||||
}
|
||||
|
||||
export function postSubmitSurvey(body: {
|
||||
|
||||
@@ -27,6 +27,14 @@ interface Body {
|
||||
challengeType: number;
|
||||
}
|
||||
|
||||
const encodeBase64 = (str: string) => {
|
||||
const bytes = new TextEncoder().encode(str);
|
||||
const binaryString = Array.from(bytes, byte =>
|
||||
String.fromCodePoint(byte)
|
||||
).join('');
|
||||
return btoa(binaryString);
|
||||
};
|
||||
|
||||
export function standardizeRequestBody({
|
||||
id,
|
||||
challengeFiles = [],
|
||||
@@ -36,7 +44,7 @@ export function standardizeRequestBody({
|
||||
id,
|
||||
files: challengeFiles?.map(({ fileKey, contents, ext, name, history }) => {
|
||||
return {
|
||||
contents,
|
||||
contents: encodeBase64(contents),
|
||||
ext,
|
||||
history, // TODO(Post-MVP): stop sending history, if possible. The client
|
||||
// already gets it from the curriculum, so it should not be necessary to
|
||||
|
||||
Reference in New Issue
Block a user