chore: import upstream snapshot with attribution
i18n - Build Validation / Validate i18n Builds (24) (push) Has been cancelled
CI - Node.js / Lint (24) (push) Has been cancelled
CI - Node.js / Build (24) (push) Has been cancelled
CI - Node.js / Test (24) (push) Has been cancelled
CI - Node.js / Test - Upcoming Changes (24) (push) Has been cancelled
CI - Node.js / Test - i18n (italian, 24) (push) Has been cancelled
CI - Node.js / Test - i18n (portuguese, 24) (push) Has been cancelled
CD - Docker - GHCR Images / Build and Push Images (push) Has been cancelled
i18n - Build Validation / Validate i18n Builds (24) (push) Has been cancelled
CI - Node.js / Lint (24) (push) Has been cancelled
CI - Node.js / Build (24) (push) Has been cancelled
CI - Node.js / Test (24) (push) Has been cancelled
CI - Node.js / Test - Upcoming Changes (24) (push) Has been cancelled
CI - Node.js / Test - i18n (italian, 24) (push) Has been cancelled
CI - Node.js / Test - i18n (portuguese, 24) (push) Has been cancelled
CD - Docker - GHCR Images / Build and Push Images (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
import {
|
||||
describe,
|
||||
test,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterEach,
|
||||
afterAll,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import Fastify, { FastifyInstance } from 'fastify';
|
||||
|
||||
import db from '../../db/prisma.js';
|
||||
import { createUserInput } from '../../utils/create-user.js';
|
||||
import { checkCanConnectToDb } from '../../../vitest.utils.js';
|
||||
import { findOrCreateUser } from './auth-helpers.js';
|
||||
import { assignVariantBucket } from '../../utils/drip-campaign.js';
|
||||
import growthBook from '../../plugins/growth-book.js';
|
||||
import {
|
||||
GROWTHBOOK_FASTIFY_API_HOST,
|
||||
GROWTHBOOK_FASTIFY_CLIENT_KEY
|
||||
} from '../../utils/env.js';
|
||||
|
||||
async function setupServer() {
|
||||
const fastify = Fastify();
|
||||
await fastify.register(db);
|
||||
await checkCanConnectToDb(fastify.prisma);
|
||||
await fastify.register(growthBook, {
|
||||
apiHost: GROWTHBOOK_FASTIFY_API_HOST,
|
||||
clientKey: GROWTHBOOK_FASTIFY_CLIENT_KEY
|
||||
});
|
||||
return fastify;
|
||||
}
|
||||
|
||||
describe('findOrCreateUser', () => {
|
||||
let fastify: FastifyInstance;
|
||||
const email = 'test@user.com';
|
||||
beforeAll(async () => {
|
||||
fastify = await setupServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fastify.prisma.$runCommandRaw({ dropDatabase: 1 });
|
||||
await fastify.close();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastify.prisma.user.deleteMany({ where: { email } });
|
||||
await fastify.prisma.dripCampaign.deleteMany({ where: { email } });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('should log an error and capture an exception if there are multiple users with the same email', async () => {
|
||||
const user1 = await fastify.prisma.user.create({
|
||||
data: createUserInput(email)
|
||||
});
|
||||
const user2 = await fastify.prisma.user.create({
|
||||
data: createUserInput(email)
|
||||
});
|
||||
|
||||
const userIds = [user1.id, user2.id];
|
||||
|
||||
const logError = vi.spyOn(fastify.log, 'error');
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
// @ts-expect-error - Only mocks part of the Sentry object.
|
||||
fastify.Sentry = { captureException, metrics: { count } };
|
||||
|
||||
await findOrCreateUser(fastify, email);
|
||||
|
||||
expect(logError).toHaveBeenCalledWith(
|
||||
{ audit: true, userIds, email },
|
||||
'Multiple user records found'
|
||||
);
|
||||
expect(captureException).toHaveBeenCalledWith(
|
||||
new Error('Multiple user records found for: ' + userIds.join(', '))
|
||||
);
|
||||
expect(count).toHaveBeenCalledWith('user.duplicate_email_detected', 1);
|
||||
});
|
||||
|
||||
test('should NOT log an error or capture an exception if there is only one user with the email', async () => {
|
||||
await fastify.prisma.user.create({ data: createUserInput(email) });
|
||||
|
||||
const logError = vi.spyOn(fastify.log, 'error');
|
||||
const captureException = vi.fn();
|
||||
// @ts-expect-error - Only mocks part of the Sentry object.
|
||||
fastify.Sentry = { captureException };
|
||||
|
||||
await findOrCreateUser(fastify, email);
|
||||
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should NOT log an error or capture an exception if there are no users with the email', async () => {
|
||||
const logError = vi.spyOn(fastify.log, 'error');
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
// @ts-expect-error - Only mocks part of the Sentry object.
|
||||
fastify.Sentry = { captureException, metrics: { count } };
|
||||
|
||||
await findOrCreateUser(fastify, email);
|
||||
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
expect(count).toHaveBeenCalledWith('user.created', 1);
|
||||
});
|
||||
|
||||
describe('drip campaign logic', () => {
|
||||
test('should create a drip campaign record when a new user is created and feature flag is enabled', async () => {
|
||||
vi.spyOn(fastify.gb, 'isOn').mockImplementationOnce(() => true);
|
||||
|
||||
const count = vi.fn();
|
||||
// @ts-expect-error - Only mocks part of the Sentry object.
|
||||
fastify.Sentry = { ...fastify.Sentry, metrics: { count } };
|
||||
|
||||
const user = await findOrCreateUser(fastify, email);
|
||||
|
||||
const dripCampaign = await fastify.prisma.dripCampaign.findFirst({
|
||||
where: { userId: user.id }
|
||||
});
|
||||
|
||||
expect(dripCampaign).toBeDefined();
|
||||
expect(dripCampaign?.userId).toBe(user.id);
|
||||
expect(dripCampaign?.email).toBe(email);
|
||||
expect(['A', 'B']).toContain(dripCampaign?.variant);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'growthbook.signup_flag_evaluated',
|
||||
1,
|
||||
{
|
||||
attributes: { flag: 'drip-campaign', result: 'success' }
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('should assign a consistent variant based on userId', async () => {
|
||||
vi.spyOn(fastify.gb, 'isOn').mockImplementationOnce(() => true);
|
||||
|
||||
const user = await findOrCreateUser(fastify, email);
|
||||
const expectedVariant = assignVariantBucket(user.id);
|
||||
|
||||
const dripCampaign = await fastify.prisma.dripCampaign.findFirst({
|
||||
where: { userId: user.id }
|
||||
});
|
||||
|
||||
expect(dripCampaign?.variant).toBe(expectedVariant);
|
||||
});
|
||||
|
||||
test('should not create a drip campaign record when feature flag is disabled', async () => {
|
||||
vi.spyOn(fastify.gb, 'isOn').mockImplementationOnce(() => false);
|
||||
|
||||
const user = await findOrCreateUser(fastify, email);
|
||||
|
||||
const dripCampaign = await fastify.prisma.dripCampaign.findFirst({
|
||||
where: { userId: user.id }
|
||||
});
|
||||
|
||||
expect(dripCampaign).toBeNull();
|
||||
});
|
||||
|
||||
test('should not prevent user creation if drip campaign record creation fails', async () => {
|
||||
vi.spyOn(fastify.gb, 'isOn').mockImplementationOnce(() => true);
|
||||
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
// @ts-expect-error - Only mocks part of the Sentry object.
|
||||
fastify.Sentry = { captureException, metrics: { count } };
|
||||
|
||||
const originalCreate = fastify.prisma.dripCampaign.create;
|
||||
|
||||
fastify.prisma.dripCampaign.create = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('Database error'));
|
||||
|
||||
const logError = vi.spyOn(fastify.log, 'error');
|
||||
|
||||
const user = await findOrCreateUser(fastify, email);
|
||||
|
||||
expect(user).toBeDefined();
|
||||
expect(user.id).toBeTruthy();
|
||||
|
||||
const dbError: unknown = expect.objectContaining({
|
||||
message: 'Database error'
|
||||
});
|
||||
expect(logError).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: user.id },
|
||||
'Failed to create drip campaign record for user'
|
||||
);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'growthbook.signup_flag_evaluated',
|
||||
1,
|
||||
{
|
||||
attributes: { flag: 'drip-campaign', result: 'failed' }
|
||||
}
|
||||
);
|
||||
|
||||
fastify.prisma.dripCampaign.create = originalCreate;
|
||||
});
|
||||
|
||||
test('should not create drip campaign for existing users', async () => {
|
||||
vi.spyOn(fastify.gb, 'isOn').mockImplementationOnce(() => true);
|
||||
|
||||
// Create user first
|
||||
await fastify.prisma.user.create({ data: createUserInput(email) });
|
||||
|
||||
// Call findOrCreateUser for existing user
|
||||
await findOrCreateUser(fastify, email);
|
||||
|
||||
// Verify no drip campaign record was created
|
||||
const dripCampaigns = await fastify.prisma.dripCampaign.findMany({
|
||||
where: { email }
|
||||
});
|
||||
|
||||
expect(dripCampaigns).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { createUserInput } from '../../utils/create-user.js';
|
||||
import { assignVariantBucket } from '../../utils/drip-campaign.js';
|
||||
|
||||
/**
|
||||
* Finds an existing user with the given email or creates a new user if none exists.
|
||||
* @param fastify - The Fastify instance.
|
||||
* @param email - The email of the user.
|
||||
* @returns The existing or newly created user.
|
||||
*/
|
||||
export const findOrCreateUser = async (
|
||||
fastify: FastifyInstance,
|
||||
email: string
|
||||
): Promise<{ id: string; acceptedPrivacyTerms: boolean }> => {
|
||||
// TODO: handle the case where there are multiple users with the same email.
|
||||
// e.g. use findMany and throw an error if more than one is found.
|
||||
const existingUser = await fastify.prisma.user.findMany({
|
||||
where: { email },
|
||||
select: { id: true, acceptedPrivacyTerms: true }
|
||||
});
|
||||
if (existingUser.length > 1) {
|
||||
const userIds = existingUser.map(user => user.id);
|
||||
fastify.log.error(
|
||||
{ audit: true, userIds, email },
|
||||
'Multiple user records found'
|
||||
);
|
||||
fastify.Sentry?.captureException(
|
||||
new Error('Multiple user records found for: ' + userIds.join(', '))
|
||||
);
|
||||
fastify.Sentry?.metrics?.count('user.duplicate_email_detected', 1);
|
||||
}
|
||||
|
||||
if (existingUser[0]) {
|
||||
return existingUser[0];
|
||||
}
|
||||
|
||||
// Create new user
|
||||
const newUser = await fastify.prisma.user.create({
|
||||
data: createUserInput(email),
|
||||
select: { id: true, acceptedPrivacyTerms: true }
|
||||
});
|
||||
|
||||
fastify.Sentry?.metrics?.count('user.created', 1);
|
||||
|
||||
// Create drip campaign record if feature flag is enabled
|
||||
if (fastify.gb.isOn('drip-campaign')) {
|
||||
try {
|
||||
const variant = assignVariantBucket(newUser.id);
|
||||
await fastify.prisma.dripCampaign.create({
|
||||
data: {
|
||||
userId: newUser.id,
|
||||
email,
|
||||
variant
|
||||
}
|
||||
});
|
||||
fastify.log.info(
|
||||
{ userId: newUser.id, variant },
|
||||
'Drip campaign record created for user'
|
||||
);
|
||||
fastify.Sentry?.metrics?.count('growthbook.signup_flag_evaluated', 1, {
|
||||
attributes: { flag: 'drip-campaign', result: 'success' }
|
||||
});
|
||||
} catch (err) {
|
||||
fastify.Sentry?.captureException(err);
|
||||
fastify.log.error(
|
||||
{ err, userId: newUser.id },
|
||||
'Failed to create drip campaign record for user'
|
||||
);
|
||||
fastify.Sentry?.metrics?.count('growthbook.signup_flag_evaluated', 1, {
|
||||
attributes: { flag: 'drip-campaign', result: 'failed' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return newUser;
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
|
||||
import { getFallbackFullStackDate } from './certificate-utils.js';
|
||||
|
||||
const fullStackChallenges = [
|
||||
{
|
||||
completedDate: 1585210952511,
|
||||
id: '5a553ca864b52e1d8bceea14'
|
||||
},
|
||||
{
|
||||
completedDate: 1585210952511,
|
||||
id: '561add10cb82ac38a17513bc'
|
||||
},
|
||||
{
|
||||
completedDate: 1588665778679,
|
||||
id: '561acd10cb82ac38a17513bc'
|
||||
},
|
||||
{
|
||||
completedDate: 1685210952511,
|
||||
id: '561abd10cb81ac38a17513bc'
|
||||
},
|
||||
{
|
||||
completedDate: 1585210952511,
|
||||
id: '561add10cb82ac38a17523bc'
|
||||
},
|
||||
{
|
||||
completedDate: 1588665778679,
|
||||
id: '561add10cb82ac38a17213bc'
|
||||
}
|
||||
];
|
||||
|
||||
describe('helper functions', () => {
|
||||
describe('getFallbackFullStackDate', () => {
|
||||
test('should return the date of the latest completed challenge', () => {
|
||||
expect(getFallbackFullStackDate(fullStackChallenges, 123)).toBe(
|
||||
1685210952511
|
||||
);
|
||||
});
|
||||
|
||||
test('should fall back to completedDate if no certifications are provided', () => {
|
||||
expect(getFallbackFullStackDate([], 123)).toBe(123);
|
||||
});
|
||||
|
||||
test('should fall back to completedDate if none of the certifications have been completed', () => {
|
||||
expect(
|
||||
getFallbackFullStackDate([{ completedDate: 567, id: 'abc' }], 123)
|
||||
).toBe(123);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
certSlugTypeMap,
|
||||
certToIdMap,
|
||||
Certification
|
||||
} from '@freecodecamp/shared/config/certification-settings';
|
||||
import { normalizeDate } from '../../utils/normalize.js';
|
||||
|
||||
const fullStackCertificateIds = [
|
||||
certToIdMap[Certification.RespWebDesign],
|
||||
certToIdMap[Certification.JsAlgoDataStruct],
|
||||
certToIdMap[Certification.FrontEndDevLibs],
|
||||
certToIdMap[Certification.DataVis],
|
||||
certToIdMap[Certification.BackEndDevApis],
|
||||
certToIdMap[Certification.LegacyInfoSecQa]
|
||||
];
|
||||
|
||||
/**
|
||||
* Checks if the given certification slug is known.
|
||||
*
|
||||
* @param certSlug - The certification slug to check.
|
||||
* @returns True if the certification slug is known, otherwise false.
|
||||
*/
|
||||
export function isKnownCertSlug(certSlug: string): certSlug is Certification {
|
||||
return certSlug in certSlugTypeMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the completion date for the full stack certification, if it exists.
|
||||
*
|
||||
* @param completedChallenges - The array of completed challenges.
|
||||
* @param completedDate - The fallback completed date.
|
||||
* @returns The latest certification date or the completed date if no certification is found.
|
||||
*/
|
||||
export function getFallbackFullStackDate(
|
||||
completedChallenges: { id: string; completedDate: Prisma.JsonValue }[],
|
||||
completedDate: Prisma.JsonValue
|
||||
): number {
|
||||
const latestCertDate = completedChallenges
|
||||
.filter(chal => fullStackCertificateIds.includes(chal.id))
|
||||
.map(chal => ({
|
||||
...chal,
|
||||
completedDate: normalizeDate(chal.completedDate)
|
||||
}))
|
||||
.sort((a, b) => b.completedDate - a.completedDate)[0]?.completedDate;
|
||||
|
||||
return latestCertDate ?? normalizeDate(completedDate);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { describe, test, expect, afterEach, vi } from 'vitest';
|
||||
import type {
|
||||
PartiallyCompletedChallenge,
|
||||
CompletedChallenge
|
||||
} from '@prisma/client';
|
||||
|
||||
import { createFetchMock } from '../../../vitest.utils.js';
|
||||
import {
|
||||
canSubmitCodeRoadCertProject,
|
||||
verifyTrophyWithMicrosoft,
|
||||
decodeFiles,
|
||||
decodeBase64,
|
||||
encodeBase64
|
||||
} from './challenge-helpers.js';
|
||||
|
||||
const id = 'abc';
|
||||
|
||||
const partiallyCompletedChallenges: PartiallyCompletedChallenge[] = [
|
||||
{
|
||||
id,
|
||||
completedDate: 1
|
||||
}
|
||||
];
|
||||
const completedChallenges: CompletedChallenge[] = [
|
||||
{
|
||||
id,
|
||||
completedDate: 1,
|
||||
challengeType: 1,
|
||||
files: [],
|
||||
githubLink: null,
|
||||
solution: null,
|
||||
isManuallyApproved: false,
|
||||
examResults: null
|
||||
}
|
||||
];
|
||||
|
||||
describe('Challenge Helpers', () => {
|
||||
describe('canSubmitCodeRoadCertProject', () => {
|
||||
test('returns true if the user has completed the required challenges or partially completed them', () => {
|
||||
expect(
|
||||
canSubmitCodeRoadCertProject(id, {
|
||||
partiallyCompletedChallenges,
|
||||
completedChallenges
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
canSubmitCodeRoadCertProject(id, {
|
||||
partiallyCompletedChallenges: [],
|
||||
completedChallenges
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
canSubmitCodeRoadCertProject(id, {
|
||||
partiallyCompletedChallenges,
|
||||
completedChallenges: []
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false if the user has not completed the required challenges', () => {
|
||||
expect(
|
||||
canSubmitCodeRoadCertProject(id, {
|
||||
partiallyCompletedChallenges: [],
|
||||
completedChallenges: []
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false if the id is undefined', () => {
|
||||
expect(
|
||||
canSubmitCodeRoadCertProject(undefined, {
|
||||
partiallyCompletedChallenges,
|
||||
completedChallenges
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyTrophyWithMicrosoft', () => {
|
||||
const userId = 'abc123';
|
||||
const msUsername = 'ANRandom';
|
||||
const msTrophyId = 'learn.wwl.get-started-c-sharp-part-3.trophy';
|
||||
const verifyData = { msUsername, msTrophyId };
|
||||
const achievementsUrl = `https://learn.microsoft.com/api/achievements/user/${userId}`;
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
test("handles failure to reach Microsoft's profile api", async () => {
|
||||
const notOk = createFetchMock({ ok: false });
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(notOk);
|
||||
|
||||
const verification = await verifyTrophyWithMicrosoft(verifyData);
|
||||
|
||||
expect(verification).toEqual({
|
||||
type: 'error',
|
||||
message: 'flash.ms.profile.err',
|
||||
variables: {
|
||||
msUsername
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("handles failure to reach Microsoft's achievements api", async () => {
|
||||
const fetchProfile = createFetchMock({ body: { userId } });
|
||||
const fetchAchievements = createFetchMock({ ok: false });
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockImplementationOnce(fetchProfile)
|
||||
.mockImplementationOnce(fetchAchievements);
|
||||
|
||||
const verification = await verifyTrophyWithMicrosoft(verifyData);
|
||||
|
||||
expect(verification).toEqual({
|
||||
type: 'error',
|
||||
message: 'flash.ms.trophy.err-3'
|
||||
});
|
||||
});
|
||||
|
||||
test('handles the case where the user has no achievements', async () => {
|
||||
const fetchProfile = createFetchMock({ body: { userId } });
|
||||
const fetchAchievements = createFetchMock({ body: { achievements: [] } });
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockImplementationOnce(fetchProfile)
|
||||
.mockImplementationOnce(fetchAchievements);
|
||||
|
||||
const verification = await verifyTrophyWithMicrosoft(verifyData);
|
||||
|
||||
expect(verification).toEqual({
|
||||
type: 'error',
|
||||
message: 'flash.ms.trophy.err-6'
|
||||
});
|
||||
});
|
||||
|
||||
test("handles failure to find the trophy in the user's achievements", async () => {
|
||||
const fetchProfile = createFetchMock({ body: { userId } });
|
||||
const fetchAchievements = createFetchMock({
|
||||
body: { achievements: [{ typeId: 'fake-id' }] }
|
||||
});
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockImplementationOnce(fetchProfile)
|
||||
.mockImplementationOnce(fetchAchievements);
|
||||
|
||||
const verification = await verifyTrophyWithMicrosoft(verifyData);
|
||||
|
||||
expect(verification).toEqual({
|
||||
type: 'error',
|
||||
message: 'flash.ms.trophy.err-4',
|
||||
variables: {
|
||||
msUsername
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('returns msUserAchievementsApiUrl on success', async () => {
|
||||
const fetchProfile = createFetchMock({ body: { userId } });
|
||||
const fetchAchievements = createFetchMock({
|
||||
body: { achievements: [{ typeId: msTrophyId }] }
|
||||
});
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockImplementationOnce(fetchProfile)
|
||||
.mockImplementationOnce(fetchAchievements);
|
||||
|
||||
const verification = await verifyTrophyWithMicrosoft(verifyData);
|
||||
|
||||
expect(verification).toEqual({
|
||||
type: 'success',
|
||||
msUserAchievementsApiUrl: achievementsUrl
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Confirm that a user can submit a CodeRoad project.
|
||||
*
|
||||
* @param id The id of the project.
|
||||
* @param param The challenges the user has completed.
|
||||
* @param param.partiallyCompletedChallenges The partially completed challenges.
|
||||
* @param param.completedChallenges The completed challenges.
|
||||
* @returns A boolean indicating if the user can submit the project.
|
||||
*/
|
||||
export const canSubmitCodeRoadCertProject = (
|
||||
id: string | undefined,
|
||||
{
|
||||
partiallyCompletedChallenges,
|
||||
completedChallenges
|
||||
}: {
|
||||
partiallyCompletedChallenges: { id: string }[];
|
||||
completedChallenges: { id: string }[];
|
||||
}
|
||||
) => {
|
||||
if (partiallyCompletedChallenges.some(c => c.id === id)) return true;
|
||||
if (completedChallenges.some(c => c.id === id)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
type MSProfileError = {
|
||||
type: 'error';
|
||||
message: 'flash.ms.profile.err';
|
||||
variables: { msUsername: string };
|
||||
};
|
||||
|
||||
type MSProfileSuccess = {
|
||||
type: 'success';
|
||||
userId: string;
|
||||
};
|
||||
|
||||
async function getMSProfile(msUsername: string) {
|
||||
const error: MSProfileError = {
|
||||
type: 'error',
|
||||
message: 'flash.ms.profile.err',
|
||||
variables: {
|
||||
msUsername
|
||||
}
|
||||
};
|
||||
|
||||
const msProfileApi = `https://learn.microsoft.com/api/profiles/${msUsername}`;
|
||||
const msProfileApiRes = await fetch(msProfileApi);
|
||||
|
||||
if (!msProfileApiRes.ok) return error;
|
||||
|
||||
const { userId } = (await msProfileApiRes.json()) as {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
const success: MSProfileSuccess = {
|
||||
type: 'success',
|
||||
userId
|
||||
};
|
||||
|
||||
return userId ? success : error;
|
||||
}
|
||||
|
||||
type AchievementsError = {
|
||||
type: 'error';
|
||||
message: 'flash.ms.trophy.err-3';
|
||||
};
|
||||
|
||||
type NoAchievementsError = {
|
||||
type: 'error';
|
||||
message: 'flash.ms.trophy.err-6';
|
||||
};
|
||||
|
||||
type NoTrophyError = {
|
||||
type: 'error';
|
||||
message: 'flash.ms.trophy.err-4';
|
||||
variables: { msUsername: string };
|
||||
};
|
||||
|
||||
type Validated = {
|
||||
type: 'success';
|
||||
msUserAchievementsApiUrl: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles all communication with the Microsoft Learn APIs.
|
||||
*
|
||||
* @param requestData The data needed by the Microsoft Learn APIs.
|
||||
* @param requestData.msUsername The Microsoft username used to get the profile.
|
||||
* @param requestData.msTrophyId The Microsoft trophy ID to verify.
|
||||
* @returns An object with 'type' of success|error and information about the success or failure.
|
||||
*/
|
||||
export async function verifyTrophyWithMicrosoft({
|
||||
msUsername,
|
||||
msTrophyId
|
||||
}: {
|
||||
msUsername: string;
|
||||
msTrophyId: string;
|
||||
}) {
|
||||
const msProfile = await getMSProfile(msUsername);
|
||||
|
||||
if (msProfile.type === 'error') return msProfile;
|
||||
|
||||
const msUserAchievementsApiUrl = `https://learn.microsoft.com/api/achievements/user/${msProfile.userId}`;
|
||||
const msUserAchievementsApiRes = await fetch(msUserAchievementsApiUrl);
|
||||
|
||||
if (!msUserAchievementsApiRes.ok) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'flash.ms.trophy.err-3'
|
||||
} as AchievementsError;
|
||||
}
|
||||
|
||||
const { achievements } = (await msUserAchievementsApiRes.json()) as {
|
||||
achievements?: { typeId: string }[];
|
||||
};
|
||||
|
||||
if (!achievements?.length)
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'flash.ms.trophy.err-6'
|
||||
} as NoAchievementsError;
|
||||
|
||||
// TODO: handle the case where there are achievements, but the `typeId` is not
|
||||
// a property of the achievements. This suggests that Microsoft has changed
|
||||
// their API and, to aid debugging, we should report a different error
|
||||
// message.
|
||||
const earnedTrophy = achievements?.some(a => a.typeId === msTrophyId);
|
||||
|
||||
if (earnedTrophy) {
|
||||
return {
|
||||
type: 'success',
|
||||
msUserAchievementsApiUrl
|
||||
} as Validated;
|
||||
} else {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'flash.ms.trophy.err-4',
|
||||
variables: {
|
||||
msUsername
|
||||
}
|
||||
} 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');
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { isProfane } from 'no-profanity';
|
||||
|
||||
import { blocklistedUsernames } from '@freecodecamp/shared/config/constants';
|
||||
|
||||
/**
|
||||
* Checks if a username is restricted (i.e. It's profane or reserved).
|
||||
* @param username - The username to check.
|
||||
* @returns True if the username is restricted, false otherwise.
|
||||
*/
|
||||
export const isRestricted = (username: string): boolean => {
|
||||
return isProfane(username) || blocklistedUsernames.includes(username);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { pick, omit } from 'lodash-es';
|
||||
|
||||
// user flags that the api-server returns as false if they're missing in the
|
||||
// user document. Since Prisma returns null for missing fields, we need to
|
||||
// normalize them to false.
|
||||
// TODO(Post-MVP): remove this when the database is normalized.
|
||||
const nullableFlags = [
|
||||
'is2018DataVisCert',
|
||||
'is2018FullStackCert',
|
||||
'isA2EnglishCert',
|
||||
'isApisMicroservicesCert',
|
||||
'isBackEndCert',
|
||||
'isCheater',
|
||||
'isCollegeAlgebraPyCertV8',
|
||||
'isDataAnalysisPyCertV7',
|
||||
'isDataVisCert',
|
||||
// isDonating doesn't need fixing because it's not nullable
|
||||
'isFoundationalCSharpCertV8',
|
||||
'isFrontEndCert',
|
||||
'isFullStackCert',
|
||||
'isFrontEndLibsCert',
|
||||
'isJavascriptCertV9',
|
||||
'isClassroomAccount',
|
||||
'isHonest',
|
||||
'isInfosecCertV7',
|
||||
'isInfosecQaCert',
|
||||
'isJsAlgoDataStructCert',
|
||||
'isJsAlgoDataStructCertV8',
|
||||
'isMachineLearningPyCertV7',
|
||||
'isPythonCertV9',
|
||||
'isQaCertV7',
|
||||
'isRelationalDatabaseCertV8',
|
||||
'isRelationalDatabaseCertV9',
|
||||
'isRespWebDesignCert',
|
||||
'isRespWebDesignCertV9',
|
||||
'isSciCompPyCertV7',
|
||||
'isFrontEndLibsCertV9',
|
||||
'isBackEndDevApisCertV9',
|
||||
'isFullStackDeveloperCertV9',
|
||||
'isB1EnglishCert',
|
||||
'isA2SpanishCert',
|
||||
'isA2ChineseCert',
|
||||
'isA1ChineseCert',
|
||||
// isUpcomingPythonCertV8 exists in the db, but is not returned by the api-server
|
||||
// TODO(Post-MVP): delete it from the db?
|
||||
'keyboardShortcuts'
|
||||
] as const;
|
||||
|
||||
type NullableFlags = (typeof nullableFlags)[number];
|
||||
|
||||
/**
|
||||
* Splits a user object into two objects: one with nullable flags and one without.
|
||||
*
|
||||
* @param user - The user object to split.
|
||||
* @returns A tuple where the first element is an object with nullable flags and the second element is an object with the remaining properties.
|
||||
*/
|
||||
export function splitUser<U extends Record<NullableFlags, unknown>>(
|
||||
user: U
|
||||
): [Pick<U, NullableFlags>, Omit<U, NullableFlags>] {
|
||||
return [pick(user, nullableFlags), omit(user, nullableFlags)];
|
||||
}
|
||||
Reference in New Issue
Block a user