Skip to content

Commit

Permalink
fix(user): release username and email after activation account token …
Browse files Browse the repository at this point in the history
…expires

There is an extra safeguard in `ON CONFLICT` SQL to prevent duplicate users from being created if
this has not been validated before.
  • Loading branch information
Rafatcb committed Sep 4, 2024
1 parent 26de9ba commit 772a72b
Show file tree
Hide file tree
Showing 3 changed files with 317 additions and 26 deletions.
97 changes: 71 additions & 26 deletions models/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -234,30 +234,69 @@ async function create(postedUserData) {

validUserData.features = ['read:activation_token'];

const newUser = await runInsertQuery(validUserData);
return newUser;
const query = getInsertQuery(validUserData, 'LOWER(username)');

let results;
try {
results = await database.query(query);
} catch (error) {
if (error.databaseErrorCode !== database.errorCodes.UNIQUE_CONSTRAINT_VIOLATION) {
throw error;
}

async function runInsertQuery(validUserData) {
const query = {
text: `
INSERT INTO
users (username, email, password, features)
VALUES
($1, $2, $3, $4)
RETURNING
*
;`,
values: [validUserData.username, validUserData.email, validUserData.password, validUserData.features],
};
const query = getInsertQuery(validUserData, 'email');
results = await database.query(query);
}

const results = await database.query(query);
const newUser = results.rows[0];
const newUser = results.rows[0];

newUser.tabcoins = 0;
newUser.tabcash = 0;
newUser.tabcoins = 0;
newUser.tabcash = 0;

return newUser;
}
return newUser;
}

function getInsertQuery(validUserData, constraint) {
return {
text: `
INSERT INTO
users (username, email, password, features)
VALUES
($1, $2, $3, $4)
ON CONFLICT (${constraint}) DO
UPDATE SET
username = $1,
email = $2,
password = $3,
features = $4,
created_at = NOW(),
updated_at = NOW(),
rewarded_at = NOW()
WHERE
(
SELECT
COUNT(*)
FROM
users u
LEFT JOIN
activate_account_tokens aat ON u.id = aat.user_id
WHERE
(
LOWER(u.username) = LOWER($1) OR
LOWER(u.email) = LOWER($2)
)
AND
(
COALESCE(aat.used, true) OR
aat.expires_at > NOW() OR
'nuked' = ANY(u.features)
)
) = 0
RETURNING
*
;`,
values: [validUserData.username, validUserData.email, validUserData.password, validUserData.features],
};
}

