diff --git a/api/src/routes/user.test.ts b/api/src/routes/user.test.ts index 8650a416d9d..be02f2cd9b1 100644 --- a/api/src/routes/user.test.ts +++ b/api/src/routes/user.test.ts @@ -15,6 +15,10 @@ import { superRequest } from '../../jest.utils'; import { JWT_SECRET } from '../utils/env'; +import { getMsTranscriptApiUrl } from './user'; + +const mockedFetch = jest.fn(); +jest.spyOn(globalThis, 'fetch').mockImplementation(mockedFetch); // This is used to build a test user. const testUserData: Prisma.userCreateInput = { @@ -812,6 +816,236 @@ Thanks and regards, expect(msUsernames).toBe(1); }); }); + + describe('POST', () => { + beforeEach(() => { + mockedFetch.mockClear(); + }); + afterEach(async () => { + await fastifyTestInstance.prisma.msUsername.deleteMany({ + where: { + OR: [ + { userId: defaultUserId }, + { userId: 'aaaaaaaaaaaaaaaaaaaaaaaa' } + ] + } + }); + }); + + it('handles missing transcript urls', async () => { + const response = await superRequest('/user/ms-username', { + method: 'POST', + setCookies + }); + + expect(response.body).toStrictEqual({ + type: 'error', + message: 'flash.ms.transcript.link-err-1' + }); + expect(response.statusCode).toBe(400); + }); + + it('handles invalid transcript urls', async () => { + mockedFetch.mockImplementationOnce(() => + Promise.resolve({ + ok: false + }) + ); + + const response = await superRequest('/user/ms-username', { + method: 'POST', + setCookies + }).send({ + msTranscriptUrl: 'https://www.example.com' + }); + + expect(response.body).toStrictEqual({ + type: 'error', + message: 'flash.ms.transcript.link-err-2' + }); + expect(response.statusCode).toBe(404); + }); + + it('handles the case that MS does not return a username', async () => { + mockedFetch.mockImplementationOnce(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({}) + }) + ); + + const response = await superRequest('/user/ms-username', { + method: 'POST', + setCookies + }).send({ + msTranscriptUrl: 'https://www.example.com' + }); + + expect(response.body).toStrictEqual({ + type: 'error', + message: 'flash.ms.transcript.link-err-3' + }); + expect(response.statusCode).toBe(500); + }); + + it('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 superRequest('/user/ms-username', { + method: 'POST', + setCookies + }).send({ + msTranscriptUrl: 'https://www.example.com' + }); + + expect(response.body).toStrictEqual({ + type: 'error', + message: 'flash.ms.transcript.link-err-4' + }); + + expect(response.statusCode).toBe(403); + }); + + it('returns the username on success', async () => { + const msUsername = 'ms-user'; + mockedFetch.mockImplementationOnce(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + userName: msUsername + }) + }) + ); + const response = await superRequest('/user/ms-username', { + method: 'POST', + setCookies + }).send({ + msTranscriptUrl: 'https://www.example.com' + }); + + expect(response.body).toStrictEqual({ + msUsername + }); + expect(response.statusCode).toBe(200); + }); + + it('creates a record of the linked account', async () => { + const msUsername = 'super-user'; + mockedFetch.mockImplementationOnce(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + userName: msUsername + }) + }) + ); + + await superRequest('/user/ms-username', { + method: 'POST', + setCookies + }).send({ + msTranscriptUrl: 'https://www.example.com' + }); + + const linkedAccount = + await fastifyTestInstance.prisma.msUsername.findFirstOrThrow({ + where: { msUsername } + }); + + expect(linkedAccount).toStrictEqual({ + id: expect.stringMatching(/^[a-f\d]{24}$/), + userId: defaultUserId, + ttl: 77760000000, + msUsername + }); + }); + + it('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 superRequest('/user/ms-username', { + method: 'POST', + setCookies + }).send({ + msTranscriptUrl: 'https://www.example.com' + }); + await superRequest('/user/ms-username', { + method: 'POST', + setCookies + }).send({ + msTranscriptUrl: 'https://www.example.com' + }); + + const linkedAccounts = + await fastifyTestInstance.prisma.msUsername.findMany({}); + + expect(linkedAccounts).toHaveLength(2); + expect(linkedAccounts[1]?.msUsername).toBe(msUsernameTwo); + }); + + it('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 superRequest('/user/ms-username', { + method: 'POST', + setCookies + }).send({ + msTranscriptUrl + }); + + expect(mockedFetch).toHaveBeenCalledWith(msTranscriptApiUrl); + }); + }); }); }); @@ -829,7 +1063,9 @@ Thanks and regards, { 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/ms-username', method: 'DELETE' }, + { path: '/user/report-user', method: 'POST' }, + { path: '/user/ms-username', method: 'POST' } ]; endpoints.forEach(({ path, method }) => { @@ -841,16 +1077,33 @@ Thanks and regards, expect(response.statusCode).toBe(401); }); }); + }); +}); - describe('/user/report-user', () => { - test('POST returns 401 status code with error message', async () => { - const response = await superRequest('/user/report-user', { - method: 'POST', - setCookies - }); +describe('Microsoft helpers', () => { + describe('getMsTranscriptApiUrl', () => { + const expectedUrl = + 'https://learn.microsoft.com/api/profiles/transcript/share/8u6awert43q1plo'; - expect(response.statusCode).toBe(401); - }); + 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`; + + it('should extract the transcript id from the url', () => { + expect(getMsTranscriptApiUrl(urlWithoutSlash)).toBe(expectedUrl); + }); + + it('should handle trailing slashes', () => { + expect(getMsTranscriptApiUrl(urlWithSlash)).toBe(expectedUrl); + }); + + it('should ignore query params', () => { + expect(getMsTranscriptApiUrl(urlWithQueryParams)).toBe(expectedUrl); + expect(getMsTranscriptApiUrl(urlWithQueryParamsAndSlash)).toBe( + expectedUrl + ); }); }); }); diff --git a/api/src/routes/user.ts b/api/src/routes/user.ts index b1f17fd7866..16aabb1f193 100644 --- a/api/src/routes/user.ts +++ b/api/src/routes/user.ts @@ -25,6 +25,24 @@ const nanoid = customAlphabet( 64 ); +/** + * Helper function to get the api url from the shared transcript link. + * + * @param msTranscript Shared transcript link. + * @returns Microsoft transcript api url. + */ +export const getMsTranscriptApiUrl = (msTranscript: string) => { + // example msTranscriptUrl: https://learn.microsoft.com/en-us/users/mot01/transcript/8u6awert43q1plo + const url = new URL(msTranscript); + + // TODO(Post-MVP): throw if it doesn't match? + const transcriptUrlRegex = /\/transcript\/([^/]+)\/?/; + const id = transcriptUrlRegex.exec(url.pathname)?.[1]; + return `https://learn.microsoft.com/api/profiles/transcript/share/${ + id ?? '' + }`; +}; + /** * Wrapper for endpoints related to user account management, * such as account deletion. @@ -256,6 +274,92 @@ export const userRoutes: FastifyPluginCallbackTypebox = ( } ); + fastify.post( + '/user/ms-username', + { + schema: schemas.postMsUsername, + errorHandler(error, request, reply) { + if (error.validation) { + void reply.code(400).send({ + message: 'flash.ms.transcript.link-err-1', + type: 'error' + }); + } else { + fastify.errorHandler(error, request, reply); + } + } + }, + async (req, reply) => { + try { + const user = await fastify.prisma.user.findUniqueOrThrow({ + where: { id: req.session.user.id } + }); + + const msApiRes = await fetch( + getMsTranscriptApiUrl(req.body.msTranscriptUrl) + ); + + if (!msApiRes.ok) { + return reply + .status(404) + .send({ type: 'error', message: 'flash.ms.transcript.link-err-2' }); + } + + const { userName } = (await msApiRes.json()) as { userName: string }; + + if (!userName) { + return reply.status(500).send({ + type: 'error', + message: 'flash.ms.transcript.link-err-3' + }); + } + + // TODO(Post-MVP): make msUsername unique, then we can simply try to + // create the record and catch the error. + const usernameUsed = !!(await fastify.prisma.msUsername.findFirst({ + where: { + msUsername: userName + } + })); + + if (usernameUsed) { + return reply.status(403).send({ + type: 'error', + message: 'flash.ms.transcript.link-err-4' + }); + } + + // TODO(Post-MVP): do we need to store tll in the database? We aren't + // storing the creation date, so we can't expire it. + + // 900 days in ms + const ttl = 900 * 24 * 60 * 60 * 1000; + + // TODO(Post-MVP): make userId unique and then we can upsert. + + await fastify.prisma.msUsername.deleteMany({ + where: { userId: user.id } + }); + + await fastify.prisma.msUsername.create({ + data: { + msUsername: userName, + ttl, + userId: user.id + } + }); + + return { msUsername: userName }; + } catch (err) { + fastify.log.error(err); + return reply.code(500).send({ + type: 'error', + message: 'flash.ms.transcript.link-err-6' + }); + } + } + ); + done(); }; diff --git a/api/src/schemas.ts b/api/src/schemas.ts index 57e434c9346..29e0165ffbb 100644 --- a/api/src/schemas.ts +++ b/api/src/schemas.ts @@ -634,5 +634,33 @@ export const schemas = { error: Type.String() }) } + }, + postMsUsername: { + body: Type.Object({ + msTranscriptUrl: Type.String({ maxLength: 1000 }) + }), + response: { + 200: Type.Object({ + msUsername: Type.String() + }), + 404: Type.Object({ + type: Type.Literal('error'), + message: Type.Literal('flash.ms.transcript.link-err-2') + }), + 403: Type.Object({ + type: Type.Literal('error'), + message: Type.Literal('flash.ms.transcript.link-err-4') + }), + 500: Type.Union([ + Type.Object({ + type: Type.Literal('error'), + message: Type.Literal('flash.ms.transcript.link-err-6') + }), + Type.Object({ + type: Type.Literal('error'), + message: Type.Literal('flash.ms.transcript.link-err-3') + }) + ]) + } } };