mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
1059 lines
51 KiB
TypeScript
1059 lines
51 KiB
TypeScript
/**
|
|
* Authentication integration tests.
|
|
* Covers AUTH-001 to AUTH-022, AUTH-028 to AUTH-033.
|
|
* OIDC scenarios (AUTH-023 to AUTH-027) require a real IdP and are excluded.
|
|
* Rate limiting scenarios (AUTH-004, AUTH-018) are at the end of this file.
|
|
*/
|
|
import { buildApp } from '../../src/bootstrap';
|
|
import { db as testDb } from '../../src/db/database';
|
|
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
|
|
import { BucketList } from '../../src/db/entities/BucketList.entity';
|
|
import { BudgetItems } from '../../src/db/entities/BudgetItems.entity';
|
|
import { Categories } from '../../src/db/entities/Categories.entity';
|
|
import { CollabNotes } from '../../src/db/entities/CollabNotes.entity';
|
|
import { InviteTokens } from '../../src/db/entities/InviteTokens.entity';
|
|
import { JourneyContributors } from '../../src/db/entities/JourneyContributors.entity';
|
|
import { JourneyEntries } from '../../src/db/entities/JourneyEntries.entity';
|
|
import { JourneyShareTokens } from '../../src/db/entities/JourneyShareTokens.entity';
|
|
import { Journeys } from '../../src/db/entities/Journeys.entity';
|
|
import { McpTokens } from '../../src/db/entities/McpTokens.entity';
|
|
import { NotificationChannelPreferences } from '../../src/db/entities/NotificationChannelPreferences.entity';
|
|
import { Notifications } from '../../src/db/entities/Notifications.entity';
|
|
import { OauthClients } from '../../src/db/entities/OauthClients.entity';
|
|
import { OauthConsents } from '../../src/db/entities/OauthConsents.entity';
|
|
import { OauthTokens } from '../../src/db/entities/OauthTokens.entity';
|
|
import { PackingBags } from '../../src/db/entities/PackingBags.entity';
|
|
import { PackingTemplates } from '../../src/db/entities/PackingTemplates.entity';
|
|
import { PasswordResetTokens } from '../../src/db/entities/PasswordResetTokens.entity';
|
|
import { Settings } from '../../src/db/entities/Settings.entity';
|
|
import { ShareTokens } from '../../src/db/entities/ShareTokens.entity';
|
|
import { Tags } from '../../src/db/entities/Tags.entity';
|
|
import { TodoItems } from '../../src/db/entities/TodoItems.entity';
|
|
import { TrekPhotos } from '../../src/db/entities/TrekPhotos.entity';
|
|
import { TripFiles } from '../../src/db/entities/TripFiles.entity';
|
|
import { TripMembers } from '../../src/db/entities/TripMembers.entity';
|
|
import { TripPhotos } from '../../src/db/entities/TripPhotos.entity';
|
|
import { Trips } from '../../src/db/entities/Trips.entity';
|
|
import { UserNoticeDismissals } from '../../src/db/entities/UserNoticeDismissals.entity';
|
|
import { Users } from '../../src/db/entities/Users.entity';
|
|
import { VacayPlanMembers } from '../../src/db/entities/VacayPlanMembers.entity';
|
|
import { VacayPlans } from '../../src/db/entities/VacayPlans.entity';
|
|
import { VisitedCountries } from '../../src/db/entities/VisitedCountries.entity';
|
|
import { VisitedRegions } from '../../src/db/entities/VisitedRegions.entity';
|
|
import { dbNow } from '../../src/db/types';
|
|
import { authCookie, authHeader } from '../helpers/auth';
|
|
import {
|
|
createUser,
|
|
createAdmin,
|
|
createUserWithMfa,
|
|
createInviteToken,
|
|
createTrip,
|
|
createBudgetItem,
|
|
createJourney,
|
|
createJourneyEntry,
|
|
addJourneyContributor,
|
|
addTripPhoto,
|
|
createCategory,
|
|
createTag,
|
|
createTodoItem,
|
|
createMcpToken,
|
|
createBucketListItem,
|
|
createVisitedCountry,
|
|
createCollabNote,
|
|
addTripMember,
|
|
} from '../helpers/factories';
|
|
import { findRow, insertRow, insertRowIgnoringConflict, updateRows } from '../helpers/factories/rows';
|
|
import { setAppSetting, setUserSetting } from '../helpers/factories/settings';
|
|
import { makeShareToken } from '../helpers/factories/trips';
|
|
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
|
import type { EntityClass, FilterQuery } from '@mikro-orm/core';
|
|
import { MikroORM } from '@mikro-orm/core';
|
|
import type { INestApplication } from '@nestjs/common';
|
|
|
|
import type { Application } from 'express';
|
|
import { authenticator } from 'otplib';
|
|
import request from 'supertest';
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Step 1: Bare in-memory DB — schema applied in beforeAll after mocks register
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
vi.mock('../../src/db/database', async () => {
|
|
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
|
return buildDbMock(createSnapshotTestDb());
|
|
});
|
|
|
|
let nestApp: INestApplication;
|
|
let app: Application;
|
|
let orm: MikroORM;
|
|
|
|
beforeAll(async () => {
|
|
nestApp = await buildApp();
|
|
app = nestApp.getHttpAdapter().getInstance();
|
|
orm = nestApp.get(MikroORM);
|
|
});
|
|
|
|
/** The row matching `where`, or null once it is gone. */
|
|
function rowOf<T extends object>(entity: EntityClass<T>, where: FilterQuery<T>) {
|
|
return findRow(orm, entity, where);
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
resetTestDb(testDb);
|
|
// Reset rate limiter state between tests so they don't interfere
|
|
await resetRateLimits(nestApp);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await nestApp.close();
|
|
testDb.close();
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Login
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('Login', () => {
|
|
it('AUTH-001 — successful login returns 200, user object, and trek_session cookie', async () => {
|
|
const { user, password } = createUser(testDb);
|
|
const res = await request(app).post('/api/auth/login').send({ email: user.email, password });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.user).toBeDefined();
|
|
expect(res.body.user.email).toBe(user.email);
|
|
expect(res.body.user.password_hash).toBeUndefined();
|
|
const cookies: string[] = Array.isArray(res.headers['set-cookie'])
|
|
? res.headers['set-cookie']
|
|
: [res.headers['set-cookie']];
|
|
expect(cookies.some((c: string) => c.includes('trek_session'))).toBe(true);
|
|
});
|
|
|
|
it('AUTH-002 — wrong password returns 401 with generic message', async () => {
|
|
const { user } = createUser(testDb);
|
|
const res = await request(app).post('/api/auth/login').send({ email: user.email, password: 'WrongPass1!' });
|
|
expect(res.status).toBe(401);
|
|
expect(res.body.error).toContain('Invalid email or password');
|
|
});
|
|
|
|
it('AUTH-003 — non-existent email returns 401 with same generic message (no user enumeration)', async () => {
|
|
const res = await request(app)
|
|
.post('/api/auth/login')
|
|
.send({ email: 'nobody@example.com', password: 'SomePass1!' });
|
|
expect(res.status).toBe(401);
|
|
// Must be same message as wrong-password to avoid email enumeration
|
|
expect(res.body.error).toContain('Invalid email or password');
|
|
});
|
|
|
|
it('AUTH-013 — POST /api/auth/logout clears session cookie', async () => {
|
|
const res = await request(app).post('/api/auth/logout');
|
|
expect(res.status).toBe(200);
|
|
const cookies: string[] = Array.isArray(res.headers['set-cookie'])
|
|
? res.headers['set-cookie']
|
|
: res.headers['set-cookie']
|
|
? [res.headers['set-cookie']]
|
|
: [];
|
|
const sessionCookie = cookies.find((c: string) => c.includes('trek_session'));
|
|
expect(sessionCookie).toBeDefined();
|
|
expect(sessionCookie).toMatch(/expires=Thu, 01 Jan 1970|Max-Age=0/i);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Registration
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('Registration', () => {
|
|
it('AUTH-005 — first user registration creates admin role and returns 201 + cookie', async () => {
|
|
const res = await request(app).post('/api/auth/register').send({
|
|
username: 'firstadmin',
|
|
email: 'admin@example.com',
|
|
password: 'Str0ng!Pass',
|
|
});
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.user.role).toBe('admin');
|
|
const cookies: string[] = Array.isArray(res.headers['set-cookie'])
|
|
? res.headers['set-cookie']
|
|
: [res.headers['set-cookie']];
|
|
expect(cookies.some((c: string) => c.includes('trek_session'))).toBe(true);
|
|
});
|
|
|
|
it('AUTH-006 — registration with weak password is rejected', async () => {
|
|
const res = await request(app).post('/api/auth/register').send({
|
|
username: 'weakpwduser',
|
|
email: 'weak@example.com',
|
|
password: 'short',
|
|
});
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toBeDefined();
|
|
});
|
|
|
|
it('AUTH-007 — registration with common password is rejected', async () => {
|
|
const res = await request(app).post('/api/auth/register').send({
|
|
username: 'commonpwd',
|
|
email: 'common@example.com',
|
|
password: 'Password1', // 'password1' is in the COMMON_PASSWORDS set
|
|
});
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/common/i);
|
|
});
|
|
|
|
it('AUTH-008 — registration with duplicate email returns 409', async () => {
|
|
createUser(testDb, { email: 'taken@example.com' });
|
|
const res = await request(app).post('/api/auth/register').send({
|
|
username: 'newuser',
|
|
email: 'taken@example.com',
|
|
password: 'Str0ng!Pass',
|
|
});
|
|
expect(res.status).toBe(409);
|
|
});
|
|
|
|
it('AUTH-009 — registration disabled by admin returns 403', async () => {
|
|
createUser(testDb);
|
|
await setAppSetting(orm, 'allow_registration', 'false');
|
|
const res = await request(app).post('/api/auth/register').send({
|
|
username: 'blocked',
|
|
email: 'blocked@example.com',
|
|
password: 'Str0ng!Pass',
|
|
});
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toMatch(/disabled/i);
|
|
});
|
|
|
|
it('AUTH-010 — registration with valid invite token succeeds even when registration disabled', async () => {
|
|
const { user: admin } = createAdmin(testDb);
|
|
await setAppSetting(orm, 'allow_registration', 'false');
|
|
const invite = createInviteToken(testDb, { max_uses: 1, created_by: admin.id });
|
|
|
|
const res = await request(app).post('/api/auth/register').send({
|
|
username: 'invited',
|
|
email: 'invited@example.com',
|
|
password: 'Str0ng!Pass',
|
|
invite_token: invite.token,
|
|
});
|
|
expect(res.status).toBe(201);
|
|
|
|
const row = (await rowOf(InviteTokens, { id: invite.id }))!;
|
|
expect(row.used_count).toBe(1);
|
|
});
|
|
|
|
it('AUTH-011 — GET /api/auth/invite/:token with expired token returns 410', async () => {
|
|
const { user: admin } = createAdmin(testDb);
|
|
const yesterday = new Date(Date.now() - 86_400_000).toISOString();
|
|
const invite = createInviteToken(testDb, { expires_at: yesterday, created_by: admin.id });
|
|
|
|
const res = await request(app).get(`/api/auth/invite/${invite.token}`);
|
|
expect(res.status).toBe(410);
|
|
expect(res.body.error).toMatch(/expired/i);
|
|
});
|
|
|
|
it('AUTH-012 — GET /api/auth/invite/:token with exhausted token returns 410', async () => {
|
|
const { user: admin } = createAdmin(testDb);
|
|
const invite = createInviteToken(testDb, { max_uses: 1, created_by: admin.id });
|
|
// Mark as exhausted
|
|
await updateRows(orm, InviteTokens, { id: invite.id }, { used_count: 1 });
|
|
|
|
const res = await request(app).get(`/api/auth/invite/${invite.token}`);
|
|
expect(res.status).toBe(410);
|
|
expect(res.body.error).toMatch(/fully used/i);
|
|
});
|
|
|
|
it('AUTH-013 — GET /api/auth/invite/:token for a never-expiring invite keeps expires_at present and null on the wire (rule 16, task-5-review F1/T1)', async () => {
|
|
const { user: admin } = createAdmin(testDb);
|
|
const invite = createInviteToken(testDb, { max_uses: 3, created_by: admin.id });
|
|
await updateRows(orm, InviteTokens, { id: invite.id }, { used_count: 1 });
|
|
|
|
const res = await request(app).get(`/api/auth/invite/${invite.token}`);
|
|
expect(res.status).toBe(200);
|
|
// toEqual cannot see a dropped key (undefined and missing compare equal),
|
|
// so this asserts on the raw response text, byte for byte.
|
|
expect(res.text).toBe(JSON.stringify({ valid: true, max_uses: 3, used_count: 1, expires_at: null }));
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Registration — whitespace normalization
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('Registration — whitespace normalization', () => {
|
|
it('AUTH-REG-TRIM-1 — username with surrounding whitespace is trimmed before storage', async () => {
|
|
const res = await request(app).post('/api/auth/register').send({
|
|
username: ' trimmeduser ',
|
|
email: 'trimmed@example.com',
|
|
password: 'Str0ng!Pass',
|
|
});
|
|
expect(res.status).toBe(201);
|
|
const row = (await rowOf(Users, { email: 'trimmed@example.com' }))!;
|
|
expect(row.username).toBe('trimmeduser');
|
|
});
|
|
|
|
it('AUTH-REG-TRIM-2 — email with surrounding whitespace is trimmed before storage', async () => {
|
|
const res = await request(app).post('/api/auth/register').send({
|
|
username: 'emailtrimuser',
|
|
email: ' emailtrim@example.com ',
|
|
password: 'Str0ng!Pass',
|
|
});
|
|
expect(res.status).toBe(201);
|
|
const row = (await rowOf(Users, { username: 'emailtrimuser' }))!;
|
|
expect(row.email).toBe('emailtrim@example.com');
|
|
});
|
|
|
|
it('AUTH-REG-TRIM-3 — whitespace-padded username that trims to existing username returns 409', async () => {
|
|
createUser(testDb, { username: 'alice', email: 'alice@example.com' });
|
|
const res = await request(app).post('/api/auth/register').send({
|
|
username: ' alice ',
|
|
email: 'alice2@example.com',
|
|
password: 'Str0ng!Pass',
|
|
});
|
|
expect(res.status).toBe(409);
|
|
});
|
|
|
|
it('AUTH-REG-TRIM-4 — whitespace-padded email that trims to existing email returns 409', async () => {
|
|
createUser(testDb, { username: 'bob', email: 'bob@example.com' });
|
|
const res = await request(app).post('/api/auth/register').send({
|
|
username: 'bob2',
|
|
email: ' bob@example.com ',
|
|
password: 'Str0ng!Pass',
|
|
});
|
|
expect(res.status).toBe(409);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Registration / login — non-ASCII case folding (program rule 18, Plan 3b
|
|
// Task 7 review H1: register('JOSÉ@x.com') then login with the exact stored
|
|
// spelling used to 401, and a duplicate registration used to 500 instead of
|
|
// 409, because the repository lowered the column with SQLite's ASCII-only
|
|
// LOWER() and the bound value with JS's full-Unicode toLowerCase().
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('Registration / login — non-ASCII case folding (H1)', () => {
|
|
it('AUTH-NONASCII-1 — register a non-ASCII email, then log in with the exact stored spelling: 201, then 200', async () => {
|
|
const register = await request(app).post('/api/auth/register').send({
|
|
username: 'joseuser',
|
|
email: 'JOSÉ@x.com',
|
|
password: 'Str0ng!Pass',
|
|
});
|
|
expect(register.status).toBe(201);
|
|
|
|
const login = await request(app).post('/api/auth/login').send({ email: 'JOSÉ@x.com', password: 'Str0ng!Pass' });
|
|
expect(login.status).toBe(200);
|
|
expect(login.body.user.email).toBe('JOSÉ@x.com');
|
|
});
|
|
|
|
it('AUTH-NONASCII-2 — registering the same non-ASCII email again returns 409, not 500', async () => {
|
|
const first = await request(app).post('/api/auth/register').send({
|
|
username: 'joseuser2',
|
|
email: 'JOSÉ2@x.com',
|
|
password: 'Str0ng!Pass',
|
|
});
|
|
expect(first.status).toBe(201);
|
|
|
|
const second = await request(app).post('/api/auth/register').send({
|
|
username: 'differentname',
|
|
email: 'JOSÉ2@x.com',
|
|
password: 'Str0ng!Pass',
|
|
});
|
|
expect(second.status).toBe(409);
|
|
expect(second.body.error).toBeDefined();
|
|
});
|
|
|
|
it('AUTH-NONASCII-3 — an ASCII-only account is unaffected (control)', async () => {
|
|
const { user, password } = createUser(testDb, { email: 'ROOT@example.com' });
|
|
const login = await request(app).post('/api/auth/login').send({ email: user.email, password });
|
|
expect(login.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Session / Me
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('Session', () => {
|
|
it('AUTH-014 — GET /api/auth/me without session returns 401 AUTH_REQUIRED', async () => {
|
|
const res = await request(app).get('/api/auth/me');
|
|
expect(res.status).toBe(401);
|
|
expect(res.body.code).toBe('AUTH_REQUIRED');
|
|
});
|
|
|
|
it('AUTH-014 — GET /api/auth/me with valid cookie returns safe user object', async () => {
|
|
const { user } = createUser(testDb);
|
|
const res = await request(app).get('/api/auth/me').set('Cookie', authCookie(user.id));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.user.id).toBe(user.id);
|
|
expect(res.body.user.email).toBe(user.email);
|
|
expect(res.body.user.password_hash).toBeUndefined();
|
|
expect(res.body.user.mfa_secret).toBeUndefined();
|
|
});
|
|
|
|
it('AUTH-021 — user with must_change_password=1 sees the flag in their profile', async () => {
|
|
const { user } = createUser(testDb);
|
|
await updateRows(orm, Users, { id: user.id }, { must_change_password: 1 });
|
|
|
|
const res = await request(app).get('/api/auth/me').set('Cookie', authCookie(user.id));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.user.must_change_password).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// App Config (AUTH-028)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('App config', () => {
|
|
it('AUTH-028 — GET /api/auth/app-config returns expected flags', async () => {
|
|
const res = await request(app).get('/api/auth/app-config');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toHaveProperty('allow_registration');
|
|
expect(res.body).toHaveProperty('oidc_configured');
|
|
expect(res.body).toHaveProperty('demo_mode');
|
|
expect(res.body).toHaveProperty('has_users');
|
|
expect(res.body).toHaveProperty('setup_complete');
|
|
});
|
|
|
|
it('AUTH-028 — allow_registration is false after admin disables it', async () => {
|
|
createUser(testDb);
|
|
await setAppSetting(orm, 'allow_registration', 'false');
|
|
const res = await request(app).get('/api/auth/app-config');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.allow_registration).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Demo Login (AUTH-022)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('Demo login', () => {
|
|
it('AUTH-022 — POST /api/auth/demo-login without DEMO_MODE returns 404', async () => {
|
|
delete process.env.DEMO_MODE;
|
|
const res = await request(app).post('/api/auth/demo-login');
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it('AUTH-022 — POST /api/auth/demo-login with DEMO_MODE and demo user returns 200 + cookie', async () => {
|
|
await insertRow(orm, Users, { username: 'demo', email: 'demo@trek.app', password_hash: 'x', role: 'user' });
|
|
process.env.DEMO_MODE = 'true';
|
|
try {
|
|
const res = await request(app).post('/api/auth/demo-login');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.user.email).toBe('demo@trek.app');
|
|
} finally {
|
|
delete process.env.DEMO_MODE;
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// MFA (AUTH-015 to AUTH-019)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('MFA', () => {
|
|
it('AUTH-015 — POST /api/auth/mfa/setup returns secret and QR data URL', async () => {
|
|
const { user } = createUser(testDb);
|
|
const res = await request(app).post('/api/auth/mfa/setup').set('Cookie', authCookie(user.id));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.secret).toBeDefined();
|
|
expect(res.body.otpauth_url).toContain('otpauth://');
|
|
expect(res.body.qr_svg).toMatch(/^<svg/);
|
|
});
|
|
|
|
it('AUTH-015 — POST /api/auth/mfa/enable with valid TOTP code enables MFA', async () => {
|
|
const { user } = createUser(testDb);
|
|
|
|
const setupRes = await request(app).post('/api/auth/mfa/setup').set('Cookie', authCookie(user.id));
|
|
expect(setupRes.status).toBe(200);
|
|
|
|
const enableRes = await request(app)
|
|
.post('/api/auth/mfa/enable')
|
|
.set('Cookie', authCookie(user.id))
|
|
.send({ code: authenticator.generate(setupRes.body.secret) });
|
|
expect(enableRes.status).toBe(200);
|
|
expect(enableRes.body.mfa_enabled).toBe(true);
|
|
expect(Array.isArray(enableRes.body.backup_codes)).toBe(true);
|
|
});
|
|
|
|
it('AUTH-016 — login with MFA-enabled account returns mfa_required + mfa_token', async () => {
|
|
const { user, password } = createUserWithMfa(testDb);
|
|
const loginRes = await request(app).post('/api/auth/login').send({ email: user.email, password });
|
|
expect(loginRes.status).toBe(200);
|
|
expect(loginRes.body.mfa_required).toBe(true);
|
|
expect(typeof loginRes.body.mfa_token).toBe('string');
|
|
});
|
|
|
|
it('AUTH-016 — POST /api/auth/mfa/verify-login with valid code completes login', async () => {
|
|
const { user, password, totpSecret } = createUserWithMfa(testDb);
|
|
|
|
const loginRes = await request(app).post('/api/auth/login').send({ email: user.email, password });
|
|
const { mfa_token } = loginRes.body;
|
|
|
|
const verifyRes = await request(app)
|
|
.post('/api/auth/mfa/verify-login')
|
|
.send({ mfa_token, code: authenticator.generate(totpSecret) });
|
|
expect(verifyRes.status).toBe(200);
|
|
expect(verifyRes.body.user).toBeDefined();
|
|
const cookies: string[] = Array.isArray(verifyRes.headers['set-cookie'])
|
|
? verifyRes.headers['set-cookie']
|
|
: [verifyRes.headers['set-cookie']];
|
|
expect(cookies.some((c: string) => c.includes('trek_session'))).toBe(true);
|
|
});
|
|
|
|
it('AUTH-017 — verify-login with invalid TOTP code returns 401', async () => {
|
|
const { user, password } = createUserWithMfa(testDb);
|
|
const loginRes = await request(app).post('/api/auth/login').send({ email: user.email, password });
|
|
|
|
const verifyRes = await request(app)
|
|
.post('/api/auth/mfa/verify-login')
|
|
.send({ mfa_token: loginRes.body.mfa_token, code: '000000' });
|
|
expect(verifyRes.status).toBe(401);
|
|
expect(verifyRes.body.error).toMatch(/invalid/i);
|
|
});
|
|
|
|
it('AUTH-019 — disable MFA with valid password and TOTP code', async () => {
|
|
const { user, password, totpSecret } = createUserWithMfa(testDb);
|
|
|
|
const disableRes = await request(app)
|
|
.post('/api/auth/mfa/disable')
|
|
.set('Cookie', authCookie(user.id))
|
|
.send({ password, code: authenticator.generate(totpSecret) });
|
|
expect(disableRes.status).toBe(200);
|
|
expect(disableRes.body.mfa_enabled).toBe(false);
|
|
});
|
|
|
|
it('AUTH-019 — disable MFA with password and unused backup code', async () => {
|
|
const { hashBackupCodeBcrypt } = await import('../../src/nest/auth/auth.helpers');
|
|
const { user, password } = createUserWithMfa(testDb);
|
|
await updateRows(orm, Users, { id: user.id }, {
|
|
mfa_backup_codes: JSON.stringify([hashBackupCodeBcrypt('A1B2-C3D4')]),
|
|
});
|
|
const res = await request(app).post('/api/auth/mfa/disable')
|
|
.set('Cookie', authCookie(user.id))
|
|
.send({ password, code: 'a1b2-c3d4' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.mfa_enabled).toBe(false);
|
|
const updated = testDb.prepare('SELECT mfa_enabled, mfa_secret, mfa_backup_codes FROM users WHERE id = ?').get(user.id) as any;
|
|
expect(updated.mfa_enabled).toBe(0);
|
|
expect(updated.mfa_secret).toBeNull();
|
|
expect(updated.mfa_backup_codes).toBeNull();
|
|
});
|
|
|
|
it('AUTH-019 — reject invalid recovery code and incorrect password', async () => {
|
|
const { hashBackupCodeBcrypt } = await import('../../src/nest/auth/auth.helpers');
|
|
const { user, password } = createUserWithMfa(testDb);
|
|
await updateRows(orm, Users, { id: user.id }, {
|
|
mfa_backup_codes: JSON.stringify([hashBackupCodeBcrypt('A1B2-C3D4')]),
|
|
});
|
|
const badCode = await request(app).post('/api/auth/mfa/disable')
|
|
.set('Cookie', authCookie(user.id))
|
|
.send({ password, code: 'FFFF-FFFF' });
|
|
expect(badCode.status).toBe(401);
|
|
const badPassword = await request(app).post('/api/auth/mfa/disable')
|
|
.set('Cookie', authCookie(user.id))
|
|
.send({ password: 'invalid-password', code: 'A1B2-C3D4' });
|
|
expect(badPassword.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Forced MFA Policy (AUTH-020)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('Forced MFA policy', () => {
|
|
it('AUTH-020 — non-MFA user is blocked (403 MFA_REQUIRED) when require_mfa is true', async () => {
|
|
const { user } = createUser(testDb);
|
|
await setAppSetting(orm, 'require_mfa', 'true');
|
|
|
|
// mfaPolicy checks Authorization: Bearer header
|
|
const res = await request(app).get('/api/trips').set(authHeader(user.id));
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.code).toBe('MFA_REQUIRED');
|
|
});
|
|
|
|
it('AUTH-020 — /api/auth/me and MFA setup endpoints are exempt from require_mfa', async () => {
|
|
const { user } = createUser(testDb);
|
|
await setAppSetting(orm, 'require_mfa', 'true');
|
|
|
|
const meRes = await request(app).get('/api/auth/me').set(authHeader(user.id));
|
|
expect(meRes.status).toBe(200);
|
|
|
|
const setupRes = await request(app).post('/api/auth/mfa/setup').set(authHeader(user.id));
|
|
expect(setupRes.status).toBe(200);
|
|
});
|
|
|
|
it('AUTH-020 — MFA-enabled user passes through require_mfa policy', async () => {
|
|
const { user } = createUserWithMfa(testDb);
|
|
await setAppSetting(orm, 'require_mfa', 'true');
|
|
|
|
const res = await request(app).get('/api/trips').set(authHeader(user.id));
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('AUTH-020 — require_mfa guards nested Nest addon controllers, not just top-level routes', async () => {
|
|
// The global MFA middleware runs ahead of the Express→Nest dispatch, so it
|
|
// must block the deeper trip-scoped controllers (budget/packing/todo) too —
|
|
// not only /api/trips. A regression that only guarded top-level paths would
|
|
// leave every addon endpoint reachable without MFA.
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await setAppSetting(orm, 'require_mfa', 'true');
|
|
|
|
for (const path of [
|
|
`/api/trips/${trip.id}/budget`,
|
|
`/api/trips/${trip.id}/packing`,
|
|
`/api/trips/${trip.id}/todo`,
|
|
]) {
|
|
const res = await request(app).get(path).set(authHeader(user.id));
|
|
expect(res.status, `${path} must be MFA-gated`).toBe(403);
|
|
expect(res.body.code).toBe('MFA_REQUIRED');
|
|
}
|
|
});
|
|
|
|
it('AUTH-020 — MFA-enabled user reaches nested Nest addon controllers under require_mfa', async () => {
|
|
const { user } = createUserWithMfa(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await setAppSetting(orm, 'require_mfa', 'true');
|
|
|
|
const res = await request(app).get(`/api/trips/${trip.id}/budget`).set(authHeader(user.id));
|
|
expect(res.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Short-lived tokens (AUTH-029, AUTH-030)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('Short-lived tokens', () => {
|
|
it('AUTH-029 — POST /api/auth/ws-token returns a single-use token', async () => {
|
|
const { user } = createUser(testDb);
|
|
const res = await request(app).post('/api/auth/ws-token').set('Cookie', authCookie(user.id));
|
|
expect(res.status).toBe(200);
|
|
expect(typeof res.body.token).toBe('string');
|
|
expect(res.body.token.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('AUTH-030 — POST /api/auth/resource-token returns a single-use token', async () => {
|
|
const { user } = createUser(testDb);
|
|
const res = await request(app)
|
|
.post('/api/auth/resource-token')
|
|
.set('Cookie', authCookie(user.id))
|
|
.send({ purpose: 'download' });
|
|
expect(res.status).toBe(200);
|
|
expect(typeof res.body.token).toBe('string');
|
|
expect(res.body.token.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Extended scenarios (AUTH-031 to AUTH-033)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('Extended auth scenarios', () => {
|
|
it('AUTH-031 — login succeeds with uppercased email (case-insensitive lookup)', async () => {
|
|
const { user, password } = createUser(testDb, { email: 'alice@example.com' });
|
|
|
|
const res = await request(app).post('/api/auth/login').send({ email: 'ALICE@EXAMPLE.COM', password });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.user).toBeDefined();
|
|
});
|
|
|
|
it('AUTH-032 — registration with duplicate username returns 409', async () => {
|
|
createUser(testDb, { username: 'alice' });
|
|
|
|
const res = await request(app)
|
|
.post('/api/auth/register')
|
|
.send({ username: 'alice', email: 'alice2@example.com', password: 'Str0ng!Pass' });
|
|
expect(res.status).toBe(409);
|
|
});
|
|
|
|
it('AUTH-033 — MFA backup code login succeeds and invalidates the used code', async () => {
|
|
const { hashBackupCode, generateBackupCodes } = await import('../../src/nest/auth/auth.helpers');
|
|
const { user, password } = createUserWithMfa(testDb);
|
|
|
|
// Generate and store backup codes on the MFA-enabled user
|
|
const backupCodes = generateBackupCodes();
|
|
const backupHashes = backupCodes.map(hashBackupCode);
|
|
await updateRows(orm, Users, { id: user.id }, { mfa_backup_codes: JSON.stringify(backupHashes) });
|
|
|
|
// Step 1: login to get mfa_token
|
|
const loginRes = await request(app).post('/api/auth/login').send({ email: user.email, password });
|
|
expect(loginRes.body.mfa_required).toBe(true);
|
|
const { mfa_token } = loginRes.body;
|
|
|
|
// Step 2: verify with a backup code
|
|
const res = await request(app).post('/api/auth/mfa/verify-login').send({ mfa_token, code: backupCodes[0] });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.user).toBeDefined();
|
|
|
|
// Step 3: same backup code is now consumed — second login attempt fails
|
|
const loginRes2 = await request(app).post('/api/auth/login').send({ email: user.email, password });
|
|
const { mfa_token: mfa_token2 } = loginRes2.body;
|
|
|
|
const res2 = await request(app)
|
|
.post('/api/auth/mfa/verify-login')
|
|
.send({ mfa_token: mfa_token2, code: backupCodes[0] });
|
|
expect(res2.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Account deletion
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('Account deletion', () => {
|
|
it('AUTH-040 — DELETE /auth/me succeeds when user has FK references', async () => {
|
|
const { user: admin } = createAdmin(testDb);
|
|
const { user: target } = createUser(testDb);
|
|
const { user: otherUser } = createUser(testDb);
|
|
const { user: thirdUser } = createUser(testDb);
|
|
|
|
// trip_members.invited_by: target invited thirdUser to otherUser's trip
|
|
// (trip survives deletion; only invited_by should become NULL)
|
|
const otherTrip = createTrip(testDb, otherUser.id);
|
|
await insertRow(orm, TripMembers, { trip: otherTrip.id, user: thirdUser.id, invitedByRef: target.id });
|
|
|
|
// share_tokens.created_by: target created a share token for otherUser's trip
|
|
await makeShareToken(orm, otherTrip.id, target.id, { token: 'tok-auth-test' });
|
|
|
|
// budget_items.paid_by_user_id: target paid for an expense on otherUser's trip
|
|
const budgetItem = createBudgetItem(testDb, otherTrip.id);
|
|
await updateRows(orm, BudgetItems, { id: budgetItem.id }, { paidByUser: target.id });
|
|
|
|
// journey_contributors: target is a contributor on otherUser's journey
|
|
const otherJourney = createJourney(testDb, otherUser.id);
|
|
addJourneyContributor(testDb, otherJourney.id, target.id);
|
|
|
|
// journey_entries: target authored an entry on otherUser's journey
|
|
createJourneyEntry(testDb, otherJourney.id, target.id);
|
|
|
|
// journey_share_tokens: target created a share token for otherUser's journey
|
|
await insertRow(orm, JourneyShareTokens, {
|
|
journey: otherJourney.id,
|
|
token: 'jst-auth-test',
|
|
createdByRef: target.id,
|
|
});
|
|
|
|
// notifications.sender_id (SET NULL): target sent a notification to otherUser
|
|
const sentNotifId = await insertRow(orm, Notifications, {
|
|
type: 'simple',
|
|
scope: 'trip',
|
|
target: otherTrip.id,
|
|
sender: target.id,
|
|
recipient: otherUser.id,
|
|
title_key: 'k',
|
|
text_key: 'k',
|
|
});
|
|
// notifications.recipient_id (CASCADE): otherUser sent a notification to target
|
|
await insertRow(orm, Notifications, {
|
|
type: 'simple',
|
|
scope: 'trip',
|
|
target: otherTrip.id,
|
|
sender: otherUser.id,
|
|
recipient: target.id,
|
|
title_key: 'k',
|
|
text_key: 'k',
|
|
});
|
|
|
|
// user_notice_dismissals (CASCADE): target dismissed a notice
|
|
await insertRow(orm, UserNoticeDismissals, { user: target.id, notice_id: 'test-notice', dismissed_at: Date.now() });
|
|
|
|
// owned journey: target owns a journey with an entry (cascade-deletes on journey deletion)
|
|
const ownedJourney = createJourney(testDb, target.id);
|
|
createJourneyEntry(testDb, ownedJourney.id, target.id);
|
|
|
|
// trip_files.uploaded_by (SET NULL): target uploaded a file to otherUser's trip
|
|
const fileId = await insertRow(orm, TripFiles, {
|
|
trip: otherTrip.id,
|
|
filename: 'f.pdf',
|
|
original_name: 'file.pdf',
|
|
uploadedByRef: target.id,
|
|
});
|
|
|
|
// trek_photos.owner_id (SET NULL): target owns a photo in the central registry
|
|
const trekPhotoId = await insertRow(orm, TrekPhotos, {
|
|
provider: 'immich',
|
|
asset_id: 'asset-auth-test',
|
|
owner: target.id,
|
|
});
|
|
|
|
// trip_photos.user_id (CASCADE): target added a photo to otherUser's trip
|
|
addTripPhoto(testDb, otherTrip.id, target.id, 'asset-tp-auth', 'immich');
|
|
|
|
// trips.user_id (CASCADE): target owns a trip
|
|
const ownedTrip = createTrip(testDb, target.id);
|
|
|
|
// trip_members.user_id (CASCADE): target is a member of otherUser's trip
|
|
addTripMember(testDb, otherTrip.id, target.id);
|
|
|
|
// categories.user_id (SET NULL): target created a category
|
|
const userCategory = createCategory(testDb, { user_id: target.id });
|
|
|
|
// tags.user_id (CASCADE): target created a tag
|
|
const userTag = createTag(testDb, target.id);
|
|
|
|
// todo_items.assigned_user_id (SET NULL): target is assigned to a todo on otherUser's trip
|
|
const todoItem = createTodoItem(testDb, otherTrip.id);
|
|
await updateRows(orm, TodoItems, { id: todoItem.id }, { assignedUser: target.id });
|
|
|
|
// packing_bags.user_id (SET NULL): target owns a packing bag on otherUser's trip
|
|
const packBagId = await insertRow(orm, PackingBags, {
|
|
trip: otherTrip.id,
|
|
name: 'Bag',
|
|
color: '#ff0000',
|
|
user: target.id,
|
|
});
|
|
|
|
// mcp_tokens.user_id (CASCADE): target has an MCP API token
|
|
createMcpToken(testDb, target.id);
|
|
|
|
// oauth_tokens/consents.user_id (CASCADE): target has tokens from otherUser's OAuth client
|
|
await insertRow(orm, OauthClients, {
|
|
id: 'cl-auth-test',
|
|
user: otherUser.id,
|
|
name: 'App',
|
|
client_id: 'cid-auth-test',
|
|
client_secret_hash: 'h',
|
|
});
|
|
await insertRow(orm, OauthTokens, {
|
|
client: 'cid-auth-test',
|
|
user: target.id,
|
|
access_token_hash: 'ath-auth',
|
|
refresh_token_hash: 'rth-auth',
|
|
access_token_expires_at: dbNow(new Date(Date.now() + 3600_000)),
|
|
refresh_token_expires_at: dbNow(new Date(Date.now() + 30 * 86_400_000)),
|
|
});
|
|
await insertRow(orm, OauthConsents, { client: 'cid-auth-test', user: target.id });
|
|
|
|
// vacay_plans.owner_id (CASCADE): target owns a vacation plan
|
|
const vacayPlanId = await insertRow(orm, VacayPlans, { owner: target.id });
|
|
|
|
// vacay_plan_members.user_id (CASCADE): target is a member of otherUser's vacay plan
|
|
const otherVacayPlanId = await insertRow(orm, VacayPlans, { owner: otherUser.id });
|
|
await insertRow(orm, VacayPlanMembers, { plan: otherVacayPlanId, user: target.id });
|
|
|
|
// bucket_list.user_id (CASCADE): target has a bucket list item
|
|
createBucketListItem(testDb, target.id);
|
|
|
|
// visited_countries.user_id (CASCADE): target has visited a country
|
|
createVisitedCountry(testDb, target.id, 'JP');
|
|
|
|
// visited_regions.user_id (CASCADE): target has visited a region
|
|
await insertRow(orm, VisitedRegions, {
|
|
user: target.id,
|
|
region_code: 'JP-13',
|
|
region_name: 'Tokyo',
|
|
country_code: 'JP',
|
|
});
|
|
|
|
// packing_templates.created_by (CASCADE): target created a packing template
|
|
const packTemplateId = await insertRow(orm, PackingTemplates, { name: 'My Template', createdByRef: target.id });
|
|
|
|
// invite_tokens.created_by (CASCADE): target created an invite token
|
|
createInviteToken(testDb, { created_by: target.id });
|
|
|
|
// collab_notes.user_id (CASCADE): target authored a collab note on otherUser's trip
|
|
createCollabNote(testDb, otherTrip.id, target.id);
|
|
|
|
// settings.user_id (CASCADE): target has a user setting
|
|
await setUserSetting(orm, target.id, 'theme', 'dark');
|
|
|
|
// password_reset_tokens.user_id (CASCADE): target has a pending password reset
|
|
await insertRow(orm, PasswordResetTokens, {
|
|
user: target.id,
|
|
token_hash: 'prt-hash-auth',
|
|
expires_at: dbNow(new Date(Date.now() + 3600_000)),
|
|
});
|
|
|
|
// audit_log.user_id (SET NULL): target performed an audited action
|
|
const auditId = await insertRow(orm, AuditLog, { user: target.id, action: 'test.action', ip: '127.0.0.1' });
|
|
|
|
// notification_channel_preferences.user_id (CASCADE): target has notification preferences
|
|
await insertRowIgnoringConflict(orm, NotificationChannelPreferences, {
|
|
user: target.id,
|
|
event_type: 'trip_invite',
|
|
channel: 'email',
|
|
});
|
|
|
|
// admin exists to ensure target (non-admin user) passes the last-admin guard
|
|
void admin;
|
|
|
|
const res = await request(app).delete('/api/auth/me').set('Cookie', authCookie(target.id));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.success).toBe(true);
|
|
|
|
expect(await rowOf(Users, { id: target.id })).toBeNull();
|
|
// trip_members row survives but invited_by is now NULL
|
|
expect((await rowOf(TripMembers, { trip: otherTrip.id, user: thirdUser.id }))!.invited_by).toBeNull();
|
|
expect(await rowOf(ShareTokens, { createdByRef: target.id })).toBeNull();
|
|
expect((await rowOf(BudgetItems, { id: budgetItem.id }))!.paid_by_user_id).toBeNull();
|
|
expect(await rowOf(JourneyContributors, { journey: otherJourney.id, user: target.id })).toBeNull();
|
|
expect(await rowOf(JourneyEntries, { author: target.id })).toBeNull();
|
|
expect(await rowOf(JourneyShareTokens, { createdByRef: target.id })).toBeNull();
|
|
// sent notification survives but sender_id becomes NULL
|
|
expect((await rowOf(Notifications, { id: sentNotifId }))!.sender_id).toBeNull();
|
|
// received notification is cascade-deleted
|
|
expect(await rowOf(Notifications, { recipient: target.id })).toBeNull();
|
|
// notice dismissals are cascade-deleted
|
|
expect(await rowOf(UserNoticeDismissals, { user: target.id, notice_id: 'test-notice' })).toBeNull();
|
|
// owned journey and its entries are cascade-deleted
|
|
expect(await rowOf(Journeys, { user: target.id })).toBeNull();
|
|
expect(await rowOf(JourneyEntries, { journey: ownedJourney.id })).toBeNull();
|
|
// uploaded file survives but uploaded_by is now NULL
|
|
expect((await rowOf(TripFiles, { id: fileId }))!.uploaded_by).toBeNull();
|
|
// trek_photos row survives but owner_id is now NULL
|
|
expect((await rowOf(TrekPhotos, { id: trekPhotoId }))!.owner_id).toBeNull();
|
|
// trip_photos row for target is cascade-deleted
|
|
expect(await rowOf(TripPhotos, { trip: otherTrip.id, user: target.id })).toBeNull();
|
|
// owned trip is cascade-deleted
|
|
expect(await rowOf(Trips, { id: ownedTrip.id })).toBeNull();
|
|
// trip membership on others' trips is removed
|
|
expect(await rowOf(TripMembers, { trip: otherTrip.id, user: target.id })).toBeNull();
|
|
// category survives but user_id is NULL
|
|
expect((await rowOf(Categories, { id: userCategory.id }))!.user_id).toBeNull();
|
|
// tag is deleted
|
|
expect(await rowOf(Tags, { id: userTag.id })).toBeNull();
|
|
// todo assigned_user_id is NULL
|
|
expect((await rowOf(TodoItems, { id: todoItem.id }))!.assigned_user_id).toBeNull();
|
|
// packing bag survives but user_id is NULL
|
|
expect((await rowOf(PackingBags, { id: packBagId }))!.user_id).toBeNull();
|
|
// MCP tokens are deleted
|
|
expect(await rowOf(McpTokens, { user: target.id })).toBeNull();
|
|
// OAuth tokens and consents are deleted
|
|
expect(await rowOf(OauthTokens, { user: target.id })).toBeNull();
|
|
expect(await rowOf(OauthConsents, { user: target.id })).toBeNull();
|
|
// owned vacay plan is deleted
|
|
expect(await rowOf(VacayPlans, { id: vacayPlanId })).toBeNull();
|
|
// vacay plan membership on others' plans is removed
|
|
expect(await rowOf(VacayPlanMembers, { plan: otherVacayPlanId, user: target.id })).toBeNull();
|
|
// bucket list items are deleted
|
|
expect(await rowOf(BucketList, { user: target.id })).toBeNull();
|
|
// travel history is deleted
|
|
expect(await rowOf(VisitedCountries, { user: target.id, country_code: 'JP' })).toBeNull();
|
|
expect(await rowOf(VisitedRegions, { user: target.id })).toBeNull();
|
|
// packing template is deleted
|
|
expect(await rowOf(PackingTemplates, { id: packTemplateId })).toBeNull();
|
|
// invite tokens created by target are deleted
|
|
expect(await rowOf(InviteTokens, { createdByRef: target.id })).toBeNull();
|
|
// collab content is deleted
|
|
expect(await rowOf(CollabNotes, { user: target.id, trip: otherTrip.id })).toBeNull();
|
|
// user settings are deleted
|
|
expect(await rowOf(Settings, { user: target.id })).toBeNull();
|
|
// password reset tokens are deleted
|
|
expect(await rowOf(PasswordResetTokens, { user: target.id })).toBeNull();
|
|
// audit log entry survives but user_id is NULL
|
|
expect((await rowOf(AuditLog, { id: auditId }))!.user_id).toBeNull();
|
|
// notification channel preferences are deleted
|
|
expect(await rowOf(NotificationChannelPreferences, { user: target.id, event_type: 'trip_invite' })).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Rate limiting (AUTH-004, AUTH-018) — placed last
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('Rate limiting', () => {
|
|
it('AUTH-004 — login endpoint rate-limits after 10 attempts from the same IP', async () => {
|
|
// beforeEach has cleared loginAttempts; we fill up exactly to the limit
|
|
let lastStatus = 0;
|
|
for (let i = 0; i <= 10; i++) {
|
|
const res = await request(app)
|
|
.post('/api/auth/login')
|
|
.send({ email: 'ratelimit@example.com', password: 'wrong' });
|
|
lastStatus = res.status;
|
|
if (lastStatus === 429) break;
|
|
}
|
|
expect(lastStatus).toBe(429);
|
|
});
|
|
|
|
it('AUTH-018 — MFA verify-login endpoint rate-limits after 5 attempts', async () => {
|
|
let lastStatus = 0;
|
|
for (let i = 0; i <= 5; i++) {
|
|
const res = await request(app).post('/api/auth/mfa/verify-login').send({ mfa_token: 'badtoken', code: '000000' });
|
|
lastStatus = res.status;
|
|
if (lastStatus === 429) break;
|
|
}
|
|
expect(lastStatus).toBe(429);
|
|
});
|
|
|
|
it('AUTH-019 — reset-password endpoint rate-limits after 5 attempts (parity with the legacy resetLimiter)', async () => {
|
|
let lastStatus = 0;
|
|
for (let i = 0; i <= 5; i++) {
|
|
const res = await request(app)
|
|
.post('/api/auth/reset-password')
|
|
.send({ token: 'badtoken', new_password: 'NewPassw0rd!' });
|
|
lastStatus = res.status;
|
|
if (lastStatus === 429) break;
|
|
}
|
|
expect(lastStatus).toBe(429);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// MCP token management (AUTH-034 to AUTH-039)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('MCP token management', () => {
|
|
it('AUTH-034 — GET /auth/mcp-tokens returns empty list initially', async () => {
|
|
const { user } = createUser(testDb);
|
|
const res = await request(app).get('/api/auth/mcp-tokens').set('Cookie', authCookie(user.id));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.tokens).toEqual([]);
|
|
});
|
|
|
|
it('AUTH-035 — POST /auth/mcp-tokens creates a token', async () => {
|
|
const { user } = createUser(testDb);
|
|
const res = await request(app)
|
|
.post('/api/auth/mcp-tokens')
|
|
.set('Cookie', authCookie(user.id))
|
|
.send({ name: 'my-token' });
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.token).toBeDefined();
|
|
expect(typeof res.body.token.raw_token).toBe('string');
|
|
});
|
|
|
|
it('AUTH-036 — POST /auth/mcp-tokens without name returns 400', async () => {
|
|
const { user } = createUser(testDb);
|
|
const res = await request(app).post('/api/auth/mcp-tokens').set('Cookie', authCookie(user.id)).send({});
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('AUTH-037 — DELETE /auth/mcp-tokens/:id deletes the token', async () => {
|
|
const { user } = createUser(testDb);
|
|
const createRes = await request(app)
|
|
.post('/api/auth/mcp-tokens')
|
|
.set('Cookie', authCookie(user.id))
|
|
.send({ name: 'to-delete' });
|
|
expect(createRes.status).toBe(201);
|
|
const tokenId = createRes.body.token.id;
|
|
|
|
const delRes = await request(app).delete(`/api/auth/mcp-tokens/${tokenId}`).set('Cookie', authCookie(user.id));
|
|
expect(delRes.status).toBe(200);
|
|
expect(delRes.body.success).toBe(true);
|
|
|
|
const listRes = await request(app).get('/api/auth/mcp-tokens').set('Cookie', authCookie(user.id));
|
|
expect(listRes.body.tokens).toEqual([]);
|
|
});
|
|
|
|
it('AUTH-038 — DELETE /auth/mcp-tokens/:id returns 404 for non-existent', async () => {
|
|
const { user } = createUser(testDb);
|
|
const res = await request(app).delete('/api/auth/mcp-tokens/99999').set('Cookie', authCookie(user.id));
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it('AUTH-039 — unauthenticated GET /auth/mcp-tokens returns 401', async () => {
|
|
const res = await request(app).get('/api/auth/mcp-tokens');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('AUTH-040 — DELETE /auth/mcp-tokens/abc (non-numeric id) returns the legacy 404, not a 500 (Plan 3b Task 2 review, F1)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const res = await request(app).delete('/api/auth/mcp-tokens/abc').set('Cookie', authCookie(user.id));
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Token not found' });
|
|
});
|
|
|
|
it('AUTH-041 — DELETE /auth/api-tokens/abc (non-numeric id) returns the legacy 404, not a 500 (Plan 3b Task 2 review, F1)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const res = await request(app).delete('/api/auth/api-tokens/abc').set('Cookie', authCookie(user.id));
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Token not found' });
|
|
});
|
|
});
|