185 lines
4.8 KiB
TypeScript
185 lines
4.8 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import type { TestContext } from 'node:test';
|
|
import bcrypt from 'bcrypt';
|
|
import type { Transaction } from 'sequelize';
|
|
|
|
import config from '../../src/config.ts';
|
|
import db from '../../src/db/models/index.ts';
|
|
import UsersDBApi from '../../src/db/api/users.ts';
|
|
import AuthService from '../../src/services/auth.ts';
|
|
import type { UserRecord } from '../../src/types/index.ts';
|
|
|
|
const suffix = `${Date.now()}-${process.pid}`;
|
|
|
|
void test.after(async () => {
|
|
await db.sequelize.close();
|
|
});
|
|
|
|
async function authenticateWithTimeout(timeoutMs = 1500): Promise<void> {
|
|
let timeoutId: NodeJS.Timeout | undefined;
|
|
const timeout = new Promise<never>((_, reject) => {
|
|
timeoutId = setTimeout(
|
|
() => reject(new Error(`Database unavailable after ${timeoutMs}ms`)),
|
|
timeoutMs,
|
|
);
|
|
});
|
|
|
|
try {
|
|
await Promise.race([db.sequelize.authenticate(), timeout]);
|
|
} finally {
|
|
if (timeoutId !== undefined) {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function withTransaction(
|
|
t: TestContext,
|
|
callback: (transaction: Transaction) => Promise<void>,
|
|
): Promise<void> {
|
|
try {
|
|
await authenticateWithTimeout();
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'unknown error';
|
|
t.skip(`Database unavailable: ${message}`);
|
|
return;
|
|
}
|
|
|
|
const transaction = await db.sequelize.transaction();
|
|
try {
|
|
await callback(transaction);
|
|
} finally {
|
|
await transaction.rollback();
|
|
}
|
|
}
|
|
|
|
async function createAuthUser(
|
|
email: string,
|
|
password: string,
|
|
transaction: Transaction,
|
|
): Promise<UserRecord> {
|
|
const hashedPassword = await bcrypt.hash(password, config.bcrypt.saltRounds);
|
|
|
|
return db.users.create(
|
|
{
|
|
email,
|
|
password: hashedPassword,
|
|
emailVerified: false,
|
|
disabled: false,
|
|
provider: config.providers.LOCAL,
|
|
createdById: null,
|
|
updatedById: null,
|
|
},
|
|
{ transaction },
|
|
);
|
|
}
|
|
|
|
async function getUserPassword(
|
|
id: string,
|
|
transaction: Transaction,
|
|
): Promise<string> {
|
|
const user = await db.users.findByPk(id, { transaction });
|
|
|
|
if (!user?.password) {
|
|
throw new Error('Expected user password to exist.');
|
|
}
|
|
|
|
return user.password;
|
|
}
|
|
|
|
function requireEmail(user: UserRecord): string {
|
|
if (!user.email) {
|
|
throw new Error('Expected user email to exist.');
|
|
}
|
|
|
|
return user.email;
|
|
}
|
|
|
|
void test('passwordReset updates password for a valid reset token', async (t) => {
|
|
await withTransaction(t, async (transaction) => {
|
|
const user = await createAuthUser(
|
|
`reset-${suffix}@example.test`,
|
|
'old-password',
|
|
transaction,
|
|
);
|
|
const token = await UsersDBApi.generatePasswordResetToken(requireEmail(user), {
|
|
transaction,
|
|
});
|
|
|
|
await AuthService.passwordReset(token, 'new-password', { transaction });
|
|
|
|
const updatedPassword = await getUserPassword(user.id, transaction);
|
|
assert.equal(await bcrypt.compare('new-password', updatedPassword), true);
|
|
assert.equal(await bcrypt.compare('old-password', updatedPassword), false);
|
|
});
|
|
});
|
|
|
|
void test('passwordReset rejects invalid reset tokens', async (t) => {
|
|
await withTransaction(t, async (transaction) => {
|
|
await assert.rejects(
|
|
() =>
|
|
AuthService.passwordReset('invalid-token', 'new-password', {
|
|
transaction,
|
|
}),
|
|
/Password reset link is invalid or has expired/,
|
|
);
|
|
});
|
|
});
|
|
|
|
void test('passwordUpdate requires current password and rejects password reuse', async (t) => {
|
|
await withTransaction(t, async (transaction) => {
|
|
const user = await createAuthUser(
|
|
`update-${suffix}@example.test`,
|
|
'current-password',
|
|
transaction,
|
|
);
|
|
const currentPassword = await getUserPassword(user.id, transaction);
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
AuthService.passwordUpdate('wrong-password', 'next-password', {
|
|
currentUser: {
|
|
id: user.id,
|
|
password: currentPassword,
|
|
},
|
|
transaction,
|
|
}),
|
|
/credentials/,
|
|
);
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
AuthService.passwordUpdate('current-password', 'current-password', {
|
|
currentUser: {
|
|
id: user.id,
|
|
password: currentPassword,
|
|
},
|
|
transaction,
|
|
}),
|
|
/same password/i,
|
|
);
|
|
});
|
|
});
|
|
|
|
void test('verifyEmail marks users as verified for a valid email token', async (t) => {
|
|
await withTransaction(t, async (transaction) => {
|
|
const user = await createAuthUser(
|
|
`verify-${suffix}@example.test`,
|
|
'password',
|
|
transaction,
|
|
);
|
|
const token = await UsersDBApi.generateEmailVerificationToken(requireEmail(user), {
|
|
transaction,
|
|
});
|
|
|
|
assert.equal(
|
|
await AuthService.verifyEmail(token, { transaction }),
|
|
true,
|
|
);
|
|
|
|
const reloaded = await db.users.findByPk(user.id, { transaction });
|
|
assert.equal(reloaded?.emailVerified, true);
|
|
});
|
|
});
|