screentinker/server/test/totp-unit.test.js
ScreenTinker b938fce368 feat(auth,tizen): TOTP 2FA UI, email verification on signup, Tizen SSSP install
Three features from this session, full server suite green (535/535).

TOTP 2FA (#100) — backend shipped without a UI; add it:
- Login: mfa_required -> 6-digit challenge (recovery codes accepted) -> /totp/verify.
- Settings > Account: enable (QR + confirm -> recovery codes once), regenerate,
  disable; SSO accounts see "managed by your identity provider".
- /totp/setup returns a server-rendered qr_data_url (bundled qrcode dep). keyuri
  folds the request Host into the issuer so multi-instance accounts are
  distinguishable in the authenticator app.

Email verification on signup — hosted HARD-block / self-host SOFT-nudge:
- email_verified column; existing users asked on first login (SSO + platform
  admins grandfathered); single-use 24h tokens (SHA-256 hashed).
- Gate engages only when email is configured (never locks out a no-mail instance).
  GET /verify-email + POST /resend-verification (generic, no account enumeration).
- Client: "confirm your email" flow + resend, verified/error toasts, self-host
  banner; onAuthSuccess refuses a tokenless response (defensive).

Tizen SSSP URL-Launcher install — Fusion-style one-URL native install:
- Server hosts /tizen/sssp_config.xml (dynamic <size>, always matches the served
  .wgt) + /tizen/ScreenTinker.wgt + a human landing. lib/wgt-cache.js resolves the
  signed .wgt (/data mount wins, mirroring the APK).
- build-wgt.sh also emits a static sssp_config.xml for CDN hosting.
- Retail panels require a Samsung Partner cert; dev-mode is SDB self-signed only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 21:08:50 -05:00

99 lines
5.3 KiB
JavaScript

'use strict';
// #100 unit tests: the security-critical assertions that don't need a full server.
// The bite-test (#1) injects an in-memory db with a real user row so that REMOVING the
// mfa_pending rejection in requireAuth makes it go red (the pending token would then
// find the user and call next()).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const Database = require('better-sqlite3');
const { authenticator } = require('otplib');
// Inject the db BEFORE requiring middleware/auth so requireAuth queries this one.
const mem = new Database(':memory:');
mem.exec(`CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT, name TEXT, role TEXT,
auth_provider TEXT, avatar_url TEXT, plan_id TEXT, email_alerts INTEGER,
must_change_password INTEGER NOT NULL DEFAULT 0)`);
mem.prepare("INSERT INTO users (id,email,name,role,auth_provider) VALUES ('u1','u1@x','U1','user','local')").run();
require.cache[require.resolve('../db/database')] = {
id: require.resolve('../db/database'), loaded: true, exports: { db: mem },
};
const { requireAuth, generateMfaPendingToken, generateToken } = require('../middleware/auth');
const totp = require('../lib/totp');
const totpLockout = require('../lib/totp-lockout');
function runRequireAuth(token) {
const req = { headers: { authorization: 'Bearer ' + token }, originalUrl: '/api/devices' };
let status = 200, nexted = false;
const res = { status(s) { status = s; return this; }, json() { return this; } };
requireAuth(req, res, () => { nexted = true; });
return { status, nexted, req };
}
test('#100 BITE: requireAuth rejects an mfa_pending token (no password-only session)', () => {
const pending = runRequireAuth(generateMfaPendingToken({ id: 'u1' }));
assert.equal(pending.status, 401, 'mfa_pending token must be 401');
assert.equal(pending.nexted, false, 'must NOT call next() for an mfa_pending token');
// Contrast: a FULL token for the SAME user passes - so the user EXISTS in the db,
// which means removing the mfa_pending check would let the pending token through too
// (next() called). That's what makes this a real bite-test, not a vacuous 401.
const full = runRequireAuth(generateToken({ id: 'u1', email: 'u1@x', role: 'user' }, null));
assert.equal(full.nexted, true, 'a full token for the same user must pass requireAuth');
assert.equal(full.req.user.id, 'u1');
});
test('#100 lockout: locks after MAX_FAILS, lifts after the window, reset clears', () => {
const k = 'user-' + crypto.randomUUID();
for (let i = 0; i < totpLockout.MAX_FAILS - 1; i++) totpLockout.recordFailure(k, 1000);
assert.equal(totpLockout.isLocked(k, 1000), false);
totpLockout.recordFailure(k, 1000);
assert.equal(totpLockout.isLocked(k, 1000), true, 'locked at MAX_FAILS');
assert.equal(totpLockout.isLocked(k, 1000 + totpLockout.LOCKOUT_MS + 1), false, 'lifts after window');
totpLockout.reset(k);
assert.equal(totpLockout.isLocked(k, 1000), false, 'reset clears');
});
test('#100 replay: a TOTP code from an already-consumed step is rejected', () => {
const secret = totp.generateSecret();
const code = authenticator.generate(secret);
const step = totp.currentStep();
assert.equal(totp.verifyCode(code, secret, step - 1), step, 'fresh code accepted, returns the step');
assert.equal(totp.verifyCode(code, secret, step), null, 'same code at the consumed step is blocked');
});
test('#100 key-mismatch is graceful: decrypt failure -> null (no throw); verifyCode tolerates null', () => {
// If the secretbox key changes (rotated JWT_SECRET, non-persisted .jwt_secret), the
// stored TOTP secret becomes undecryptable. That must degrade, not 500.
assert.equal(totp.decryptSecret('!!!not-decryptable'), null, 'undecryptable -> null, not a throw');
assert.doesNotThrow(() => totp.verifyCode('123456', null, 0), 'null secret must not throw on the login path');
assert.equal(totp.verifyCode('123456', null, 0), null, 'null secret -> null (recovery path then handles it)');
});
test('#100 recovery codes: stored hashed, never plaintext; input normalized', () => {
const { plain, hashes } = totp.generateRecoveryCodes(10);
assert.equal(plain.length, 10);
assert.equal(hashes.length, 10);
assert.match(plain[0], /^[0-9A-F]{10}$/, 'plaintext is 10 hex chars (shown once)');
assert.match(hashes[0], /^[0-9a-f]{64}$/, 'stored value is a SHA-256 hash');
assert.notEqual(plain[0], hashes[0], 'the stored value is not the plaintext');
// typed with stray spaces/hyphens/lowercase still matches the stored hash
const messy = ' ' + plain[0].toLowerCase().slice(0, 5) + '-' + plain[0].toLowerCase().slice(5) + ' ';
assert.equal(totp.hashRecoveryCode(messy), hashes[0], 'normalized input matches');
});
test('keyuri: bare issuer by default; folds the instance host in so multi-instance accounts are distinguishable', () => {
const secret = totp.generateSecret();
const plain = totp.keyuri('user@x.com', secret);
assert.match(plain, /^otpauth:\/\/totp\//);
assert.match(plain, /issuer=ScreenTinker(&|$)/, 'bare "ScreenTinker" issuer when no instance given');
const scoped = totp.keyuri('user@x.com', secret, 'alpha.screentinker.com');
const decoded = decodeURIComponent(scoped);
assert.ok(decoded.includes('ScreenTinker (alpha.screentinker.com)'), 'issuer carries the host');
assert.notEqual(scoped, plain, 'a different instance yields a different label');
});