mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
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>
161 lines
7.7 KiB
JavaScript
161 lines
7.7 KiB
JavaScript
'use strict';
|
|
|
|
// Email verification on signup. Boots the real server with a mail transport CONFIGURED
|
|
// (SMTP pointed at a dead port — sendEmail() never throws, so nothing actually sends, but
|
|
// isConfigured() is true so the gate engages). Covers the hosted HARD block and the self-host
|
|
// SOFT nudge, plus the verify-link + resend endpoints.
|
|
//
|
|
// Assertions go through the API (login / register responses), NOT direct DB reads: the server
|
|
// runs in a separate process and, under WAL, a fresh test-side connection can read a stale
|
|
// snapshot of the server's just-committed write. Planting a verification token IS a DB write
|
|
// (test -> server), which the server reads correctly. Register calls are kept <=5 per server
|
|
// to stay under the 5/min-per-IP register limit (each describe boots its own server -> own limit).
|
|
|
|
const { test, describe, before, after } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { spawn } = require('node:child_process');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
const fs = require('node:fs');
|
|
const crypto = require('node:crypto');
|
|
const Database = require('better-sqlite3');
|
|
const { freePort } = require('./helpers/free-port');
|
|
|
|
const PW = 'Passw0rd123';
|
|
const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
|
|
const rand = () => crypto.randomBytes(5).toString('hex');
|
|
const MAIL_ENV = { EMAIL_TRANSPORT: 'smtp', SMTP_HOST: '127.0.0.1', SMTP_PORT: '2', SMTP_FROM: 'noreply@test.local' };
|
|
|
|
async function boot(extraEnv) {
|
|
const PORT = await freePort();
|
|
const BASE = `http://127.0.0.1:${PORT}`;
|
|
const DATA_DIR = path.join(os.tmpdir(), 'st-ev-' + rand());
|
|
const LOG = path.join(os.tmpdir(), 'st-ev-' + rand() + '.log');
|
|
const logFd = fs.openSync(LOG, 'w');
|
|
const proc = spawn('node', ['server.js'], {
|
|
cwd: path.join(__dirname, '..'),
|
|
env: { ...process.env, ...MAIL_ENV, ...extraEnv, DATA_DIR, PORT: String(PORT), NODE_ENV: 'test' },
|
|
stdio: ['ignore', logFd, logFd],
|
|
});
|
|
let up = false;
|
|
for (let i = 0; i < 80; i++) {
|
|
try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* not yet */ }
|
|
await new Promise(r => setTimeout(r, 250));
|
|
}
|
|
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
|
|
return { BASE, proc, dbPath: path.join(DATA_DIR, 'db', 'remote_display.db') };
|
|
}
|
|
|
|
async function jfetch(BASE, p, body, method = 'POST') {
|
|
const res = await fetch(BASE + p, {
|
|
method, redirect: 'manual',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
let json = null; try { json = await res.json(); } catch { /* redirect / non-JSON */ }
|
|
return { status: res.status, body: json, location: res.headers.get('location') };
|
|
}
|
|
const register = (BASE, email) => jfetch(BASE, '/api/auth/register', { email, password: PW });
|
|
const login = (BASE, email) => jfetch(BASE, '/api/auth/login', { email, password: PW });
|
|
|
|
// Plant a known verification token straight into the user's row (the plaintext otherwise lives
|
|
// only in the email). A test->server write; the server reads it correctly.
|
|
function plantToken(dbPath, email, tok, expiresInSec = 3600) {
|
|
const db = new Database(dbPath); db.pragma('busy_timeout = 4000');
|
|
try {
|
|
db.prepare('UPDATE users SET email_verify_hash=?, email_verify_expires=? WHERE email=?')
|
|
.run(sha256(tok), Math.floor(Date.now() / 1000) + expiresInSec, email);
|
|
} finally { db.close(); }
|
|
}
|
|
function forceUnverified(dbPath, email) {
|
|
const db = new Database(dbPath); db.pragma('busy_timeout = 4000');
|
|
try {
|
|
db.prepare('UPDATE users SET email_verified=0, email_verify_hash=NULL, email_verify_expires=NULL WHERE email=?').run(email);
|
|
} finally { db.close(); }
|
|
}
|
|
|
|
describe('hosted (SELF_HOSTED unset): hard block until verified', () => {
|
|
let S;
|
|
before(async () => { S = await boot({ SELF_HOSTED: 'false' }); });
|
|
after(() => { try { S.proc.kill('SIGKILL'); } catch { /* ignore */ } });
|
|
|
|
test('first user is exempt; a later signup is gated with no session', async () => {
|
|
const admin = 'ev-admin-' + rand() + '@x.test';
|
|
const first = await register(S.BASE, admin);
|
|
assert.equal(first.status, 201);
|
|
assert.ok(first.body.token, 'first/bootstrap user is verified -> full session');
|
|
|
|
const email = 'ev-' + rand() + '@x.test';
|
|
const r = await register(S.BASE, email);
|
|
assert.equal(r.body.verification_required, true, 'gated signup');
|
|
assert.equal(r.body.token, undefined, 'NO session before verification');
|
|
});
|
|
|
|
test('login is blocked until verified, then succeeds; verify link is single-use', async () => {
|
|
const email = 'ev-' + rand() + '@x.test';
|
|
await register(S.BASE, email);
|
|
|
|
const blocked = await login(S.BASE, email);
|
|
assert.equal(blocked.body.verification_required, true, 'login blocked while unverified');
|
|
assert.equal(blocked.body.token, undefined);
|
|
|
|
const tok = rand() + rand();
|
|
plantToken(S.dbPath, email, tok);
|
|
const click = await jfetch(S.BASE, '/api/auth/verify-email?token=' + tok, null, 'GET');
|
|
assert.equal(click.status, 302);
|
|
assert.match(click.location, /verified=1/);
|
|
|
|
const ok = await login(S.BASE, email);
|
|
assert.ok(ok.body.token, 'login succeeds after verification');
|
|
assert.equal(ok.body.user.email_verified, 1);
|
|
|
|
const reuse = await jfetch(S.BASE, '/api/auth/verify-email?token=' + tok, null, 'GET');
|
|
assert.match(reuse.location, /verify_error=1/, 'single-use: second click errors');
|
|
});
|
|
|
|
test('bad and expired tokens redirect to verify_error', async () => {
|
|
const bad = await jfetch(S.BASE, '/api/auth/verify-email?token=deadbeef', null, 'GET');
|
|
assert.match(bad.location, /verify_error=1/);
|
|
|
|
const email = 'ev-' + rand() + '@x.test';
|
|
await register(S.BASE, email);
|
|
const tok = rand() + rand();
|
|
plantToken(S.dbPath, email, tok, -10); // already expired
|
|
const click = await jfetch(S.BASE, '/api/auth/verify-email?token=' + tok, null, 'GET');
|
|
assert.match(click.location, /verify_error=1/, 'expired token rejected');
|
|
|
|
// ...and that same user (still unverified) is asked on login — the existing-user path.
|
|
const gated = await login(S.BASE, email);
|
|
assert.equal(gated.body.verification_required, true, 'unverified user is gated on login');
|
|
});
|
|
|
|
test('resend is generic for known, unknown, and already-verified addresses', async () => {
|
|
const email = 'ev-' + rand() + '@x.test';
|
|
await register(S.BASE, email);
|
|
assert.deepEqual((await jfetch(S.BASE, '/api/auth/resend-verification', { email })).body, { ok: true });
|
|
assert.deepEqual((await jfetch(S.BASE, '/api/auth/resend-verification', { email: 'nobody-' + rand() + '@x.test' })).body,
|
|
{ ok: true }, 'unknown address returns the same generic ok (no enumeration)');
|
|
});
|
|
});
|
|
|
|
describe('self-host (SELF_HOSTED=true): soft nudge, never blocks', () => {
|
|
let S;
|
|
before(async () => { S = await boot({ SELF_HOSTED: 'true' }); });
|
|
after(() => { try { S.proc.kill('SIGKILL'); } catch { /* ignore */ } });
|
|
|
|
test('a later signup still gets a session, flagged unverified for the banner', async () => {
|
|
await register(S.BASE, 'ev-admin-' + rand() + '@x.test'); // first user
|
|
const email = 'ev-' + rand() + '@x.test';
|
|
const r = await register(S.BASE, email);
|
|
assert.ok(r.body.token, 'self-host issues a session even when unverified');
|
|
assert.equal(r.body.user.email_verified, 0, 'flagged unverified so the client can nudge');
|
|
|
|
const li = await login(S.BASE, email);
|
|
assert.ok(li.body.token, 'login proceeds (soft nudge, no block)');
|
|
|
|
// An existing/unverified user still logs in on self-host (soft), never gated.
|
|
forceUnverified(S.dbPath, email);
|
|
assert.ok((await login(S.BASE, email)).body.token, 'still no block after forcing unverified');
|
|
});
|
|
});
|