function createAnonymous() {
Expand Down Expand Up @@ -375,29 +414,35 @@ async function validateUniqueUser(userData, options) {

if (userData.username) {
queryValues.push(userData.username);
orConditions.push(`LOWER(username) = LOWER($${queryValues.length})`);
orConditions.push(`LOWER(u.username) = LOWER($${queryValues.length})`);
}

if (userData.email) {
queryValues.push(userData.email);
orConditions.push(`LOWER(email) = LOWER($${queryValues.length})`);
orConditions.push(`LOWER(u.email) = LOWER($${queryValues.length})`);
}

let where = `(${orConditions.join(' OR ')})`;

if (userData.id) {
queryValues.push(userData.id);
where += ` AND id <> $${queryValues.length}`;
where += ` AND u.id <> $${queryValues.length}`;
}

const query = {
text: `
SELECT
username,
email
u.username,
u.email
FROM
users
users u
LEFT JOIN activate_account_tokens aat ON user_id = u.id
WHERE
(
COALESCE(aat.used, true) OR
aat.expires_at > NOW() OR
'nuked' = ANY(u.features)
) AND
${where}
`,
values: queryValues,
Expand Down
214 changes: 214 additions & 0 deletions tests/integration/api/v1/users/post.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { version as uuidVersion } from 'uuid';
import password from 'models/password.js';
import user from 'models/user.js';
import orchestrator from 'tests/orchestrator.js';
import RequestBuilder from 'tests/request-builder';

beforeAll(async () => {
await orchestrator.waitForAllServices();
Expand All @@ -11,6 +12,8 @@ beforeAll(async () => {
});

describe('POST /api/v1/users', () => {
const usersRequestBuilder = new RequestBuilder('/api/v1/users');

describe('Anonymous user', () => {
test('With unique and valid data', async () => {
const response = await fetch(`${orchestrator.webserverUrl}/api/v1/users`, {
Expand Down Expand Up @@ -208,6 +211,43 @@ describe('POST /api/v1/users', () => {
expect(secondResponseBody.key).toBe('username');
});

test('With "username" and "email" duplicated', async () => {
const { response: firstResponse, responseBody: firstResponseBody } = await usersRequestBuilder.post({
username: 'SaMeUsErNaMe',
email: '[email protected]',
password: 'validpassword',
});

expect.soft(firstResponse.status).toBe(201);

await orchestrator.nukeUser(firstResponseBody);

const activateAccountToken = await orchestrator.getLastActivateAccountToken();
await orchestrator.updateActivateAccountToken(activateAccountToken.id, {
expires_at: new Date(Date.now() - 1000),
});

const { response: secondResponse, responseBody: secondResponseBody } = await usersRequestBuilder.post({
username: 'sameUsername',
email: '[email protected]',
password: 'new-password',
});

expect.soft(secondResponse.status).toBe(400);
expect(secondResponseBody).toStrictEqual({
status_code: 400,
name: 'ValidationError',
message: 'O "username" informado já está sendo usado.',
action: 'Ajuste os dados enviados e tente novamente.',
error_location_code: 'MODEL:USER:VALIDATE_UNIQUE_USERNAME:ALREADY_EXISTS',
key: 'username',
error_id: secondResponseBody.error_id,
request_id: secondResponseBody.request_id,
});
expect(uuidVersion(secondResponseBody.error_id)).toBe(4);
expect(uuidVersion(secondResponseBody.request_id)).toBe(4);
});

test('With "username" missing', async () => {
const response = await fetch(`${orchestrator.webserverUrl}/api/v1/users`, {
method: 'POST',
Expand Down Expand Up @@ -762,4 +802,178 @@ describe('POST /api/v1/users', () => {
expect(responseBody.key).toBe('object');
});
});

test('With a duplicate "username" for a user with expired activation token', async () => {
const { response: firstResponse, responseBody: firstResponseBody } = await usersRequestBuilder.post({
username: 'ARepeatedUsername',
email: '[email protected]',
password: 'validpassword',
});

expect.soft(firstResponse.status).toBe(201);

const activateAccountToken = await orchestrator.getLastActivateAccountToken();
await orchestrator.updateActivateAccountToken(activateAccountToken.id, {
expires_at: new Date(Date.now() - 1000),
});

const { response: secondResponse, responseBody: secondResponseBody } = await usersRequestBuilder.post({
username: 'ARepeatedUSERNAME',
email: '[email protected]',
password: 'new-password',
});

expect.soft(secondResponse.status).toBe(201);
expect(secondResponseBody).toStrictEqual({
id: firstResponseBody.id,
username: 'ARepeatedUSERNAME',
description: '',
features: ['read:activation_token'],
tabcoins: 0,
tabcash: 0,
created_at: secondResponseBody.created_at,
updated_at: secondResponseBody.updated_at,
});

expect(new Date(secondResponseBody.created_at).getTime()).toBeGreaterThan(
new Date(firstResponseBody.created_at).getTime(),
);
expect(new Date(secondResponseBody.updated_at).getTime()).toBeGreaterThan(
new Date(firstResponseBody.updated_at).getTime(),
);

const userInDatabase = await user.findOneByUsername('ARepeatedUSERNAME');
const passwordsMatch = await password.compare('new-password', userInDatabase.password);

expect(passwordsMatch).toBe(true);
expect(userInDatabase.email).toBe('[email protected]');
});

test('With a duplicate "email" for a user with expired activation token', async () => {
const { response: firstResponse, responseBody: firstResponseBody } = await usersRequestBuilder.post({
username: 'ARepeatedEmail',
email: '[email protected]',
password: 'validpassword',
});

expect.soft(firstResponse.status).toBe(201);

const activateAccountToken = await orchestrator.getLastActivateAccountToken();
await orchestrator.updateActivateAccountToken(activateAccountToken.id, {
expires_at: new Date(Date.now() - 1000),
});

const { response: secondResponse, responseBody: secondResponseBody } = await usersRequestBuilder.post({
username: 'ARepeatedEmail2',
email: '[email protected]',
password: 'new-password',
});

expect.soft(secondResponse.status).toBe(201);
expect(secondResponseBody).toStrictEqual({
id: firstResponseBody.id,
username: 'ARepeatedEmail2',
description: '',
features: ['read:activation_token'],
tabcoins: 0,
tabcash: 0,
created_at: secondResponseBody.created_at,
updated_at: secondResponseBody.updated_at,
});

expect(new Date(secondResponseBody.created_at).getTime()).toBeGreaterThan(
new Date(firstResponseBody.created_at).getTime(),
);
expect(new Date(secondResponseBody.updated_at).getTime()).toBeGreaterThan(
new Date(firstResponseBody.updated_at).getTime(),
);

const userInDatabase = await user.findOneByUsername('ARepeatedEmail2');
const passwordsMatch = await password.compare('new-password', userInDatabase.password);

expect(passwordsMatch).toBe(true);
expect(userInDatabase.email).toBe('[email protected]');
});

test('With a duplicate "username" for a nuked user with expired activation token', async () => {
const { response: firstResponse, responseBody: firstResponseBody } = await usersRequestBuilder.post({
username: 'NukedSameUsername',
email: '[email protected]',
password: 'validpassword',
});

expect.soft(firstResponse.status).toBe(201);

await orchestrator.nukeUser(firstResponseBody);

const activateAccountToken = await orchestrator.getLastActivateAccountToken();
await orchestrator.updateActivateAccountToken(activateAccountToken.id, {
expires_at: new Date(Date.now() - 1000),
});

const { response: secondResponse, responseBody: secondResponseBody } = await usersRequestBuilder.post({
username: 'NukedSameUsername',
email: '[email protected]',
password: 'new-password',
});

expect.soft(secondResponse.status).toBe(400);
expect(secondResponseBody).toStrictEqual({
status_code: 400,
name: 'ValidationError',
message: 'O "username" informado já está sendo usado.',
action: 'Ajuste os dados enviados e tente novamente.',
error_location_code: 'MODEL:USER:VALIDATE_UNIQUE_USERNAME:ALREADY_EXISTS',
key: 'username',
error_id: secondResponseBody.error_id,
request_id: secondResponseBody.request_id,
});
expect(uuidVersion(secondResponseBody.error_id)).toBe(4);
expect(uuidVersion(secondResponseBody.request_id)).toBe(4);
});

test('With "username" and "email" duplicated from different users, one with expired token and the other nuked', async () => {
const { response: firstResponse, responseBody: firstResponseBody } = await usersRequestBuilder.post({
username: 'firstUserNuked',
email: '[email protected]',
password: 'validpassword',
});

expect.soft(firstResponse.status).toBe(201);

await orchestrator.nukeUser(firstResponseBody);

const { response: secondResponse } = await usersRequestBuilder.post({
username: 'secondUserExpiredToken',
email: '[email protected]',
password: 'validpassword',
});

expect.soft(secondResponse.status).toBe(201);

const activateAccountToken = await orchestrator.getLastActivateAccountToken();
await orchestrator.updateActivateAccountToken(activateAccountToken.id, {
expires_at: new Date(Date.now() - 1000),
});

const { response: thirdResponse, responseBody: thirdResponseBody } = await usersRequestBuilder.post({
username: 'firstUserNuked',
email: '[email protected]',
password: 'new-password',
});

expect.soft(thirdResponse.status).toBe(400);
expect(thirdResponseBody).toStrictEqual({
status_code: 400,
name: 'ValidationError',
message: 'O "username" informado já está sendo usado.',
action: 'Ajuste os dados enviados e tente novamente.',
error_location_code: 'MODEL:USER:VALIDATE_UNIQUE_USERNAME:ALREADY_EXISTS',
key: 'username',
error_id: thirdResponseBody.error_id,
request_id: thirdResponseBody.request_id,
});
expect(uuidVersion(thirdResponseBody.error_id)).toBe(4);
expect(uuidVersion(thirdResponseBody.request_id)).toBe(4);
});
});
Loading

0 comments on commit 772a72b

Please sign in to comment.