271 lines
9.1 KiB
TypeScript
271 lines
9.1 KiB
TypeScript
import assert from 'node:assert/strict';
|
|||
|
|
import { after, before, describe, it } from 'node:test';
|
||
|
|
import { AuthProvider } from '@prisma/client';
|
||
|
|
import { prisma } from '../src/lib/prisma.js';
|
||
|
|
import { GENERIC_CREDENTIALS_MESSAGE } from '../src/modules/auth/auth.service.js';
|
||
|
|
import { isArgon2idHash } from '../src/modules/auth/password.js';
|
||
|
|
import { hashSessionToken } from '../src/modules/auth/sessionToken.js';
|
||
|
|
import {
|
||
|
|
assertNoSecrets,
|
||
|
|
authHeader,
|
||
|
|
createTestApp,
|
||
|
|
isDatabaseAvailable,
|
||
|
|
requestJson,
|
||
|
|
} from './helpers.js';
|
||
|
|
|
||
|
|
const app = createTestApp();
|
||
|
|
const createdUserIds: bigint[] = [];
|
||
|
|
|
||
|
|
async function registerUser(email: string, password: string) {
|
||
|
|
return requestJson(app, '/auth/register', {
|
||
|
|
method: 'POST',
|
||
|
|
body: JSON.stringify({ email, password }),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('authentication API', () => {
|
||
|
|
let dbAvailable = false;
|
||
|
|
|
||
|
|
before(async () => {
|
||
|
|
dbAvailable = await isDatabaseAvailable();
|
||
|
|
});
|
||
|
|
|
||
|
|
after(async () => {
|
||
|
|
if (dbAvailable && createdUserIds.length > 0) {
|
||
|
|
await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
async function trackUser(externalId: string | undefined) {
|
||
|
|
if (!externalId) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const user = await prisma.user.findUnique({ where: { externalId } });
|
||
|
|
if (user) {
|
||
|
|
createdUserIds.push(user.id);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
it('registers a valid email/password user', async (t) => {
|
||
|
|
if (!dbAvailable) {
|
||
|
|
t.skip('MySQL is not running');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const email = `reg-${Date.now()}@example.com`;
|
||
|
|
const { status, body, raw } = await registerUser(email, 'password12');
|
||
|
|
|
||
|
|
assert.equal(status, 201);
|
||
|
|
assert.equal(body.success, true);
|
||
|
|
assert.equal(body.data?.user && (body.data.user as { email: string }).email, email);
|
||
|
|
assert.equal((body.data?.user as { isGuest: boolean }).isGuest, false);
|
||
|
|
assert.equal((body.data?.user as { profileComplete: boolean }).profileComplete, false);
|
||
|
|
assert.equal(typeof body.data?.sessionToken, 'string');
|
||
|
|
assertNoSecrets(raw);
|
||
|
|
await trackUser((body.data?.user as { id: string }).id);
|
||
|
|
|
||
|
|
const identity = await prisma.authIdentity.findFirst({
|
||
|
|
where: { email, provider: AuthProvider.password },
|
||
|
|
});
|
||
|
|
assert.ok(identity?.passwordHash);
|
||
|
|
assert.equal(isArgon2idHash(identity.passwordHash), true);
|
||
|
|
assert.notEqual(identity.passwordHash, 'password12');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('rejects duplicate email', async (t) => {
|
||
|
|
if (!dbAvailable) {
|
||
|
|
t.skip('MySQL is not running');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const email = `dup-${Date.now()}@example.com`;
|
||
|
|
const first = await registerUser(email, 'password12');
|
||
|
|
await trackUser((first.body.data?.user as { id: string }).id);
|
||
|
|
|
||
|
|
const second = await registerUser(email.toUpperCase(), 'password12');
|
||
|
|
assert.equal(second.status, 409);
|
||
|
|
assert.equal(second.body.error?.code, 'CONFLICT');
|
||
|
|
assertNoSecrets(second.raw);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('rejects invalid email', async () => {
|
||
|
|
const { status, body, raw } = await registerUser('not-an-email', 'password12');
|
||
|
|
assert.equal(status, 422);
|
||
|
|
assert.equal(body.error?.code, 'VALIDATION_ERROR');
|
||
|
|
assertNoSecrets(raw);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('rejects invalid password', async () => {
|
||
|
|
const { status, body, raw } = await registerUser('short@example.com', 'short');
|
||
|
|
assert.equal(status, 422);
|
||
|
|
assert.equal(body.error?.code, 'VALIDATION_ERROR');
|
||
|
|
assertNoSecrets(raw);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('does not return password on registration', async (t) => {
|
||
|
|
if (!dbAvailable) {
|
||
|
|
t.skip('MySQL is not running');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const email = `nopw-${Date.now()}@example.com`;
|
||
|
|
const { raw, body } = await registerUser(email, 'password12');
|
||
|
|
await trackUser((body.data?.user as { id: string }).id);
|
||
|
|
assert.doesNotMatch(raw, /password/i);
|
||
|
|
assertNoSecrets(raw);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('logs in with valid credentials', async (t) => {
|
||
|
|
if (!dbAvailable) {
|
||
|
|
t.skip('MySQL is not running');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const email = `login-${Date.now()}@example.com`;
|
||
|
|
const registered = await registerUser(email, 'password12');
|
||
|
|
await trackUser((registered.body.data?.user as { id: string }).id);
|
||
|
|
|
||
|
|
const { status, body, raw } = await requestJson(app, '/auth/login', {
|
||
|
|
method: 'POST',
|
||
|
|
body: JSON.stringify({ email, password: 'password12' }),
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.equal(status, 200);
|
||
|
|
assert.equal(typeof body.data?.sessionToken, 'string');
|
||
|
|
assert.equal((body.data?.user as { email: string }).email, email);
|
||
|
|
assertNoSecrets(raw);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('rejects wrong password and unknown email with the same message', async (t) => {
|
||
|
|
if (!dbAvailable) {
|
||
|
|
t.skip('MySQL is not running');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const email = `enum-${Date.now()}@example.com`;
|
||
|
|
const registered = await registerUser(email, 'password12');
|
||
|
|
await trackUser((registered.body.data?.user as { id: string }).id);
|
||
|
|
|
||
|
|
const wrongPassword = await requestJson(app, '/auth/login', {
|
||
|
|
method: 'POST',
|
||
|
|
body: JSON.stringify({ email, password: 'wrong-password' }),
|
||
|
|
});
|
||
|
|
const unknownEmail = await requestJson(app, '/auth/login', {
|
||
|
|
method: 'POST',
|
||
|
|
body: JSON.stringify({ email: `missing-${Date.now()}@example.com`, password: 'password12' }),
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.equal(wrongPassword.status, 401);
|
||
|
|
assert.equal(unknownEmail.status, 401);
|
||
|
|
assert.equal(wrongPassword.body.error?.code, 'INVALID_CREDENTIALS');
|
||
|
|
assert.equal(unknownEmail.body.error?.code, 'INVALID_CREDENTIALS');
|
||
|
|
assert.equal(wrongPassword.body.error?.message, GENERIC_CREDENTIALS_MESSAGE);
|
||
|
|
assert.equal(unknownEmail.body.error?.message, GENERIC_CREDENTIALS_MESSAGE);
|
||
|
|
assert.equal(wrongPassword.body.error?.message, unknownEmail.body.error?.message);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('authenticates /auth/me with a valid token', async (t) => {
|
||
|
|
if (!dbAvailable) {
|
||
|
|
t.skip('MySQL is not running');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const email = `me-${Date.now()}@example.com`;
|
||
|
|
const registered = await registerUser(email, 'password12');
|
||
|
|
await trackUser((registered.body.data?.user as { id: string }).id);
|
||
|
|
const token = registered.body.data?.sessionToken as string;
|
||
|
|
|
||
|
|
const { status, body, raw } = await requestJson(app, '/auth/me', {
|
||
|
|
headers: authHeader(token),
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.equal(status, 200);
|
||
|
|
assert.equal((body.data as { email: string }).email, email);
|
||
|
|
assert.equal((body.data as { isGuest: boolean }).isGuest, false);
|
||
|
|
assert.doesNotMatch(raw, /sessionToken/);
|
||
|
|
assertNoSecrets(raw);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('rejects a missing bearer token', async () => {
|
||
|
|
const missing = await requestJson(app, '/auth/me');
|
||
|
|
assert.equal(missing.status, 401);
|
||
|
|
assert.equal(missing.body.error?.code, 'UNAUTHORIZED');
|
||
|
|
assertNoSecrets(missing.raw);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('rejects missing, invalid, expired, and revoked sessions', async (t) => {
|
||
|
|
if (!dbAvailable) {
|
||
|
|
t.skip('MySQL is not running');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const missing = await requestJson(app, '/auth/me');
|
||
|
|
assert.equal(missing.status, 401);
|
||
|
|
|
||
|
|
const invalid = await requestJson(app, '/auth/me', {
|
||
|
|
headers: authHeader('not-a-valid-token'),
|
||
|
|
});
|
||
|
|
assert.equal(invalid.status, 401);
|
||
|
|
|
||
|
|
const email = `sess-${Date.now()}@example.com`;
|
||
|
|
const registered = await registerUser(email, 'password12');
|
||
|
|
const token = registered.body.data?.sessionToken as string;
|
||
|
|
await trackUser((registered.body.data?.user as { id: string }).id);
|
||
|
|
|
||
|
|
const session = await prisma.authSession.findUnique({
|
||
|
|
where: { tokenHash: hashSessionToken(token) },
|
||
|
|
});
|
||
|
|
assert.ok(session);
|
||
|
|
|
||
|
|
await prisma.authSession.update({
|
||
|
|
where: { id: session.id },
|
||
|
|
data: { expiresAt: new Date(Date.now() - 1000) },
|
||
|
|
});
|
||
|
|
|
||
|
|
const expired = await requestJson(app, '/auth/me', { headers: authHeader(token) });
|
||
|
|
assert.equal(expired.status, 401);
|
||
|
|
|
||
|
|
const second = await registerUser(`sess2-${Date.now()}@example.com`, 'password12');
|
||
|
|
await trackUser((second.body.data?.user as { id: string }).id);
|
||
|
|
const liveToken = second.body.data?.sessionToken as string;
|
||
|
|
|
||
|
|
const logout = await requestJson(app, '/auth/logout', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: authHeader(liveToken),
|
||
|
|
});
|
||
|
|
assert.equal(logout.status, 200);
|
||
|
|
|
||
|
|
const revoked = await requestJson(app, '/auth/me', { headers: authHeader(liveToken) });
|
||
|
|
assert.equal(revoked.status, 401);
|
||
|
|
|
||
|
|
const repeatedLogout = await requestJson(app, '/auth/logout', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: authHeader(liveToken),
|
||
|
|
});
|
||
|
|
assert.equal(repeatedLogout.status, 401);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('creates a guest user with a session', async (t) => {
|
||
|
|
if (!dbAvailable) {
|
||
|
|
t.skip('MySQL is not running');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const { status, body, raw } = await requestJson(app, '/auth/guest', { method: 'POST' });
|
||
|
|
assert.equal(status, 201);
|
||
|
|
assert.equal((body.data?.user as { isGuest: boolean }).isGuest, true);
|
||
|
|
assert.equal((body.data?.user as { email: string | null }).email, null);
|
||
|
|
assert.equal(typeof body.data?.sessionToken, 'string');
|
||
|
|
assert.doesNotMatch((body.data?.user as { id: string }).id, /^guest-/);
|
||
|
|
assertNoSecrets(raw);
|
||
|
|
await trackUser((body.data?.user as { id: string }).id);
|
||
|
|
|
||
|
|
const me = await requestJson(app, '/auth/me', {
|
||
|
|
headers: authHeader(body.data?.sessionToken as string),
|
||
|
|
});
|
||
|
|
assert.equal(me.status, 200);
|
||
|
|
assert.equal((me.body.data as { isGuest: boolean }).isGuest, true);
|
||
|
|
});
|
||
|
|
});
|