feat(api): POST /user/ms-username (#51764)

pull/52212/head
Oliver Eyton-Williams 2023-11-03 09:45:21 +01:00 committed by GitHub
parent 106bb00307
commit 6163330b70
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 394 additions and 9 deletions

View File

@ -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
);
});
});
});

View File

@ -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();
};

View File

@ -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')
})
])
}
}
};