diff --git a/api/jest.utils.ts b/api/jest.utils.ts index 38846362368..afae3adbf1e 100644 --- a/api/jest.utils.ts +++ b/api/jest.utils.ts @@ -109,3 +109,12 @@ export async function seedExam(): Promise { } }); } + +export function createFetchMock({ ok = true, body = {} } = {}) { + return jest.fn().mockResolvedValue( + Promise.resolve({ + ok, + json: () => Promise.resolve(body) + }) + ); +} diff --git a/api/src/routes/challenge.test.ts b/api/src/routes/challenge.test.ts index e775546304c..f514ca47900 100644 --- a/api/src/routes/challenge.test.ts +++ b/api/src/routes/challenge.test.ts @@ -1,8 +1,13 @@ +// Yes, putting this above the imports is a hack to get around the fact that +// jest.mock() must be called at the top level of the file. +const mockVerifyTrophyWithMicrosoft = jest.fn(); /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { omit } from 'lodash'; + import { challengeTypes } from '../../../shared/config/challenge-types'; import { + defaultUserId, devLogin, setupServer, superRequest, @@ -12,6 +17,18 @@ import { import { completedTrophyChallenges } from '../../__mocks__/exam'; import { GeneratedAnswer } from '../utils/exam-types'; +jest.mock('./helpers/challenge-helpers', () => { + const originalModule = jest.requireActual< + typeof import('./helpers/challenge-helpers') + >('./helpers/challenge-helpers'); + + return { + __esModule: true, + ...originalModule, + verifyTrophyWithMicrosoft: mockVerifyTrophyWithMicrosoft + }; +}); + const isValidChallengeCompletionErrorMsg = { type: 'error', message: 'That does not appear to be a valid challenge submission.' @@ -539,7 +556,8 @@ describe('challengeRoutes', () => { completedDate: expect.any(Number) }); - // It should return an updated completedDate + // If a challenge has already been completed, it should return the + // original completedDate expect(resUpdate.body.completedDate).not.toBe( resOriginal.body.completedDate ); @@ -1101,6 +1119,278 @@ describe('challengeRoutes', () => { expect(response.statusCode).toBe(200); }); }); + describe('/ms-trophy-challenge-completed', () => { + const msUserId = 'abc123'; + // Add Logic to C# Console Applications's id: + const trophyChallengeId = '647f882207d29547b3bee1c0'; + // Create and Run Simple C# Console Applications's id: + const trophyChallengeId2 = '647f87dc07d29547b3bee1bf'; + const nonTrophyChallengeId = 'bd7123c8c441eddfaeb5bdef'; + const solutionUrl = `https://learn.microsoft.com/api/gamestatus/${msUserId}`; + + const idIsMissingOrInvalid = { + type: 'error', + message: 'flash.ms.trophy.err-2' + } as const; + const userHasNotLinkedTheirAccount = { + type: 'error', + message: 'flash.ms.trophy.err-1' + } as const; + const unexpectedError = { + type: 'error', + message: 'flash.ms.trophy.err-5' + } as const; + + describe('validation', () => { + test('POST rejects requests without valid ids', async () => { + const resNoId = await superRequest( + '/ms-trophy-challenge-completed', + { + method: 'POST', + setCookies + } + ); + + expect(resNoId.body).toStrictEqual(idIsMissingOrInvalid); + expect(resNoId.statusCode).toBe(400); + + const resBadId = await superRequest( + '/ms-trophy-challenge-completed', + { + method: 'POST', + setCookies + } + ).send({ id: nonTrophyChallengeId }); + + expect(resBadId.body).toStrictEqual(idIsMissingOrInvalid); + expect(resBadId.statusCode).toBe(400); + }); + + // TODO(Post-MVP): give a more specific error message + test('POST rejects requests without valid ObjectIDs', async () => { + const response = await superRequest( + '/ms-trophy-challenge-completed', + { + method: 'POST', + setCookies + } + ).send({ id: 'not-a-valid-id' }); + + expect(response.body).toStrictEqual(idIsMissingOrInvalid); + expect(response.statusCode).toBe(400); + }); + }); + + describe('handling', () => { + async function createMSUsernameRecord(msUsername: string) { + await fastifyTestInstance.prisma.msUsername.create({ + data: { + msUsername, + ttl: 123, + userId: defaultUserId + } + }); + } + afterEach(async () => { + await fastifyTestInstance.prisma.msUsername.deleteMany({ + where: { userId: defaultUserId } + }); + await fastifyTestInstance.prisma.user.updateMany({ + where: { id: defaultUserId }, + data: { + completedChallenges: [], + progressTimestamps: [] + } + }); + }); + + test('POST rejects requests if the user does not have a Microsoft username', async () => { + const res = await superRequest('/ms-trophy-challenge-completed', { + method: 'POST', + setCookies + }).send({ id: trophyChallengeId }); + + expect(res.body).toStrictEqual(userHasNotLinkedTheirAccount); + expect(res.statusCode).toBe(403); + }); + + test("POST rejects requests if Microsoft's api responds with an error", async () => { + const msUsername = 'ANRandom'; + await createMSUsernameRecord(msUsername); + // This can be any error that the route can serialize. Other than + // that, the details do not matter, since whatever + // verifyTrophyWithMicrosoft returns will be returned by the route. + const verifyError = { + type: 'error', + message: 'flash.ms.profile.err', + variables: { + msUsername + } + }; + mockVerifyTrophyWithMicrosoft.mockImplementationOnce(() => + Promise.resolve(verifyError) + ); + + const res = await superRequest('/ms-trophy-challenge-completed', { + method: 'POST', + setCookies + }).send({ id: trophyChallengeId }); + + expect(res.body).toStrictEqual(verifyError); + expect(res.statusCode).toBe(403); + }); + + test('POST handles unexpected errors', async () => { + mockVerifyTrophyWithMicrosoft.mockImplementationOnce(() => { + throw new Error('Network error'); + }); + const msUsername = 'ANRandom'; + await createMSUsernameRecord(msUsername); + + const res = await superRequest('/ms-trophy-challenge-completed', { + method: 'POST', + setCookies + }).send({ id: trophyChallengeId }); + + expect(res.body).toStrictEqual(unexpectedError); + expect(res.statusCode).toBe(500); + }); + + test('POST updates the user record with a new completed challenge', async () => { + mockVerifyTrophyWithMicrosoft.mockImplementationOnce(() => + Promise.resolve({ + type: 'success', + msGameStatusApiUrl: solutionUrl + }) + ); + const msUsername = 'ANRandom'; + await createMSUsernameRecord(msUsername); + const now = Date.now(); + + const res = await superRequest('/ms-trophy-challenge-completed', { + method: 'POST', + setCookies + }).send({ id: trophyChallengeId }); + + const user = + await fastifyTestInstance.prisma.user.findUniqueOrThrow({ + where: { id: defaultUserId } + }); + const completedDate = user.completedChallenges[0]?.completedDate; + + expect(res.body).toStrictEqual({ + alreadyCompleted: false, + points: 1, + completedDate + }); + + // TODO: use a custom matcher for thisu + expect(completedDate).toBeGreaterThan(now); + expect(completedDate).toBeLessThan(now + 1000); + expect(res.statusCode).toBe(200); + + expect(user).toMatchObject({ + completedChallenges: [ + { + id: trophyChallengeId, + solution: solutionUrl, + completedDate: expect.any(Number) + } + ] + }); + }); + + it('POST correctly handles multiple requests', async () => { + mockVerifyTrophyWithMicrosoft.mockImplementationOnce(() => + Promise.resolve({ + type: 'success', + msGameStatusApiUrl: solutionUrl + }) + ); + const msUsername = 'ANRandom'; + await createMSUsernameRecord(msUsername); + + const resOne = await superRequest( + '/ms-trophy-challenge-completed', + { + method: 'POST', + setCookies + } + ).send({ id: trophyChallengeId }); + + mockVerifyTrophyWithMicrosoft.mockImplementationOnce(() => + Promise.resolve({ + type: 'success', + msGameStatusApiUrl: solutionUrl + }) + ); + const resTwo = await superRequest( + '/ms-trophy-challenge-completed', + { + method: 'POST', + setCookies + } + ).send({ id: trophyChallengeId2 }); + + // sending the second trophy challenge again should not change + // anything + mockVerifyTrophyWithMicrosoft.mockImplementationOnce(() => + Promise.resolve({ + type: 'success', + msGameStatusApiUrl: solutionUrl + }) + ); + const resUpdate = await superRequest( + '/ms-trophy-challenge-completed', + { + method: 'POST', + setCookies + } + ).send({ id: trophyChallengeId2 }); + + const { completedChallenges, progressTimestamps } = + await fastifyTestInstance.prisma.user.findUniqueOrThrow({ + where: { id: defaultUserId } + }); + + expect(completedChallenges).toHaveLength(2); + expect(completedChallenges).toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: trophyChallengeId, + solution: solutionUrl, + completedDate: resOne.body.completedDate + }), + expect.objectContaining({ + id: trophyChallengeId2, + solution: solutionUrl, + completedDate: resTwo.body.completedDate + }) + ]) + ); + + const expectedProgressTimestamps = completedChallenges.map( + challenge => challenge.completedDate + ); + expect(progressTimestamps).toStrictEqual( + expectedProgressTimestamps + ); + + expect(resUpdate.body).toStrictEqual({ + alreadyCompleted: true, + points: 2, + completedDate: expect.any(Number) + }); + + // If a challenge has already been completed, it should return the + // original completedDate + expect(resUpdate.body.completedDate).toBe( + resTwo.body.completedDate + ); + expect(resUpdate.statusCode).toBe(200); + }); + }); + }); }); }); @@ -1119,7 +1409,8 @@ describe('challengeRoutes', () => { { path: '/backend-challenge-completed', method: 'POST' }, { path: '/modern-challenge-completed', method: 'POST' }, { path: '/save-challenge', method: 'POST' }, - { path: '/exam/647e22d18acb466c97ccbef8', method: 'GET' } + { path: '/exam/647e22d18acb466c97ccbef8', method: 'GET' }, + { path: '/ms-trophy-challenge-completed', method: 'POST' } ]; endpoints.forEach(({ path, method }) => { diff --git a/api/src/routes/challenge.ts b/api/src/routes/challenge.ts index 232221223f3..772751abae3 100644 --- a/api/src/routes/challenge.ts +++ b/api/src/routes/challenge.ts @@ -9,7 +9,8 @@ import { multifileCertProjectIds, updateUserChallengeData, type CompletedChallenge, - saveUserChallengeData + saveUserChallengeData, + msTrophyChallenges } from '../utils/common-challenge-functions'; import { JWT_SECRET } from '../utils/env'; import { @@ -26,7 +27,8 @@ import { generateRandomExam } from '../utils/exam'; import { canSubmitCodeRoadCertProject, createProject, - updateProject + updateProject, + verifyTrophyWithMicrosoft } from './helpers/challenge-helpers'; interface JwtPayload { @@ -512,5 +514,104 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = ( } ); + fastify.post( + '/ms-trophy-challenge-completed', + { + schema: schemas.msTrophyChallengeCompleted, + errorHandler(error, request, reply) { + if (error.validation) { + void reply.code(400); + void reply.send({ type: 'error', message: 'flash.ms.trophy.err-2' }); + } else { + fastify.errorHandler(error, request, reply); + } + } + }, + async (req, reply) => { + try { + const challengeId = req.body.id; + const challenge = msTrophyChallenges.find( + challenge => challenge.id === challengeId + ); + + if (!challenge) { + return reply + .code(400) + .send({ type: 'error', message: 'flash.ms.trophy.err-2' }); + } + + const msUser = await fastify.prisma.msUsername.findFirst({ + where: { userId: req.session.user.id } + }); + + if (!msUser || !msUser.msUsername) { + return reply + .code(403) + .send({ type: 'error', message: 'flash.ms.trophy.err-1' }); + } + + const { msUsername } = msUser; + + // TODO: log error if msTrophyId not found? + const msTrophyId = challenge.msTrophyId ?? ''; + + const msTrophyStatus = await verifyTrophyWithMicrosoft({ + msUsername, + msTrophyId + }); + + if (msTrophyStatus.type === 'error') { + return reply.code(403).send(msTrophyStatus); + } + + const user = await fastify.prisma.user.findUniqueOrThrow({ + where: { id: req.session.user.id }, + select: { completedChallenges: true, progressTimestamps: true } + }); + + const { completedChallenges } = user; + const progressTimestamps = + user.progressTimestamps as ProgressTimestamp[]; + const oldChallenge = completedChallenges.find( + ({ id }) => id === challengeId + ); + const alreadyCompleted = !!oldChallenge; + const completedDate = alreadyCompleted + ? oldChallenge.completedDate + : Date.now(); + + if (!alreadyCompleted) { + const newChallenge = { + id: challengeId, + completedDate, + solution: msTrophyStatus.msGameStatusApiUrl + }; + await fastify.prisma.user.update({ + where: { id: req.session.user.id }, + data: { + completedChallenges: { + push: newChallenge + }, + progressTimestamps: [...progressTimestamps, completedDate] + } + }); + } + + return { + alreadyCompleted, + points: getPoints(progressTimestamps) + (alreadyCompleted ? 0 : 1), + completedDate + }; + } catch (error) { + fastify.log.error(error); + void reply.code(500); + return { + type: 'error', + message: 'flash.ms.trophy.err-5' + } as const; + } + } + ); + done(); }; diff --git a/api/src/routes/helpers/challenge-helpers.test.ts b/api/src/routes/helpers/challenge-helpers.test.ts index b36974fb02a..22848c992b4 100644 --- a/api/src/routes/helpers/challenge-helpers.test.ts +++ b/api/src/routes/helpers/challenge-helpers.test.ts @@ -3,7 +3,11 @@ import type { CompletedChallenge } from '@prisma/client'; -import { canSubmitCodeRoadCertProject } from './challenge-helpers'; +import { createFetchMock } from '../../../jest.utils'; +import { + canSubmitCodeRoadCertProject, + verifyTrophyWithMicrosoft +} from './challenge-helpers'; const id = 'abc'; @@ -68,4 +72,100 @@ describe('Challenge Helpers', () => { ).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 gamestatusUrl = `https://learn.microsoft.com/api/gamestatus/${userId}`; + + afterEach(() => jest.clearAllMocks()); + + test("handles failure to reach Microsoft's profile api", async () => { + const notOk = createFetchMock({ ok: false }); + jest.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 gamestatus api", async () => { + const fetchProfile = createFetchMock({ body: { userId } }); + const fetchGameStatus = createFetchMock({ ok: false }); + jest + .spyOn(globalThis, 'fetch') + .mockImplementationOnce(fetchProfile) + .mockImplementationOnce(fetchGameStatus); + + 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 fetchGameStatus = createFetchMock({ body: { achievements: [] } }); + jest + .spyOn(globalThis, 'fetch') + .mockImplementationOnce(fetchProfile) + .mockImplementationOnce(fetchGameStatus); + + 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 fetchGameStatus = createFetchMock({ + body: { achievements: [{ awardUid: 'fake-id' }] } + }); + jest + .spyOn(globalThis, 'fetch') + .mockImplementationOnce(fetchProfile) + .mockImplementationOnce(fetchGameStatus); + + const verification = await verifyTrophyWithMicrosoft(verifyData); + + expect(verification).toEqual({ + type: 'error', + message: 'flash.ms.trophy.err-4', + variables: { + msUsername + } + }); + }); + + test('returns msGameStatusApiUrl on success', async () => { + const fetchProfile = createFetchMock({ body: { userId } }); + const fetchGameStatus = createFetchMock({ + body: { achievements: [{ awardUid: msTrophyId }] } + }); + jest + .spyOn(globalThis, 'fetch') + .mockImplementationOnce(fetchProfile) + .mockImplementationOnce(fetchGameStatus); + + const verification = await verifyTrophyWithMicrosoft(verifyData); + + expect(verification).toEqual({ + type: 'success', + msGameStatusApiUrl: gamestatusUrl + }); + }); + }); }); diff --git a/api/src/routes/helpers/challenge-helpers.ts b/api/src/routes/helpers/challenge-helpers.ts index 48d1fc91c21..8ec70e6b2e0 100644 --- a/api/src/routes/helpers/challenge-helpers.ts +++ b/api/src/routes/helpers/challenge-helpers.ts @@ -58,3 +58,81 @@ export const createProject = ( partiallyCompletedChallenges: { deleteMany: { where: { id } } }, progressTimestamps: [...progressTimestamps, newChallenge.completedDate] }); + +async function getMSProfile(msUsername: string) { + const profileError = { + type: 'error', + message: 'flash.ms.profile.err', + variables: { + msUsername + } + } as const; + + const msProfileApi = `https://learn.microsoft.com/api/profiles/${msUsername}`; + const msProfileApiRes = await fetch(msProfileApi); + + if (!msProfileApiRes.ok) return profileError; + + const { userId } = (await msProfileApiRes.json()) as { + userId: string; + }; + + return userId ? ({ type: 'success', userId } as const) : profileError; +} + +/** + * 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 msGameStatusApiUrl = `https://learn.microsoft.com/api/gamestatus/${msProfile.userId}`; + const msGameStatusApiRes = await fetch(msGameStatusApiUrl); + + if (!msGameStatusApiRes.ok) { + return { + type: 'error', + message: 'flash.ms.trophy.err-3' + } as const; + } + + const { achievements } = (await msGameStatusApiRes.json()) as { + achievements?: { awardUid: string }[]; + }; + + if (!achievements?.length) + return { + type: 'error', + message: 'flash.ms.trophy.err-6' + } as const; + + const earnedTrophy = achievements?.some(a => a.awardUid === msTrophyId); + + if (earnedTrophy) { + return { + type: 'success', + msGameStatusApiUrl + } as const; + } else { + return { + type: 'error', + message: 'flash.ms.trophy.err-4', + variables: { + msUsername + } + } as const; + } +} diff --git a/api/src/schemas.ts b/api/src/schemas.ts index 29e0165ffbb..b6bc1fbda16 100644 --- a/api/src/schemas.ts +++ b/api/src/schemas.ts @@ -580,6 +580,54 @@ export const schemas = { }) } }, + msTrophyChallengeCompleted: { + body: Type.Object({ + id: Type.String({ format: 'objectid', maxLength: 24, minLength: 24 }) + }), + response: { + 200: Type.Object({ + completedDate: Type.Number(), + points: Type.Number(), + alreadyCompleted: Type.Boolean() + }), + 400: Type.Object({ + type: Type.Literal('error'), + message: Type.Literal('flash.ms.trophy.err-2') + }), + 403: Type.Union([ + Type.Object({ + type: Type.Literal('error'), + message: Type.Literal('flash.ms.trophy.err-1') + }), + Type.Object({ + type: Type.Literal('error'), + message: Type.Literal('flash.ms.trophy.err-3') + }), + Type.Object({ + type: Type.Literal('error'), + message: Type.Literal('flash.ms.trophy.err-4'), + variables: Type.Object({ + msUsername: Type.String() + }) + }), + Type.Object({ + type: Type.Literal('error'), + message: Type.Literal('flash.ms.trophy.err-6') + }), + Type.Object({ + type: Type.Literal('error'), + message: Type.Literal('flash.ms.profile.err'), + variables: Type.Object({ + msUsername: Type.String() + }) + }) + ]), + 500: Type.Object({ + type: Type.Literal('error'), + message: Type.Literal('flash.ms.trophy.err-5') + }) + } + }, saveChallenge: { body: saveChallengeBody, response: { diff --git a/api/src/utils/common-challenge-functions.ts b/api/src/utils/common-challenge-functions.ts index 679c68e5a63..385fb54d2b8 100644 --- a/api/src/utils/common-challenge-functions.ts +++ b/api/src/utils/common-challenge-functions.ts @@ -16,6 +16,10 @@ export const multifileCertProjectIds = getChallenges() .filter(c => c.challengeType === challengeTypes.multifileCertProject) .map(c => c.id); +export const msTrophyChallenges = getChallenges() + .filter(challenge => challenge.challengeType === challengeTypes.msTrophy) + .map(({ id, msTrophyId }) => ({ id, msTrophyId })); + type SavedChallengeFile = { key: string; ext: string; // NOTE: This is Ext type in client diff --git a/api/src/utils/get-challenges.ts b/api/src/utils/get-challenges.ts index c7a8d40a4be..f27672b56fd 100644 --- a/api/src/utils/get-challenges.ts +++ b/api/src/utils/get-challenges.ts @@ -14,6 +14,7 @@ interface Block { tests?: { id?: string }[]; challengeType: number; url?: string; + msTrophyId?: string; }[]; }