Files
ar.badieiandCursor 370ac15471 Ship Novya PWA with backend for deployable production use.
Fix production TypeScript builds and add DEPLOY.md so the app can be hosted from this repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 21:01:52 +03:30

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);
});
});