feat(api): POST /user/ms-username (#51764)
parent
106bb00307
commit
6163330b70
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
])
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue