feat: update my username endpoint (#50271)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Sem Bauke
2023-06-02 07:04:50 +02:00
committed by GitHub
co-authored by Oliver Eyton-Williams
parent d6e30b2b39
commit c5deeccd3f
7 changed files with 494 additions and 127 deletions
+2
View File
@@ -13,6 +13,7 @@
"@immobiliarelabs/fastify-sentry": "^6.0.0",
"@prisma/client": "4.14.1",
"connect-mongo": "4.6.0",
"bad-words": "3.0.4",
"fastify": "4.17.0",
"fastify-auth0-verify": "^1.0.0",
"fastify-plugin": "^4.3.0",
@@ -23,6 +24,7 @@
"@fastify/type-provider-typebox": "3.2.0",
"@types/express-session": "1.17.7",
"@types/supertest": "2.0.12",
"@types/bad-words": "^3.0.1",
"ajv": "8.12.0",
"dotenv-cli": "7.2.1",
"jest": "29.5.0",
+1 -1
View File
@@ -124,7 +124,7 @@ model user {
timezone String? // Undefined
twitter String? // Null | Undefined
unsubscribeId String
username String
username String // TODO(Post-MVP): make this unique
usernameDisplay String? // Undefined
verificationToken String? // Undefined
website String? // Undefined
+204
View File
@@ -186,6 +186,199 @@ describe('settingRoutes', () => {
});
});
describe('/update-my-username', () => {
test('PUT returns an error when the username uses special characters', async () => {
const response = await superRequest('/update-my-username', {
method: 'PUT',
setCookies
}).send({
username: 'twaha@'
});
expect(response?.statusCode).toEqual(400);
expect(response?.body).toEqual({
message: 'Username twaha@ contains invalid characters',
type: 'info'
});
});
test('PUT returns an error when the username is an endpoint', async () => {
const response = await superRequest('/update-my-username', {
method: 'PUT',
setCookies
}).send({
username: 'german'
});
expect(response?.statusCode).toEqual(400);
expect(response?.body).toEqual({
message: 'flash.username-taken',
type: 'info'
});
});
test('PUT returns an error when the username is a bad word', async () => {
const response = await superRequest('/update-my-username', {
method: 'PUT',
setCookies
}).send({
username: 'ass'
});
expect(response?.statusCode).toEqual(400);
expect(response?.body).toEqual({
message: 'flash.username-taken',
type: 'info'
});
});
test('PUT returns an error when the username is a https status code', async () => {
const response = await superRequest('/update-my-username', {
method: 'PUT',
setCookies
}).send({
username: '404'
});
expect(response?.statusCode).toEqual(400);
expect(response?.body).toEqual({
message: 'Username 404 is a reserved error code',
type: 'info'
});
});
test('PUT returns an error when the username is shorter than 3 characters', async () => {
const response = await superRequest('/update-my-username', {
method: 'PUT',
setCookies
}).send({
username: 'fo'
});
expect(response?.statusCode).toEqual(400);
expect(response.body).toEqual({
message: 'body/username must NOT have fewer than 3 characters',
type: 'info'
});
});
test('PUT returns 200 status code with "success" message', async () => {
const response = await superRequest('/update-my-username', {
method: 'PUT',
setCookies
}).send({
username: 'TwaHa1'
});
expect(response?.statusCode).toEqual(200);
expect(response?.body).toEqual({
message: 'flash.username-updated',
type: 'success',
username: 'TwaHa1'
});
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: 'foo@bar.com' }
});
expect(user?.username).toEqual('twaha1');
});
test('PUT returns an error when the username is already used', async () => {
await fastifyTestInstance.prisma.user.create({
data: {
email: 'an@ran.dom',
username: 'sembauke',
about: 'about',
acceptedPrivacyTerms: true,
emailVerified: true,
externalId: 'externalId',
isDonating: true,
picture: 'picture',
sendQuincyEmail: true,
unsubscribeId: 'unsubscribeId'
}
});
await superRequest('/update-my-username', {
method: 'PUT',
setCookies
}).send({
username: 'twaha2'
});
const secondUpdate = await superRequest('/update-my-username', {
method: 'PUT',
setCookies
}).send({
username: 'twaha2'
});
expect(secondUpdate.body).toEqual({
message: 'flash.username-used',
type: 'info'
});
expect(secondUpdate.statusCode).toEqual(400);
// Not allowed because, while the usernameDisplay is different, the
// username is not
const existingUser = await superRequest('/update-my-username', {
method: 'PUT',
setCookies
}).send({
username: 'SemBauke'
});
expect(existingUser.body).toEqual({
message: 'flash.username-taken',
type: 'info'
});
expect(existingUser.statusCode).toEqual(400);
});
test('PUT returns 200 status code with "success" message', async () => {
await superRequest('/update-my-username', {
method: 'PUT',
setCookies
}).send({
username: 'twaha3'
});
const response = await superRequest('/update-my-username', {
method: 'PUT',
setCookies
}).send({
username: 'TWaha3'
});
expect(response?.body).toEqual({
message: 'flash.username-updated',
type: 'success',
username: 'TWaha3'
});
expect(response?.statusCode).toEqual(200);
});
test('PUT /update-my-username returns 400 status code when username is too long', async () => {
const username = 'a'.repeat(1001);
const response = await superRequest('/update-my-username', {
method: 'PUT',
setCookies
}).send({
username
});
expect(response?.statusCode).toEqual(400);
expect(response.body).toEqual({
message: 'body/username must NOT have more than 1000 characters',
type: 'info'
});
});
});
describe('/update-my-keyboard-shortcuts', () => {
test('PUT returns 200 status code with "success" message', async () => {
const response = await superRequest('/update-my-keyboard-shortcuts', {
@@ -363,5 +556,16 @@ describe('settingRoutes', () => {
expect(response?.statusCode).toEqual(401);
});
test('PUT /update-my-username returns 401 status code for un-authenticated users', async () => {
const response = await superRequest('/update-my-username', {
method: 'PUT',
setCookies
}).send({
username: 'twaha2'
});
expect(response?.statusCode).toEqual(401);
});
});
});
+92 -1
View File
@@ -1,5 +1,8 @@
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import badWordsFilter from 'bad-words';
import { isValidUsername } from '../../../utils/validate';
// we have to use this file as JavaScript because it is used by the old api.
import { blocklistedUsernames } from '../../../config/constants.js';
import { schemas } from '../schemas';
export const settingRoutes: FastifyPluginCallbackTypebox = (
@@ -106,6 +109,94 @@ export const settingRoutes: FastifyPluginCallbackTypebox = (
}
);
fastify.put(
'/update-my-username',
{
schema: schemas.updateMyUsername,
attachValidation: true
},
async (req, reply) => {
try {
const user = await fastify.prisma.user.findFirstOrThrow({
where: { id: req.session.user.id }
});
const newUsernameDisplay = req.body.username.trim();
const oldUsernameDisplay = user.usernameDisplay?.trim();
const newUsername = newUsernameDisplay.toLowerCase();
const oldUsername = user.username.toLowerCase();
const usernameUnchanged =
newUsername === oldUsername &&
newUsernameDisplay === oldUsernameDisplay;
if (usernameUnchanged) {
void reply.code(400);
return {
message: 'flash.username-used',
type: 'info'
} as const;
}
if (req.validationError) {
void reply.code(400);
return {
message: req.validationError.message,
type: 'info'
} as const;
}
const validation = isValidUsername(newUsername);
if (!validation.valid) {
void reply.code(400);
return {
// TODO(Post-MVP): custom validation errors.
message: `Username ${newUsername} ${validation.error}`,
type: 'info'
} as const;
}
const isProfane = new badWordsFilter().isProfane(newUsername);
const onBlocklist = blocklistedUsernames.includes(newUsername);
const usernameTaken =
newUsername === oldUsername
? false
: await fastify.prisma.user.count({
where: { username: newUsername }
});
if (usernameTaken || isProfane || onBlocklist) {
void reply.code(400);
return {
message: 'flash.username-taken',
type: 'info'
} as const;
}
await fastify.prisma.user.update({
where: { id: req.session.user.id },
data: {
username: newUsername,
usernameDisplay: newUsernameDisplay
}
});
return {
message: 'flash.username-updated',
type: 'success',
username: newUsernameDisplay
} as const;
} catch (err) {
fastify.log.error(err);
void reply.code(500);
return { message: 'flash.wrong-updating', type: 'danger' } as const;
}
}
);
fastify.put(
'/update-my-keyboard-shortcuts',
{
+20
View File
@@ -43,6 +43,26 @@ export const schemas = {
})
}
},
updateMyUsername: {
body: Type.Object({
username: Type.String({ minLength: 3, maxLength: 1000 })
}),
response: {
200: Type.Object({
message: Type.String(),
type: Type.Literal('success'),
username: Type.String()
}),
400: Type.Object({
message: Type.Optional(Type.String()),
type: Type.Literal('info')
}),
500: Type.Object({
message: Type.String(),
type: Type.Literal('danger')
})
}
},
updateMySocials: {
body: Type.Object({
website: Type.Optional(Type.String({ format: 'url', maxLength: 1024 })),
+1 -1
View File
@@ -2,7 +2,7 @@
"compilerOptions": {
"target": "es2022",
"module": "CommonJS",
"allowJs": false,
"allowJs": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,