← back to AbramsOS

tests/auth-e2e.test.js

148 lines

// End-to-end: signup → enroll TOTP → signin (password+totp) → /import gate → step-up → /import 200.
// Talks to a fresh in-process server.

const test = require('node:test');
const assert = require('node:assert');
const http = require('node:http');
const { authenticator } = require('otplib');

require('dotenv').config();

// Wipe + reset relevant tables so the test is reproducible
const { pool } = require('../lib/db');
test.before(async () => {
  await pool.query(`DELETE FROM auth_event`);
  await pool.query(`DELETE FROM auth_session`);
  await pool.query(`DELETE FROM auth_totp`);
  await pool.query(`DELETE FROM auth_credential`);
});

const app = require('../server');
let server;
test.before(() => new Promise((r) => { server = app.listen(0, r); }));
test.after(async () => {
  await new Promise((r) => server.close(r));
  await pool.end();
});

let cookieJar = {};
let csrfToken = '';

function cookieHeader() {
  return Object.entries(cookieJar).map(([k, v]) => `${k}=${v}`).join('; ');
}

function captureCookie(res) {
  const set = res.headers['set-cookie'];
  if (set) {
    for (const c of set) {
      const m = c.match(/^([^=]+)=([^;]*)/);
      if (!m) continue;
      const name = m[1], val = m[2];
      if (val === '') delete cookieJar[name];
      else cookieJar[name] = val;
      if (name === 'aos.csrf') csrfToken = val;
    }
  }
}

function req(path, { method = 'GET', body = null } = {}) {
  return new Promise((resolve, reject) => {
    const port = server.address().port;
    const opts = {
      method, hostname: '127.0.0.1', port, path,
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Cookie': cookieHeader(),
        'Accept': 'text/html,application/json',
      },
    };
    const r = http.request(opts, (res) => {
      let data = ''; res.on('data', (c) => (data += c));
      res.on('end', () => {
        captureCookie(res);
        resolve({ status: res.statusCode, body: data, headers: res.headers });
      });
    });
    r.on('error', reject);
    // Auto-attach CSRF token for unsafe methods even when caller passed no body
    const isUnsafe = ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method);
    if (isUnsafe && !body) body = {};
    if (body) {
      if (typeof body === 'object' && csrfToken && !body._csrf) body._csrf = csrfToken;
      const enc = typeof body === 'string' ? body : new URLSearchParams(body).toString();
      r.write(enc);
    }
    r.end();
  });
}

// Prime CSRF cookie before any POST tests
test.before(async () => { await req('/signup'); });

test('signup creates user + sets cookie', async () => {
  const r = await req('/signup', { method: 'POST', body: { email: 'steve+e2e@example.com', password: 'verystrongpassword!' } });
  assert.strictEqual(r.status, 302);
  assert.match(r.headers.location, /\/enroll-totp/);
  assert.ok(cookieJar, 'session cookie should be set');
});

test('enroll-totp page shows the QR + secret', async () => {
  const r = await req('/enroll-totp');
  assert.strictEqual(r.status, 200);
  assert.match(r.body, /Enroll two-factor authentication/);
});

test('TOTP enrollment with valid code completes', async () => {
  const sec = (await pool.query(`SELECT secret_encrypted, secret_iv, secret_tag FROM auth_totp`)).rows[0];
  const { decrypt } = require('../lib/crypto');
  const secret = decrypt(sec.secret_encrypted, sec.secret_iv, sec.secret_tag);
  const token = authenticator.generate(secret);
  const r = await req('/enroll-totp', { method: 'POST', body: { token } });
  assert.strictEqual(r.status, 302);
  assert.match(r.headers.location, /\//);
});

test('after enrollment, / returns 200', async () => {
  const r = await req('/');
  assert.strictEqual(r.status, 200);
});

test('after enrollment with valid step-up, /import returns 200', async () => {
  const r = await req('/import');
  // step-up just happened during TOTP enroll → within 60s window → should pass
  assert.strictEqual(r.status, 200);
  assert.match(r.body, /Import all receipts now/);
});

test('signout clears cookie + /import becomes 302 again', async () => {
  await req('/signout', { method: 'POST' });
  const r = await req('/import');
  assert.strictEqual(r.status, 302);
});

test('full signin (password → totp) restores access', async () => {
  // password
  const r1 = await req('/signin', { method: 'POST', body: { stage: 'password', email: 'steve+e2e@example.com', password: 'verystrongpassword!', next: '/' } });
  assert.strictEqual(r1.status, 200);
  assert.match(r1.body, /Two-factor code/);

  // totp
  const sec = (await pool.query(`SELECT secret_encrypted, secret_iv, secret_tag FROM auth_totp`)).rows[0];
  const { decrypt } = require('../lib/crypto');
  const secret = decrypt(sec.secret_encrypted, sec.secret_iv, sec.secret_tag);
  const token = authenticator.generate(secret);
  const r2 = await req('/signin', { method: 'POST', body: { stage: 'totp', token, next: '/' } });
  assert.strictEqual(r2.status, 302);
  assert.match(r2.headers.location, /\//);

  const r3 = await req('/');
  assert.strictEqual(r3.status, 200);
});

test('signup is locked after first user', async () => {
  const r = await req('/signup');
  assert.strictEqual(r.status, 302);
  assert.match(r.headers.location, /\/signin/);
});