mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-01 01:00:36 -04:00
1657 lines
52 KiB
TypeScript
1657 lines
52 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
|
import {
|
|
describe,
|
|
test,
|
|
expect,
|
|
beforeEach,
|
|
afterEach,
|
|
beforeAll,
|
|
afterAll,
|
|
vi,
|
|
MockInstance
|
|
} from 'vitest';
|
|
import jwt, { JwtPayload } from 'jsonwebtoken';
|
|
import { DailyCodingChallengeLanguage, type Prisma } from '@prisma/client';
|
|
import { ObjectId } from 'mongodb';
|
|
import { omit } from 'lodash-es';
|
|
|
|
import { createUserInput } from '../../utils/create-user.js';
|
|
import {
|
|
defaultUserId,
|
|
defaultUserEmail,
|
|
devLogin,
|
|
setupServer,
|
|
superRequest,
|
|
createSuperRequest,
|
|
defaultUsername,
|
|
resetDefaultUser
|
|
} from '../../../vitest.utils.js';
|
|
import { JWT_SECRET } from '../../utils/env.js';
|
|
import {
|
|
clearEnvExam,
|
|
seedEnvExam,
|
|
seedEnvExamAttempt,
|
|
seedExamEnvExamAuthToken
|
|
} from '../../../__mocks__/exam-environment-exam.js';
|
|
import { getMsTranscriptApiUrl } from './user.js';
|
|
|
|
const mockedFetch = vi.fn();
|
|
vi.spyOn(globalThis, 'fetch').mockImplementation(mockedFetch);
|
|
|
|
let mockDeploymentEnv = 'staging';
|
|
vi.mock('../../utils/env', async () => {
|
|
const actualEnv =
|
|
await vi.importActual<typeof import('../../utils/env.js')>(
|
|
'../../utils/env'
|
|
);
|
|
return {
|
|
...actualEnv,
|
|
get DEPLOYMENT_ENV() {
|
|
return mockDeploymentEnv;
|
|
},
|
|
JWT_SECRET: actualEnv.JWT_SECRET
|
|
};
|
|
});
|
|
|
|
// This is used to build a test user.
|
|
const testUserData: Prisma.userCreateInput = {
|
|
...createUserInput(defaultUserEmail),
|
|
username: 'foobar',
|
|
usernameDisplay: 'Foo Bar',
|
|
progressTimestamps: [1520002973119, 1520440323273],
|
|
completedChallenges: [
|
|
{
|
|
id: 'a6b0bb188d873cb2c8729495',
|
|
completedDate: 1520002973119,
|
|
solution: null,
|
|
challengeType: 5,
|
|
files: [
|
|
{
|
|
contents: 'test',
|
|
ext: 'js',
|
|
key: 'indexjs',
|
|
name: 'test',
|
|
path: 'path-test'
|
|
},
|
|
{
|
|
contents: 'test2',
|
|
ext: 'html',
|
|
key: 'html-test',
|
|
name: 'test2'
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'a5229172f011153519423690',
|
|
completedDate: 1520440323273,
|
|
solution: null,
|
|
challengeType: 5,
|
|
files: []
|
|
},
|
|
{
|
|
id: 'a5229172f011153519423692',
|
|
completedDate: 1520440323274,
|
|
githubLink: '',
|
|
challengeType: 5,
|
|
examResults: {
|
|
numberOfCorrectAnswers: 0,
|
|
numberOfQuestionsInExam: 0,
|
|
percentCorrect: 0,
|
|
passingPercent: 0,
|
|
passed: false,
|
|
examTimeInSeconds: 0
|
|
}
|
|
}
|
|
],
|
|
completedDailyCodingChallenges: [
|
|
{
|
|
id: '5900f36e1000cf542c50fe80',
|
|
completedDate: 1742941672524,
|
|
languages: [
|
|
DailyCodingChallengeLanguage.python,
|
|
DailyCodingChallengeLanguage.javascript
|
|
]
|
|
}
|
|
],
|
|
partiallyCompletedChallenges: [{ id: '123', completedDate: 123 }],
|
|
completedExams: [],
|
|
quizAttempts: [
|
|
{
|
|
challengeId: '66df3b712c41c499e9d31e5b',
|
|
quizId: '0',
|
|
timestamp: 1731924665902
|
|
}
|
|
],
|
|
githubProfile: 'github.com/foobar',
|
|
website: 'https://www.freecodecamp.org',
|
|
donationEmails: ['an@add.ress'],
|
|
portfolio: [
|
|
{
|
|
description: 'A portfolio',
|
|
id: 'a6b0bb188d873cb2c8729495',
|
|
image: 'https://www.freecodecamp.org/cat.png',
|
|
title: 'A portfolio',
|
|
url: 'https://www.freecodecamp.org'
|
|
}
|
|
],
|
|
savedChallenges: [
|
|
{
|
|
id: 'a6b0bb188d873cb2c8729495',
|
|
lastSavedDate: 123,
|
|
files: [
|
|
{
|
|
contents: 'test-contents',
|
|
ext: 'js',
|
|
history: ['indexjs'],
|
|
key: 'indexjs',
|
|
name: 'test-name'
|
|
}
|
|
]
|
|
}
|
|
],
|
|
yearsTopContributor: ['2018'],
|
|
twitter: '@foobar',
|
|
bluesky: '@foobar',
|
|
linkedin: 'linkedin.com/foobar',
|
|
sendQuincyEmail: false
|
|
};
|
|
|
|
const minimalUserData: Prisma.userCreateInput = {
|
|
about: 'I am a test user',
|
|
acceptedPrivacyTerms: true,
|
|
email: testUserData.email,
|
|
emailVerified: true,
|
|
externalId: '1234567890',
|
|
isDonating: false,
|
|
picture: 'https://www.freecodecamp.org/cat.png',
|
|
sendQuincyEmail: true,
|
|
username: 'testuser',
|
|
usernameDisplay: 'testuser',
|
|
unsubscribeId: '1234567890'
|
|
};
|
|
|
|
const lockedProfileUI = {
|
|
isLocked: true,
|
|
showAbout: false,
|
|
showCerts: false,
|
|
showDonation: false,
|
|
showHeatMap: false,
|
|
showLocation: false,
|
|
showName: false,
|
|
showPoints: false,
|
|
showPortfolio: false,
|
|
showTimeLine: false
|
|
};
|
|
|
|
// These are not part of the schema, but are added to the user object by
|
|
// get-session-user's handler
|
|
const computedProperties = {
|
|
calendar: {},
|
|
completedChallengeCount: 0,
|
|
isEmailVerified: minimalUserData.emailVerified,
|
|
points: 1,
|
|
// This is the default value if profileUI is missing. If individual properties
|
|
// are missing from the db, they will be omitted from the response.
|
|
profileUI: lockedProfileUI
|
|
};
|
|
|
|
// The following appears in get-session-user responses, but not
|
|
// get-public-profile
|
|
const sessionOnlyData = {
|
|
currentChallengeId: testUserData.currentChallengeId,
|
|
email: testUserData.email,
|
|
emailVerified: testUserData.emailVerified,
|
|
isEmailVerified: testUserData.emailVerified,
|
|
sendQuincyEmail: testUserData.sendQuincyEmail,
|
|
theme: testUserData.theme,
|
|
keyboardShortcuts: testUserData.keyboardShortcuts,
|
|
completedChallengeCount: 3,
|
|
acceptedPrivacyTerms: testUserData.acceptedPrivacyTerms
|
|
};
|
|
|
|
const publicUserData = {
|
|
about: testUserData.about,
|
|
calendar: { 1520002973: 1, 1520440323: 1 },
|
|
// testUserData.completedChallenges, with nulls removed
|
|
completedChallenges: [
|
|
{
|
|
id: 'a6b0bb188d873cb2c8729495',
|
|
completedDate: 1520002973119,
|
|
challengeType: 5,
|
|
files: [
|
|
{
|
|
contents: 'test',
|
|
ext: 'js',
|
|
key: 'indexjs',
|
|
name: 'test',
|
|
path: 'path-test'
|
|
},
|
|
{
|
|
contents: 'test2',
|
|
ext: 'html',
|
|
key: 'html-test',
|
|
name: 'test2'
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'a5229172f011153519423690',
|
|
completedDate: 1520440323273,
|
|
challengeType: 5,
|
|
files: []
|
|
},
|
|
{
|
|
id: 'a5229172f011153519423692',
|
|
completedDate: 1520440323274,
|
|
githubLink: '',
|
|
challengeType: 5,
|
|
files: [],
|
|
examResults: {
|
|
numberOfCorrectAnswers: 0,
|
|
numberOfQuestionsInExam: 0,
|
|
percentCorrect: 0,
|
|
passingPercent: 0,
|
|
passed: false,
|
|
examTimeInSeconds: 0
|
|
}
|
|
}
|
|
],
|
|
completedDailyCodingChallenges: [
|
|
{
|
|
id: '5900f36e1000cf542c50fe80',
|
|
completedDate: 1742941672524,
|
|
languages: [
|
|
DailyCodingChallengeLanguage.python,
|
|
DailyCodingChallengeLanguage.javascript
|
|
]
|
|
}
|
|
],
|
|
completedExams: testUserData.completedExams,
|
|
completedSurveys: [], // TODO: add surveys
|
|
quizAttempts: testUserData.quizAttempts,
|
|
githubProfile: testUserData.githubProfile,
|
|
is2018DataVisCert: testUserData.is2018DataVisCert,
|
|
is2018FullStackCert: testUserData.is2018FullStackCert, // TODO: should this be returned? The client doesn't use it at the moment.
|
|
isA2EnglishCert: testUserData.isA2EnglishCert,
|
|
isApisMicroservicesCert: testUserData.isApisMicroservicesCert,
|
|
isBackEndCert: testUserData.isBackEndCert,
|
|
isCheater: testUserData.isCheater,
|
|
isCollegeAlgebraPyCertV8: testUserData.isCollegeAlgebraPyCertV8,
|
|
isDataAnalysisPyCertV7: testUserData.isDataAnalysisPyCertV7,
|
|
isDataVisCert: testUserData.isDataVisCert,
|
|
isDonating: testUserData.isDonating,
|
|
isFoundationalCSharpCertV8: testUserData.isFoundationalCSharpCertV8,
|
|
isFrontEndCert: testUserData.isFrontEndCert,
|
|
isFrontEndLibsCert: testUserData.isFrontEndLibsCert,
|
|
isFullStackCert: testUserData.isFullStackCert,
|
|
isHonest: testUserData.isHonest,
|
|
isInfosecCertV7: testUserData.isInfosecCertV7,
|
|
isInfosecQaCert: testUserData.isInfosecQaCert,
|
|
isJavascriptCertV9: testUserData.isJavascriptCertV9,
|
|
isJsAlgoDataStructCert: testUserData.isJsAlgoDataStructCert,
|
|
isJsAlgoDataStructCertV8: testUserData.isJsAlgoDataStructCertV8,
|
|
isMachineLearningPyCertV7: testUserData.isMachineLearningPyCertV7,
|
|
isQaCertV7: testUserData.isQaCertV7,
|
|
isRelationalDatabaseCertV8: testUserData.isRelationalDatabaseCertV8,
|
|
isRespWebDesignCert: testUserData.isRespWebDesignCert,
|
|
isRespWebDesignCertV9: testUserData.isRespWebDesignCertV9,
|
|
isSciCompPyCertV7: testUserData.isSciCompPyCertV7,
|
|
linkedin: testUserData.linkedin,
|
|
location: testUserData.location,
|
|
name: testUserData.name,
|
|
partiallyCompletedChallenges: [{ id: '123', completedDate: 123 }],
|
|
picture: testUserData.picture,
|
|
points: 2,
|
|
portfolio: testUserData.portfolio,
|
|
profileUI: testUserData.profileUI,
|
|
savedChallenges: testUserData.savedChallenges,
|
|
twitter: 'https://twitter.com/foobar',
|
|
bluesky: 'https://bsky.app/profile/foobar',
|
|
sendQuincyEmail: testUserData.sendQuincyEmail,
|
|
username: testUserData.username,
|
|
usernameDisplay: testUserData.usernameDisplay,
|
|
website: testUserData.website,
|
|
yearsTopContributor: testUserData.yearsTopContributor
|
|
};
|
|
|
|
// This is (most of) what we expect to get back from the API. The remaining
|
|
// properties are 'id' and 'joinDate', which are generated by the database.
|
|
// We're currently filtering properties with null values, since the old api just
|
|
// would not return those.
|
|
const sessionUserData = {
|
|
...sessionOnlyData,
|
|
...publicUserData
|
|
};
|
|
|
|
const baseProgressData = {
|
|
currentChallengeId: '',
|
|
isA2EnglishCert: false,
|
|
isRespWebDesignCert: false,
|
|
is2018DataVisCert: false,
|
|
isFrontEndLibsCert: false,
|
|
isJsAlgoDataStructCert: false,
|
|
isApisMicroservicesCert: false,
|
|
isInfosecQaCert: false,
|
|
isQaCertV7: false,
|
|
isInfosecCertV7: false,
|
|
is2018FullStackCert: false,
|
|
isFrontEndCert: false,
|
|
isBackEndCert: false,
|
|
isDataVisCert: false,
|
|
isFullStackCert: false,
|
|
isJavascriptCertV9: false,
|
|
isSciCompPyCertV7: false,
|
|
isDataAnalysisPyCertV7: false,
|
|
isMachineLearningPyCertV7: false,
|
|
isRelationalDatabaseCertV8: false,
|
|
isRespWebDesignCertV9: false,
|
|
isCollegeAlgebraPyCertV8: false,
|
|
completedChallenges: [],
|
|
completedDailyCodingChallenges: [],
|
|
completedExams: [],
|
|
savedChallenges: [],
|
|
partiallyCompletedChallenges: [],
|
|
needsModeration: false
|
|
};
|
|
|
|
const modifiedProgressData = {
|
|
...baseProgressData,
|
|
currentChallengeId: 'hello there',
|
|
isRespWebDesignCert: true,
|
|
isJsAlgoDataStructCert: true,
|
|
isRelationalDatabaseCertV8: true,
|
|
needsModeration: true
|
|
};
|
|
|
|
const userTokenId = 'dummy-id';
|
|
const otherUserId = 'aaaaaaaaaaaaaaaaaaaaaaaa';
|
|
|
|
const msUsernameData = [
|
|
{ msUsername: 'foobar', userId: defaultUserId, ttl: 123 },
|
|
{ msUsername: 'foobar2', userId: defaultUserId, ttl: 123 },
|
|
{ msUsername: 'foobar3', userId: otherUserId, ttl: 123 }
|
|
];
|
|
|
|
const tokenData = [
|
|
{ created: new Date(), id: '123', ttl: 1000, userId: defaultUserId },
|
|
{ created: new Date(), id: '456', ttl: 1000, userId: defaultUserId },
|
|
{ created: new Date(), id: '789', ttl: 1000, userId: otherUserId }
|
|
];
|
|
|
|
const mockSurveyResults = {
|
|
title: 'Foundational C# with Microsoft Survey',
|
|
responses: [
|
|
{
|
|
question: 'Please describe your role:',
|
|
response: 'Beginner developer (less than 2 years experience)'
|
|
},
|
|
{
|
|
question:
|
|
'Prior to this course, how experienced were you with .NET and C#?',
|
|
response: 'Novice (no prior experience)'
|
|
}
|
|
]
|
|
};
|
|
|
|
describe('userRoutes', () => {
|
|
setupServer();
|
|
|
|
describe('Authenticated user', () => {
|
|
let superGet: ReturnType<typeof createSuperRequest>;
|
|
let superPost: ReturnType<typeof createSuperRequest>;
|
|
let superDelete: ReturnType<typeof createSuperRequest>;
|
|
|
|
beforeEach(async () => {
|
|
const setCookies = await devLogin();
|
|
superGet = createSuperRequest({ method: 'GET', setCookies });
|
|
superPost = createSuperRequest({ method: 'POST', setCookies });
|
|
superDelete = createSuperRequest({ method: 'DELETE', setCookies });
|
|
});
|
|
|
|
describe('/account/delete', () => {
|
|
afterEach(async () => {
|
|
await fastifyTestInstance.prisma.userToken.deleteMany({
|
|
where: { OR: [{ userId: defaultUserId }, { userId: otherUserId }] }
|
|
});
|
|
await fastifyTestInstance.prisma.msUsername.deleteMany({
|
|
where: { OR: [{ userId: defaultUserId }, { userId: otherUserId }] }
|
|
});
|
|
await clearEnvExam();
|
|
});
|
|
|
|
test('POST returns 200 status code with empty object', async () => {
|
|
const initialCount = await fastifyTestInstance.prisma.user.count();
|
|
const response = await superPost('/account/delete');
|
|
const finalCount = await fastifyTestInstance.prisma.user.count();
|
|
const deletedUser = await fastifyTestInstance.prisma.user.findFirst({
|
|
where: { email: testUserData.email }
|
|
});
|
|
|
|
expect(response.body).toStrictEqual({});
|
|
expect(response.status).toBe(200);
|
|
expect(finalCount).toBe(initialCount - 1);
|
|
expect(deletedUser).toBeNull();
|
|
});
|
|
|
|
test('POST deletes Microsoft usernames associated with the user', async () => {
|
|
await fastifyTestInstance.prisma.msUsername.createMany({
|
|
data: msUsernameData
|
|
});
|
|
|
|
await superPost('/account/delete');
|
|
expect(await fastifyTestInstance.prisma.msUsername.count()).toBe(1);
|
|
});
|
|
|
|
test('POST deletes userTokens associated with the user', async () => {
|
|
await fastifyTestInstance.prisma.userToken.createMany({
|
|
data: tokenData
|
|
});
|
|
|
|
await superPost('/account/delete');
|
|
|
|
const userTokens =
|
|
await fastifyTestInstance.prisma.userToken.findMany();
|
|
expect(userTokens).toHaveLength(1);
|
|
expect(userTokens[0]?.userId).toBe(otherUserId);
|
|
});
|
|
|
|
test("POST deletes all the user's cookies", async () => {
|
|
const res = await superPost('/account/delete');
|
|
|
|
const setCookie = res.headers['set-cookie'] as string[];
|
|
expect(setCookie).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringMatching(
|
|
/^_csrf=; Max-Age=0; Path=\/; Expires=Thu, 01 Jan 1970 00:00:00 GMT/
|
|
),
|
|
expect.stringMatching(
|
|
/^csrf_token=; Max-Age=0; Path=\/; Expires=Thu, 01 Jan 1970 00:00:00 GMT/
|
|
),
|
|
expect.stringMatching(
|
|
/^jwt_access_token=; Max-Age=0; Path=\/; Expires=Thu, 01 Jan 1970 00:00:00 GMT/
|
|
)
|
|
])
|
|
);
|
|
expect(setCookie).toHaveLength(3);
|
|
});
|
|
|
|
test("POST deletes all the user's exam attempts", async () => {
|
|
await seedEnvExam();
|
|
await seedEnvExamAttempt();
|
|
const countBefore =
|
|
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.count();
|
|
expect(countBefore).toBe(1);
|
|
|
|
const res = await superPost('/account/delete');
|
|
|
|
const countAfter =
|
|
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.count();
|
|
expect(countAfter).toBe(0);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
test("POST deletes all the user's exam tokens", async () => {
|
|
await seedExamEnvExamAuthToken();
|
|
const countBefore =
|
|
await fastifyTestInstance.prisma.examEnvironmentAuthorizationToken.count();
|
|
expect(countBefore).toBe(1);
|
|
|
|
const res = await superPost('/account/delete');
|
|
|
|
const countAfter =
|
|
await fastifyTestInstance.prisma.examEnvironmentAuthorizationToken.count();
|
|
expect(countAfter).toBe(0);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
test('handles concurrent requests to delete the same user', async () => {
|
|
const deletePromises = Array.from({ length: 2 }, () =>
|
|
superPost('/account/delete')
|
|
);
|
|
|
|
const responses = await Promise.all(deletePromises);
|
|
|
|
const userCount = await fastifyTestInstance.prisma.user.count({
|
|
where: { email: testUserData.email }
|
|
});
|
|
responses.forEach(response => {
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toStrictEqual({});
|
|
});
|
|
expect(userCount).toBe(0);
|
|
});
|
|
|
|
test("only deletes the logged in user's data", async () => {
|
|
const initialCount = await fastifyTestInstance.prisma.user.count();
|
|
const otherEmail = 'an.random@user';
|
|
const otherUser = await fastifyTestInstance.prisma.user.create({
|
|
data: {
|
|
...testUserData,
|
|
email: otherEmail
|
|
}
|
|
});
|
|
expect(otherUser.email).toBe(otherEmail);
|
|
const afterAdd = await fastifyTestInstance.prisma.user.count();
|
|
expect(afterAdd).toBe(initialCount + 1);
|
|
|
|
await superPost('/account/delete');
|
|
|
|
const finalCount = await fastifyTestInstance.prisma.user.count();
|
|
expect(finalCount).toBe(initialCount);
|
|
const remaining = await fastifyTestInstance.prisma.user.findFirst({
|
|
where: { email: otherEmail }
|
|
});
|
|
expect(remaining).not.toBeNull();
|
|
});
|
|
|
|
test('logs if it is asked to delete a non-existent user', async () => {
|
|
const spy = vi.spyOn(fastifyTestInstance.log, 'warn');
|
|
|
|
// Note: this could be flaky since the log is generated if the two
|
|
// requests are concurrent. If they're sequential the second request
|
|
// will be not be authed and hence not log anything.
|
|
const deletePromises = Array.from({ length: 2 }, () =>
|
|
superPost('/account/delete')
|
|
);
|
|
await Promise.all(deletePromises);
|
|
const messages: string[] = spy.mock.calls.map(call =>
|
|
call.map(part => String(part)).join(' ')
|
|
);
|
|
const found = messages.some(m =>
|
|
m.includes(`User with id ${defaultUserId} not found for deletion.`)
|
|
);
|
|
expect(found).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('/users/:userId', () => {
|
|
afterEach(async () => {
|
|
await fastifyTestInstance.prisma.userToken.deleteMany({
|
|
where: { OR: [{ userId: defaultUserId }, { userId: otherUserId }] }
|
|
});
|
|
await fastifyTestInstance.prisma.msUsername.deleteMany({
|
|
where: { OR: [{ userId: defaultUserId }, { userId: otherUserId }] }
|
|
});
|
|
await clearEnvExam();
|
|
});
|
|
|
|
test('DELETE returns 204 status code with empty object', async () => {
|
|
const response = await superDelete(`/users/${defaultUserId}`);
|
|
const userCount = await fastifyTestInstance.prisma.user.count({
|
|
where: { email: testUserData.email }
|
|
});
|
|
|
|
expect(response.body).toStrictEqual({});
|
|
expect(response.status).toBe(204);
|
|
expect(userCount).toBe(0);
|
|
});
|
|
|
|
test('DELETE deletes Microsoft usernames associated with the user', async () => {
|
|
await fastifyTestInstance.prisma.msUsername.createMany({
|
|
data: msUsernameData
|
|
});
|
|
|
|
await superDelete(`/users/${defaultUserId}`);
|
|
expect(await fastifyTestInstance.prisma.msUsername.count()).toBe(1);
|
|
});
|
|
|
|
test('DELETE deletes userTokens associated with the user', async () => {
|
|
await fastifyTestInstance.prisma.userToken.createMany({
|
|
data: tokenData
|
|
});
|
|
|
|
await superDelete(`/users/${defaultUserId}`);
|
|
|
|
const userTokens =
|
|
await fastifyTestInstance.prisma.userToken.findMany();
|
|
expect(userTokens).toHaveLength(1);
|
|
expect(userTokens[0]?.userId).toBe(otherUserId);
|
|
});
|
|
|
|
test("DELETE deletes all the user's cookies", async () => {
|
|
const res = await superDelete(`/users/${defaultUserId}`);
|
|
|
|
const setCookie = res.headers['set-cookie'] as string[];
|
|
expect(setCookie).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringMatching(
|
|
/^_csrf=; Max-Age=0; Path=\/:?; Expires=Thu, 01 Jan 1970 00:00:00 GMT/
|
|
),
|
|
expect.stringMatching(
|
|
/^csrf_token=; Max-Age=0; Path=\/:?; Expires=Thu, 01 Jan 1970 00:00:00 GMT/
|
|
),
|
|
expect.stringMatching(
|
|
/^jwt_access_token=; Max-Age=0; Path=\/:?; Expires=Thu, 01 Jan 1970 00:00:00 GMT/
|
|
)
|
|
])
|
|
);
|
|
expect(setCookie).toHaveLength(3);
|
|
});
|
|
|
|
test("DELETE deletes all the user's exam attempts", async () => {
|
|
await seedEnvExam();
|
|
await seedEnvExamAttempt();
|
|
const countBefore =
|
|
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.count();
|
|
expect(countBefore).toBe(1);
|
|
|
|
const res = await superDelete(`/users/${defaultUserId}`);
|
|
|
|
const countAfter =
|
|
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.count();
|
|
expect(countAfter).toBe(0);
|
|
expect(res.status).toBe(204);
|
|
});
|
|
|
|
test("DELETE deletes all the user's exam tokens", async () => {
|
|
await seedExamEnvExamAuthToken();
|
|
const countBefore =
|
|
await fastifyTestInstance.prisma.examEnvironmentAuthorizationToken.count();
|
|
expect(countBefore).toBe(1);
|
|
|
|
const res = await superDelete(`/users/${defaultUserId}`);
|
|
|
|
const countAfter =
|
|
await fastifyTestInstance.prisma.examEnvironmentAuthorizationToken.count();
|
|
expect(countAfter).toBe(0);
|
|
expect(res.status).toBe(204);
|
|
});
|
|
|
|
test("only deletes the logged in user's data", async () => {
|
|
const initialCount = await fastifyTestInstance.prisma.user.count();
|
|
await fastifyTestInstance.prisma.user.create({
|
|
data: {
|
|
...testUserData,
|
|
email: 'an.random@user'
|
|
}
|
|
});
|
|
expect(await fastifyTestInstance.prisma.user.count()).toBe(
|
|
initialCount + 1
|
|
);
|
|
|
|
await superDelete(`/users/${defaultUserId}`);
|
|
|
|
const userCount = await fastifyTestInstance.prisma.user.count();
|
|
expect(userCount).toBe(initialCount);
|
|
});
|
|
|
|
test('logs if it is asked to delete a non-existent user', async () => {
|
|
const spy = vi.spyOn(fastifyTestInstance.log, 'warn');
|
|
|
|
const deletePromises = Array.from({ length: 2 }, () =>
|
|
superDelete(`/users/${defaultUserId}`)
|
|
);
|
|
|
|
await Promise.all(deletePromises);
|
|
|
|
const messages = spy.mock.calls.flat().map(String);
|
|
expect(
|
|
messages.some(m =>
|
|
m.includes(`User with id ${defaultUserId} not found for deletion.`)
|
|
)
|
|
).toBe(true);
|
|
});
|
|
|
|
test('returns 403 if attempting to delete a different user', async () => {
|
|
const res = await superDelete(`/users/${otherUserId}`);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe('/account/reset-progress', () => {
|
|
afterEach(async () => {
|
|
await fastifyTestInstance.prisma.userToken.deleteMany({
|
|
where: { OR: [{ userId: defaultUserId }, { userId: otherUserId }] }
|
|
});
|
|
await fastifyTestInstance.prisma.msUsername.deleteMany({
|
|
where: { OR: [{ userId: defaultUserId }, { userId: otherUserId }] }
|
|
});
|
|
});
|
|
test('POST returns 200 status code with empty object', async () => {
|
|
await fastifyTestInstance.prisma.user.updateMany({
|
|
where: { email: testUserData.email },
|
|
data: modifiedProgressData
|
|
});
|
|
|
|
const response = await superPost('/account/reset-progress');
|
|
|
|
const user = await fastifyTestInstance.prisma.user.findFirst({
|
|
where: { email: testUserData.email }
|
|
});
|
|
|
|
expect(response.body).toStrictEqual({});
|
|
expect(response.status).toBe(200);
|
|
|
|
expect(user?.progressTimestamps).toHaveLength(1);
|
|
expect(user).toMatchObject(baseProgressData);
|
|
});
|
|
|
|
test('POST deletes Microsoft usernames associated with the user', async () => {
|
|
await fastifyTestInstance.prisma.msUsername.createMany({
|
|
data: msUsernameData
|
|
});
|
|
|
|
await superPost('/account/reset-progress');
|
|
|
|
expect(await fastifyTestInstance.prisma.msUsername.count()).toBe(1);
|
|
});
|
|
|
|
test('POST deletes userTokens associated with the user', async () => {
|
|
await fastifyTestInstance.prisma.userToken.createMany({
|
|
data: tokenData
|
|
});
|
|
|
|
await superPost('/account/reset-progress');
|
|
|
|
const userTokens =
|
|
await fastifyTestInstance.prisma.userToken.findMany();
|
|
expect(userTokens).toHaveLength(1);
|
|
expect(userTokens[0]?.userId).toBe(otherUserId);
|
|
});
|
|
|
|
test.todo('POST resets the user to the default state');
|
|
});
|
|
|
|
describe('/user/user-token', () => {
|
|
beforeEach(async () => {
|
|
await fastifyTestInstance.prisma.userToken.create({
|
|
data: {
|
|
created: new Date(),
|
|
id: '123',
|
|
ttl: 1000,
|
|
userId: defaultUserId
|
|
}
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fastifyTestInstance.prisma.userToken.deleteMany({
|
|
where: {
|
|
userId: defaultUserId
|
|
}
|
|
});
|
|
});
|
|
|
|
// TODO(Post-MVP): consider using PUT and updating the logic to upsert
|
|
test('POST success response includes a JWT encoded string', async () => {
|
|
const response = await superPost('/user/user-token');
|
|
|
|
const userToken = response.body.userToken;
|
|
const decodedToken = jwt.decode(userToken);
|
|
|
|
expect(response.body).toStrictEqual({ userToken: expect.any(String) });
|
|
expect(decodedToken).toStrictEqual({
|
|
userToken: expect.stringMatching(/^[a-zA-Z0-9]{64}$/),
|
|
iat: expect.any(Number)
|
|
});
|
|
|
|
expect(() => jwt.verify(userToken, 'wrong-secret')).toThrow();
|
|
expect(() => jwt.verify(userToken, JWT_SECRET)).not.toThrow();
|
|
|
|
// TODO(Post-MVP): consider using 201 for new tokens.
|
|
expect(response.status).toBe(200);
|
|
});
|
|
|
|
test('POST responds with an encoded UserToken id', async () => {
|
|
const response = await superPost('/user/user-token');
|
|
|
|
const decodedToken = jwt.decode(response.body.userToken);
|
|
const userTokenId = (decodedToken as JwtPayload).userToken;
|
|
|
|
// Verify that the token has been created.
|
|
await fastifyTestInstance.prisma.userToken.findUniqueOrThrow({
|
|
where: { id: userTokenId }
|
|
});
|
|
|
|
// TODO(Post-MVP): consider using 201 for new tokens.
|
|
expect(response.status).toBe(200);
|
|
});
|
|
|
|
test('POST deletes old tokens when creating a new one', async () => {
|
|
const response = await superPost('/user/user-token');
|
|
|
|
const decodedToken = jwt.decode(response.body.userToken);
|
|
const userTokenId = (decodedToken as JwtPayload).userToken;
|
|
|
|
// Verify that the token has been created.
|
|
await fastifyTestInstance.prisma.userToken.findUniqueOrThrow({
|
|
where: { id: userTokenId }
|
|
});
|
|
|
|
await superPost('/user/user-token');
|
|
|
|
// Verify that the old token has been deleted.
|
|
expect(
|
|
await fastifyTestInstance.prisma.userToken.findUnique({
|
|
where: { id: userTokenId }
|
|
})
|
|
).toBeNull();
|
|
expect(await fastifyTestInstance.prisma.userToken.count()).toBe(1);
|
|
});
|
|
|
|
test('DELETE returns 200 status with null userToken', async () => {
|
|
const response = await superDelete('/user/user-token');
|
|
|
|
expect(response.body).toStrictEqual({ userToken: null });
|
|
expect(response.status).toBe(200);
|
|
expect(await fastifyTestInstance.prisma.userToken.count()).toBe(0);
|
|
});
|
|
|
|
test('DELETEing a missing userToken returns 404 status with an error message', async () => {
|
|
await superDelete('/user/user-token');
|
|
|
|
const response = await superDelete('/user/user-token');
|
|
|
|
expect(response.body).toStrictEqual({
|
|
type: 'info',
|
|
message: 'userToken not found'
|
|
});
|
|
expect(response.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('/user/get-user-session', () => {
|
|
beforeEach(async () => {
|
|
await fastifyTestInstance.prisma.user.updateMany({
|
|
where: { email: testUserData.email },
|
|
data: testUserData
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fastifyTestInstance.prisma.userToken.deleteMany({
|
|
where: { id: userTokenId }
|
|
});
|
|
await fastifyTestInstance.prisma.examEnvironmentAuthorizationToken.deleteMany(
|
|
{
|
|
where: { userId: defaultUserId }
|
|
}
|
|
);
|
|
});
|
|
|
|
test('GET rejects with 500 status code if the username is missing', async () => {
|
|
await fastifyTestInstance.prisma.user.updateMany({
|
|
where: { email: testUserData.email },
|
|
data: { username: '' }
|
|
});
|
|
|
|
const response = await superGet('/user/get-session-user');
|
|
|
|
expect(response.body).toStrictEqual({ user: {}, result: '' });
|
|
expect(response.statusCode).toBe(500);
|
|
});
|
|
|
|
// This should help debugging, since this the route returns this if
|
|
// anything throws in the handler.
|
|
test('GET does not return the error response if the request is valid', async () => {
|
|
const response = await superGet('/user/get-session-user');
|
|
|
|
expect(response.body).not.toEqual({ user: {}, result: '' });
|
|
});
|
|
|
|
test('GET returns username as the result property', async () => {
|
|
const response = await superGet('/user/get-session-user');
|
|
|
|
expect(response.body).toMatchObject({
|
|
result: testUserData.username
|
|
});
|
|
expect(response.statusCode).toBe(200);
|
|
});
|
|
|
|
test('GET returns the public user object', async () => {
|
|
// TODO: This gets the user from the database so that we can verify the
|
|
// joinDate. It feels like there should be a better way to do this.
|
|
const testUser = await fastifyTestInstance?.prisma.user.findFirst({
|
|
where: { email: testUserData.email }
|
|
});
|
|
const publicUser = {
|
|
...sessionUserData,
|
|
id: testUser?.id,
|
|
joinDate: new ObjectId(testUser?.id).getTimestamp().toISOString()
|
|
};
|
|
|
|
const response = await superGet('/user/get-session-user');
|
|
const {
|
|
user: { foobar }
|
|
} = response.body as unknown as {
|
|
user: { foobar: typeof publicUser };
|
|
};
|
|
|
|
expect(testUser).not.toBeNull();
|
|
expect(testUser?.id).not.toBeNull();
|
|
expect(foobar).toEqual(publicUser);
|
|
});
|
|
|
|
test('GET returns the userToken if it exists', async () => {
|
|
const tokenData = {
|
|
userId: defaultUserId,
|
|
ttl: 123,
|
|
id: userTokenId,
|
|
created: new Date()
|
|
};
|
|
|
|
await fastifyTestInstance.prisma.userToken.create({
|
|
data: tokenData
|
|
});
|
|
|
|
const tokens = await fastifyTestInstance.prisma.userToken.count();
|
|
expect(tokens).toBe(1);
|
|
|
|
const response = await superGet('/user/get-session-user');
|
|
|
|
const { userToken } = jwt.decode(
|
|
response.body.user.foobar.userToken
|
|
) as { userToken: string };
|
|
|
|
expect(tokenData.id).toBe(userToken);
|
|
});
|
|
|
|
test('GET returns the msUsername if it exists', async () => {
|
|
await fastifyTestInstance.prisma.msUsername.create({
|
|
data: msUsernameData[0] as (typeof msUsernameData)[0]
|
|
});
|
|
|
|
const msUsernames = await fastifyTestInstance.prisma.msUsername.count();
|
|
expect(msUsernames).toBe(1);
|
|
|
|
const response = await superGet('/user/get-session-user');
|
|
|
|
const { msUsername } = response.body.user.foobar;
|
|
|
|
expect(msUsername).toBe(msUsernameData[0]?.msUsername);
|
|
});
|
|
|
|
test('GET returns a minimal user when all optional properties are missing', async () => {
|
|
// To get a minimal test user we first delete the existing one...
|
|
await fastifyTestInstance.prisma.user.deleteMany({
|
|
where: {
|
|
email: minimalUserData.email
|
|
}
|
|
});
|
|
// ...then recreate it using only the properties that the schema
|
|
// requires. The alternative is to update, but that would require
|
|
// a lot of unsets (this is neater)
|
|
const testUser = await fastifyTestInstance.prisma.user.create({
|
|
data: minimalUserData
|
|
});
|
|
|
|
// devLogin must not be used here since it overrides the user
|
|
const res = await superRequest('/signin', { method: 'GET' });
|
|
const setCookies = res.get('Set-Cookie');
|
|
|
|
const publicUser = {
|
|
...omit(minimalUserData, ['externalId', 'unsubscribeId']),
|
|
...computedProperties,
|
|
id: testUser.id,
|
|
joinDate: new ObjectId(testUser.id).getTimestamp().toISOString(),
|
|
// the following properties are defaults provided if the field is
|
|
// missing in the user document.
|
|
currentChallengeId: '',
|
|
completedChallenges: [],
|
|
completedDailyCodingChallenges: [],
|
|
completedExams: [],
|
|
completedSurveys: [],
|
|
partiallyCompletedChallenges: [],
|
|
portfolio: [],
|
|
savedChallenges: [],
|
|
quizAttempts: [],
|
|
yearsTopContributor: [],
|
|
is2018DataVisCert: false,
|
|
is2018FullStackCert: false,
|
|
isA2EnglishCert: false,
|
|
isApisMicroservicesCert: false,
|
|
isBackEndCert: false,
|
|
isCheater: false,
|
|
isCollegeAlgebraPyCertV8: false,
|
|
isDataAnalysisPyCertV7: false,
|
|
isDataVisCert: false,
|
|
isFoundationalCSharpCertV8: false,
|
|
isFrontEndCert: false,
|
|
isFrontEndLibsCert: false,
|
|
isFullStackCert: false,
|
|
isJavascriptCertV9: false,
|
|
isHonest: false,
|
|
isInfosecCertV7: false,
|
|
isInfosecQaCert: false,
|
|
isJsAlgoDataStructCert: false,
|
|
isJsAlgoDataStructCertV8: false,
|
|
isMachineLearningPyCertV7: false,
|
|
isQaCertV7: false,
|
|
isRelationalDatabaseCertV8: false,
|
|
isRespWebDesignCert: false,
|
|
isRespWebDesignCertV9: false,
|
|
isSciCompPyCertV7: false,
|
|
keyboardShortcuts: false,
|
|
location: '',
|
|
name: '',
|
|
theme: 'default'
|
|
};
|
|
|
|
const response = await superRequest('/user/get-session-user', {
|
|
method: 'GET',
|
|
setCookies
|
|
});
|
|
|
|
const {
|
|
user: { testuser }
|
|
} = response.body as unknown as {
|
|
user: { testuser: typeof publicUser };
|
|
};
|
|
|
|
expect(testuser).toStrictEqual(publicUser);
|
|
});
|
|
});
|
|
|
|
describe('/user/report-user', () => {
|
|
let sendEmailSpy: MockInstance;
|
|
beforeEach(() => {
|
|
sendEmailSpy = vi
|
|
.spyOn(fastifyTestInstance, 'sendEmail')
|
|
.mockImplementation(vi.fn());
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await resetDefaultUser();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
test('POST returns 400 for empty username', async () => {
|
|
const response = await superPost('/user/report-user').send({
|
|
username: '',
|
|
reportDescription: 'Test Report'
|
|
});
|
|
|
|
expect(response.statusCode).toBe(404);
|
|
expect(response.body).toStrictEqual({
|
|
type: 'danger',
|
|
message: 'flash.report-error'
|
|
});
|
|
});
|
|
|
|
test('POST returns 400 for empty report', async () => {
|
|
const response = await superPost('/user/report-user').send({
|
|
username: testUserData.username,
|
|
reportDescription: ''
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
});
|
|
|
|
test('POST returns 403 for users with no email', async () => {
|
|
await fastifyTestInstance.prisma.user.updateMany({
|
|
where: { email: testUserData.email },
|
|
data: { email: null }
|
|
});
|
|
|
|
const response = await superPost('/user/report-user').send({
|
|
username: testUserData.username,
|
|
reportDescription: 'Test Report'
|
|
});
|
|
|
|
expect(response.statusCode).toBe(403);
|
|
expect(response.body).toStrictEqual({
|
|
type: 'danger',
|
|
message: 'flash.report-error'
|
|
});
|
|
});
|
|
|
|
test('POST sanitises report description', async () => {
|
|
await superPost('/user/report-user').send({
|
|
username: defaultUsername,
|
|
reportDescription:
|
|
'<script>const breath = "loud"</script>Luke, I am your father'
|
|
});
|
|
|
|
expect(sendEmailSpy).toHaveBeenCalledTimes(1);
|
|
expect(sendEmailSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
text: expect.stringContaining(
|
|
'Report Details:\n\nLuke, I am your father'
|
|
)
|
|
})
|
|
);
|
|
});
|
|
|
|
test('POST returns 200 status code with "success" message', async () => {
|
|
const testUser = await fastifyTestInstance.prisma.user.findFirstOrThrow(
|
|
{
|
|
where: { email: testUserData.email }
|
|
}
|
|
);
|
|
const response = await superPost('/user/report-user').send({
|
|
username: testUser.username,
|
|
reportDescription: 'Luke, I am your father'
|
|
});
|
|
|
|
expect(sendEmailSpy).toHaveBeenCalledTimes(1);
|
|
expect(sendEmailSpy).toHaveBeenCalledWith({
|
|
from: 'team@freecodecamp.org',
|
|
to: 'support@freecodecamp.org',
|
|
cc: 'foo@bar.com',
|
|
subject: `Abuse Report : Reporting ${testUser.username}'s profile.`,
|
|
text: `
|
|
Hello Team,
|
|
|
|
This is to report the profile of ${testUser.username}. ID: ${defaultUserId}.
|
|
|
|
Report Details:
|
|
|
|
Luke, I am your father
|
|
|
|
|
|
Reported by:
|
|
ID: ${testUser.id}
|
|
Username: ${testUser.username}
|
|
Name:
|
|
Email: foo@bar.com
|
|
|
|
Thanks and regards,
|
|
`
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.body).toStrictEqual({
|
|
type: 'info',
|
|
message: 'flash.report-sent',
|
|
variables: { email: 'foo@bar.com' }
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('/user/ms-username', () => {
|
|
describe('DELETE', () => {
|
|
afterEach(async () => {
|
|
await fastifyTestInstance.prisma.msUsername.deleteMany({
|
|
where: { userId: otherUserId }
|
|
});
|
|
});
|
|
|
|
test('deletes all Microsoft usernames associated with the user', async () => {
|
|
await fastifyTestInstance.prisma.msUsername.createMany({
|
|
data: [
|
|
{ msUsername: 'foobar', userId: defaultUserId, ttl: 123 },
|
|
{ msUsername: 'foobar2', userId: defaultUserId, ttl: 123 }
|
|
]
|
|
});
|
|
|
|
const response = await superDelete('/user/ms-username');
|
|
|
|
const msUsernames =
|
|
await fastifyTestInstance.prisma.msUsername.count();
|
|
|
|
expect(msUsernames).toBe(0);
|
|
expect(response.body).toStrictEqual({ msUsername: null });
|
|
expect(response.statusCode).toBe(200);
|
|
});
|
|
|
|
test('does not delete Microsoft usernames associated with other users', async () => {
|
|
await fastifyTestInstance.prisma.msUsername.createMany({
|
|
data: [
|
|
{ msUsername: 'foobar', userId: otherUserId, ttl: 123 },
|
|
{ msUsername: 'foobar2', userId: defaultUserId, ttl: 123 }
|
|
]
|
|
});
|
|
|
|
await superDelete('/user/ms-username');
|
|
|
|
const msUsernames =
|
|
await fastifyTestInstance.prisma.msUsername.count();
|
|
|
|
expect(msUsernames).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('POST', () => {
|
|
beforeEach(() => {
|
|
mockedFetch.mockClear();
|
|
});
|
|
afterEach(async () => {
|
|
await fastifyTestInstance.prisma.msUsername.deleteMany({
|
|
where: {
|
|
OR: [
|
|
{ userId: defaultUserId },
|
|
{ userId: 'aaaaaaaaaaaaaaaaaaaaaaaa' }
|
|
]
|
|
}
|
|
});
|
|
});
|
|
|
|
test('handles missing transcript urls', async () => {
|
|
const response = await superPost('/user/ms-username');
|
|
|
|
expect(response.body).toStrictEqual({
|
|
type: 'error',
|
|
message: 'flash.ms.transcript.link-err-1'
|
|
});
|
|
expect(response.statusCode).toBe(400);
|
|
});
|
|
|
|
test('handles invalid transcript urls', async () => {
|
|
const response = await superPost('/user/ms-username').send({
|
|
msTranscriptUrl: 'https://www.example.com'
|
|
});
|
|
|
|
expect(response.body).toStrictEqual({
|
|
type: 'error',
|
|
message: 'flash.ms.transcript.link-err-1'
|
|
});
|
|
expect(response.statusCode).toBe(400);
|
|
});
|
|
|
|
test('handles the case that MS does not return a username', async () => {
|
|
mockedFetch.mockImplementationOnce(() =>
|
|
Promise.resolve({
|
|
ok: true,
|
|
json: () => Promise.resolve({})
|
|
})
|
|
);
|
|
|
|
const response = await superPost('/user/ms-username').send({
|
|
msTranscriptUrl:
|
|
'https://learn.microsoft.com/en-us/users/not/transcript/8u6ert43q1p'
|
|
});
|
|
|
|
expect(response.body).toStrictEqual({
|
|
type: 'error',
|
|
message: 'flash.ms.transcript.link-err-3'
|
|
});
|
|
expect(response.statusCode).toBe(500);
|
|
});
|
|
|
|
test('handles duplicate Microsoft usernames', async () => {
|
|
mockedFetch.mockImplementationOnce(() =>
|
|
Promise.resolve({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
userName: 'foobar'
|
|
})
|
|
})
|
|
);
|
|
|
|
await fastifyTestInstance.prisma.msUsername.create({
|
|
data: {
|
|
msUsername: 'foobar',
|
|
userId: defaultUserId,
|
|
ttl: 77760000000
|
|
}
|
|
});
|
|
|
|
const response = await superPost('/user/ms-username').send({
|
|
msTranscriptUrl:
|
|
'https://learn.microsoft.com/en-us/users/mot01/transcript/8wert4'
|
|
});
|
|
|
|
expect(response.body).toStrictEqual({
|
|
type: 'error',
|
|
message: 'flash.ms.transcript.link-err-4'
|
|
});
|
|
|
|
expect(response.statusCode).toBe(403);
|
|
});
|
|
|
|
test('returns the username on success', async () => {
|
|
const msUsername = 'ms-user';
|
|
mockedFetch.mockImplementationOnce(() =>
|
|
Promise.resolve({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
userName: msUsername
|
|
})
|
|
})
|
|
);
|
|
const response = await superPost('/user/ms-username').send({
|
|
msTranscriptUrl:
|
|
'https://learn.microsoft.com/en-us/users/mot01/transcript/8ert43q'
|
|
});
|
|
|
|
expect(response.body).toStrictEqual({
|
|
msUsername
|
|
});
|
|
expect(response.statusCode).toBe(200);
|
|
});
|
|
|
|
test('creates a record of the linked account', async () => {
|
|
const msUsername = 'super-user';
|
|
mockedFetch.mockImplementationOnce(() =>
|
|
Promise.resolve({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
userName: msUsername
|
|
})
|
|
})
|
|
);
|
|
|
|
await superPost('/user/ms-username').send({
|
|
msTranscriptUrl:
|
|
'https://learn.microsoft.com/en-us/users/mot01/transcript/12345'
|
|
});
|
|
|
|
const linkedAccount =
|
|
await fastifyTestInstance.prisma.msUsername.findFirstOrThrow({
|
|
where: { msUsername }
|
|
});
|
|
|
|
expect(linkedAccount).toStrictEqual({
|
|
id: expect.stringMatching(/^[a-f\d]{24}$/),
|
|
userId: defaultUserId,
|
|
ttl: 77760000000,
|
|
msUsername
|
|
});
|
|
});
|
|
|
|
test('removes any other accounts linked to the same user', async () => {
|
|
const msUsernameOne = 'super-user';
|
|
const msUsernameTwo = 'super-user-2';
|
|
mockedFetch
|
|
.mockImplementationOnce(() =>
|
|
Promise.resolve({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
userName: msUsernameOne
|
|
})
|
|
})
|
|
)
|
|
.mockImplementationOnce(() =>
|
|
Promise.resolve({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
userName: msUsernameTwo
|
|
})
|
|
})
|
|
);
|
|
|
|
await fastifyTestInstance.prisma.msUsername.create({
|
|
data: {
|
|
msUsername: 'dummy',
|
|
userId: 'aaaaaaaaaaaaaaaaaaaaaaaa',
|
|
ttl: 77760000000
|
|
}
|
|
});
|
|
|
|
await superPost('/user/ms-username').send({
|
|
msTranscriptUrl:
|
|
'https://learn.microsoft.com/en-us/users/mot01/transcript/8u6awert43q1plo'
|
|
});
|
|
await superPost('/user/ms-username').send({
|
|
msTranscriptUrl:
|
|
'https://learn.microsoft.com/en-us/users/mot01/transcript/8u6awert43q1plo'
|
|
});
|
|
|
|
const linkedAccounts =
|
|
await fastifyTestInstance.prisma.msUsername.findMany({});
|
|
|
|
expect(linkedAccounts).toHaveLength(2);
|
|
expect(linkedAccounts[1]?.msUsername).toBe(msUsernameTwo);
|
|
});
|
|
|
|
test('calls the Microsoft API with the correct url', async () => {
|
|
const msTranscriptUrl =
|
|
'https://learn.microsoft.com/en-us/users/mot01/transcript/8u6awert43q1plo';
|
|
|
|
const msTranscriptApiUrl =
|
|
'https://learn.microsoft.com/api/profiles/transcript/share/8u6awert43q1plo';
|
|
|
|
await superPost('/user/ms-username').send({
|
|
msTranscriptUrl
|
|
});
|
|
|
|
expect(mockedFetch).toHaveBeenCalledWith(msTranscriptApiUrl);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('/user/submit-survey', () => {
|
|
afterEach(async () => {
|
|
await fastifyTestInstance.prisma.survey.deleteMany({
|
|
where: { userId: defaultUserId }
|
|
});
|
|
});
|
|
|
|
test('POST returns 400 for invalid survey title', async () => {
|
|
const response = await superPost('/user/submit-survey').send({
|
|
surveyResults: { ...mockSurveyResults, title: 'Invalid Survey' }
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.body).toStrictEqual({
|
|
type: 'error',
|
|
message: 'flash.survey.err-1'
|
|
});
|
|
});
|
|
|
|
test('POST returns 400 if user already submitted survey', async () => {
|
|
// Submit survey for first time
|
|
await superPost('/user/submit-survey').send({
|
|
surveyResults: mockSurveyResults
|
|
});
|
|
|
|
// Submit same survey again to get failed response
|
|
const response = await superPost('/user/submit-survey').send({
|
|
surveyResults: mockSurveyResults
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.body).toStrictEqual({
|
|
type: 'error',
|
|
message: 'flash.survey.err-2'
|
|
});
|
|
});
|
|
|
|
test('POST returns 200 status code with "success" message', async () => {
|
|
const response = await superPost('/user/submit-survey').send({
|
|
surveyResults: mockSurveyResults
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.body).toStrictEqual({
|
|
type: 'success',
|
|
message: 'flash.survey.success'
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('/user/exam-environment/token', () => {
|
|
beforeEach(() => {
|
|
mockDeploymentEnv = 'staging';
|
|
});
|
|
|
|
afterAll(() => {
|
|
mockDeploymentEnv = 'production';
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fastifyTestInstance.prisma.examEnvironmentAuthorizationToken.deleteMany(
|
|
{
|
|
where: { userId: defaultUserId }
|
|
}
|
|
);
|
|
});
|
|
|
|
test('POST generates a new token if one does not exist', async () => {
|
|
mockDeploymentEnv = 'production';
|
|
const response = await superPost('/user/exam-environment/token');
|
|
const { examEnvironmentAuthorizationToken } = response.body;
|
|
|
|
const decodedToken = jwt.decode(examEnvironmentAuthorizationToken);
|
|
|
|
expect(decodedToken).toStrictEqual({
|
|
examEnvironmentAuthorizationToken:
|
|
expect.stringMatching(/^[a-z0-9]{24}$/),
|
|
iat: expect.any(Number)
|
|
});
|
|
|
|
expect(() =>
|
|
jwt.verify(examEnvironmentAuthorizationToken, 'wrong-secret')
|
|
).toThrow();
|
|
expect(() =>
|
|
jwt.verify(examEnvironmentAuthorizationToken, JWT_SECRET)
|
|
).not.toThrow();
|
|
|
|
expect(response.status).toBe(201);
|
|
});
|
|
|
|
test('POST only allows for one token per user id', async () => {
|
|
mockDeploymentEnv = 'production';
|
|
const token =
|
|
await fastifyTestInstance.prisma.examEnvironmentAuthorizationToken.create(
|
|
{
|
|
data: {
|
|
userId: defaultUserId,
|
|
expireAt: new Date()
|
|
}
|
|
}
|
|
);
|
|
|
|
const response = await superPost('/user/exam-environment/token');
|
|
|
|
const { examEnvironmentAuthorizationToken } = response.body;
|
|
|
|
const decodedToken = jwt.decode(examEnvironmentAuthorizationToken);
|
|
|
|
expect(decodedToken).not.toHaveProperty(
|
|
'examEnvironmentAuthorizationToken',
|
|
token.id
|
|
);
|
|
|
|
expect(response.status).toBe(201);
|
|
|
|
const tokens =
|
|
await fastifyTestInstance.prisma.examEnvironmentAuthorizationToken.findMany(
|
|
{
|
|
where: { userId: defaultUserId }
|
|
}
|
|
);
|
|
expect(tokens).toHaveLength(1);
|
|
});
|
|
|
|
test('POST does not generate a new token in non-production environments for non-staff', async () => {
|
|
// Override deployment environment for this test
|
|
mockDeploymentEnv = 'staging';
|
|
const response = await superPost('/user/exam-environment/token');
|
|
expect(response.status).toBe(403);
|
|
});
|
|
|
|
test('POST does generate a new token in non-production environments for staff', async () => {
|
|
// Override deployment environment for this test
|
|
mockDeploymentEnv = 'staging';
|
|
await fastifyTestInstance.prisma.user.update({
|
|
where: {
|
|
id: defaultUserId
|
|
},
|
|
data: { email: 'camperbot@freecodecamp.org' }
|
|
});
|
|
|
|
const response = await superPost('/user/exam-environment/token');
|
|
const { examEnvironmentAuthorizationToken } = response.body;
|
|
|
|
const decodedToken = jwt.decode(examEnvironmentAuthorizationToken);
|
|
|
|
expect(decodedToken).toStrictEqual({
|
|
examEnvironmentAuthorizationToken:
|
|
expect.stringMatching(/^[a-z0-9]{24}$/),
|
|
iat: expect.any(Number)
|
|
});
|
|
|
|
expect(() =>
|
|
jwt.verify(examEnvironmentAuthorizationToken, 'wrong-secret')
|
|
).toThrow();
|
|
expect(() =>
|
|
jwt.verify(examEnvironmentAuthorizationToken, JWT_SECRET)
|
|
).not.toThrow();
|
|
|
|
expect(response.status).toBe(201);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Unauthenticated user', () => {
|
|
let setCookies: string[];
|
|
// Get the CSRF cookies from an unprotected route
|
|
beforeAll(async () => {
|
|
const res = await superRequest('/status/ping', { method: 'GET' });
|
|
setCookies = res.get('Set-Cookie');
|
|
});
|
|
|
|
const endpoints: { path: string; method: 'GET' | 'POST' | 'DELETE' }[] = [
|
|
{ path: `/users/${otherUserId}`, method: 'DELETE' },
|
|
{ path: '/account/delete', method: 'POST' },
|
|
{ path: '/account/reset-progress', method: 'POST' },
|
|
{ path: '/user/get-session-user', method: 'GET' },
|
|
{ path: '/user/user-token', method: 'DELETE' },
|
|
{ path: '/user/user-token', method: 'POST' },
|
|
{ path: '/user/ms-username', method: 'DELETE' },
|
|
{ path: '/user/report-user', method: 'POST' },
|
|
{ path: '/user/ms-username', method: 'POST' },
|
|
{ path: '/user/submit-survey', method: 'POST' }
|
|
];
|
|
|
|
endpoints.forEach(({ path, method }) => {
|
|
test(`${method} ${path} returns 401 status code with error message`, async () => {
|
|
const response = await superRequest(path, {
|
|
method,
|
|
setCookies
|
|
});
|
|
expect(response.statusCode).toBe(401);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Microsoft helpers', () => {
|
|
describe('getMsTranscriptApiUrl', () => {
|
|
const expectedUrl =
|
|
'https://learn.microsoft.com/api/profiles/transcript/share/8u6awert43q1plo';
|
|
|
|
const urlWithoutSlash =
|
|
'https://learn.microsoft.com/en-us/users/mot01/transcript/8u6awert43q1plo';
|
|
const urlWithSlash = `${urlWithoutSlash}/`;
|
|
const urlWithQueryParams = `${urlWithoutSlash}?foo=bar`;
|
|
const urlWithQueryParamsAndSlash = `${urlWithSlash}?foo=bar`;
|
|
|
|
test('should extract the transcript id from the url', () => {
|
|
expect(getMsTranscriptApiUrl(urlWithoutSlash)).toEqual({
|
|
error: null,
|
|
data: expectedUrl
|
|
});
|
|
});
|
|
|
|
test('should handle trailing slashes', () => {
|
|
expect(getMsTranscriptApiUrl(urlWithSlash)).toEqual({
|
|
error: null,
|
|
data: expectedUrl
|
|
});
|
|
});
|
|
|
|
test('should ignore query params', () => {
|
|
expect(getMsTranscriptApiUrl(urlWithQueryParams)).toEqual({
|
|
error: null,
|
|
data: expectedUrl
|
|
});
|
|
expect(getMsTranscriptApiUrl(urlWithQueryParamsAndSlash)).toEqual({
|
|
error: null,
|
|
data: expectedUrl
|
|
});
|
|
});
|
|
|
|
test('should return an error for invalid URLs', () => {
|
|
const validBadUrl = 'https://www.example.com/invalid-url';
|
|
expect(getMsTranscriptApiUrl(validBadUrl)).toEqual({
|
|
error: expect.any(String),
|
|
data: null
|
|
});
|
|
const invalidUrl = ' ';
|
|
expect(getMsTranscriptApiUrl(invalidUrl)).toEqual({
|
|
error: expect.any(String),
|
|
data: null
|
|
});
|
|
});
|
|
});
|
|
});
|