BillTracker/tests/authLoginWebauthnRoute.test.js

139 lines
4.6 KiB
JavaScript
Raw Normal View History

'use strict';
// QA-B1-01 — POST /login for a webauthn-enabled user must return the
// requires_webauthn two-step payload (not fall through and 500), and a user
// whose webauthn_enabled flag is stale (no credential rows) must still be able
// to log in with their password instead of being locked out.
const test = require('node:test');
const assert = require('node:assert/strict');
const os = require('node:os');
const path = require('node:path');
const fs = require('node:fs');
const dbPath = path.join(os.tmpdir(), `bill-tracker-webauthn-login-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database.cts');
const { formatError } = require('../utils/apiError.cts');
const { hashPassword } = require('../services/authService.cts');
const router = require('../routes/auth.cts');
const PASSWORD = 'correct-horse-battery';
function handler(method, routePath) {
const layer = router.stack.find((l) => l.route?.path === routePath && l.route.methods[method]);
assert.ok(layer, `${method.toUpperCase()} ${routePath} exists`);
return layer.route.stack[layer.route.stack.length - 1].handle;
}
function callLogin(body) {
const h = handler('post', '/login');
return new Promise((resolve, reject) => {
const req = {
body,
ip: '127.0.0.1',
cookies: {},
headers: {},
secure: false,
get: () => 'node-test-agent',
};
const cookies = [];
const res = {
statusCode: 200,
status(code) {
this.statusCode = code;
return this;
},
cookie(name, value, opts) {
cookies.push({ name, value, opts });
},
json(data) {
resolve({ status: this.statusCode, body: data, cookies });
},
};
// Routes follow the throw-pattern: mirror the app's terminal error handler
// so thrown/rejected ApiErrors resolve to the same wire shape.
Promise.resolve(h(req, res)).catch((err) => {
if (err && (err.status || err.name === 'ApiError')) {
resolve({ status: err.status || 500, body: formatError(err), cookies });
} else {
reject(err);
}
});
});
}
let webauthnUserId;
let staleFlagUserId;
test.before(async () => {
const db = getDb();
const hash = await hashPassword(PASSWORD);
const u1 = db
.prepare(
"INSERT INTO users (username, password_hash, role, webauthn_enabled) VALUES (?, ?, 'user', 1)",
)
.run('webauthn-user', hash);
webauthnUserId = u1.lastInsertRowid;
db.prepare(
'INSERT INTO webauthn_credentials (user_id, credential_id, public_key, sign_count, transports) VALUES (?, ?, ?, 0, ?)',
).run(webauthnUserId, 'dGVzdC1jcmVkLWlk', 'dGVzdC1wdWJsaWMta2V5', JSON.stringify(['internal']));
const u2 = db
.prepare(
"INSERT INTO users (username, password_hash, role, webauthn_enabled) VALUES (?, ?, 'user', 1)",
)
.run('stale-flag-user', hash);
staleFlagUserId = u2.lastInsertRowid;
});
test.after(() => {
closeDb();
for (const suffix of ['', '-shm', '-wal']) {
try {
fs.unlinkSync(dbPath + suffix);
} catch {}
}
});
test('webauthn-enabled user gets the two-step payload, not a 500', async () => {
const { status, body, cookies } = await callLogin({
username: 'webauthn-user',
password: PASSWORD,
});
assert.equal(status, 200);
assert.equal(body.requires_webauthn, true);
assert.equal(typeof body.challenge_token, 'string');
assert.ok(body.challenge_token.length > 0);
assert.ok(body.webauthn_options, 'webauthn_options present');
assert.equal(typeof body.webauthn_options.challenge, 'string');
assert.equal(cookies.length, 0, 'no session cookie before the key verifies');
assert.equal(body.user, undefined, 'no user payload before the key verifies');
const challenge = getDb()
.prepare(
"SELECT id FROM webauthn_challenges WHERE user_id = ? AND challenge_type = 'authentication'",
)
.get(webauthnUserId);
assert.ok(challenge, 'authentication challenge persisted');
});
test('stale webauthn flag (no credentials) falls through to password login, not lockout', async () => {
const { status, body, cookies } = await callLogin({
username: 'stale-flag-user',
password: PASSWORD,
});
assert.equal(status, 200);
assert.equal(body.requires_webauthn, undefined);
assert.ok(body.user, 'password login succeeded despite the stale flag');
assert.equal(body.user.id, staleFlagUserId);
assert.equal(cookies.length, 1, 'session cookie issued');
});
test('wrong password still 401s for a webauthn-enabled user', async () => {
const { status, body } = await callLogin({ username: 'webauthn-user', password: 'nope-wrong' });
assert.equal(status, 401);
assert.equal(body.code, 'AUTH_ERROR');
});