code-kumpel/tests/features.test.js

124 lines
7.7 KiB
JavaScript

const assert = require('assert');
const { createAuth, createLimiter, verifyPassword, emailKey } = require('../brain/auth.js');
const { memoryStore } = require('../brain/db.js');
const { createFeatures } = require('../brain/features.js');
const { createMailer } = require('../brain/mailer.js');
(async () => {
let t = 5_000_000;
const now = () => t;
const big = () => createLimiter(1000, 1000, now);
// Ohne Mail-Config: deaktiviert
assert.strictEqual(createMailer({}).enabled, false);
assert.strictEqual(createMailer({ NODE_ENV: 'development' }).kind, 'log');
assert.strictEqual(createMailer({ RESEND_API_KEY: 'x' }).kind, 'resend');
{
const kv = memoryStore(); const auth = createAuth(kv, { now, loginLimiter: big(), registerLimiter: big() });
const f = createFeatures(kv, auth, createMailer({}), { now, env: {}, limiter: big(), providers: {} });
assert.strictEqual((await f.forgot({ email: 'a@b.de' }, 'ip')).status, 503);
assert.deepStrictEqual(f.config().body.oauth, []);
assert.strictEqual(f.config().body.mail, false);
const r = await auth.register({ email: 'a@b.de', password: 'pixelpixel' }, 'ip');
assert.strictEqual(r.body.user.emailVerified, null, 'ohne Mail übersprungen');
}
// Mit Fake-Mailer
const sent = [];
const mailer = { enabled: true, kind: 'fake', send: async (to, subject, text) => sent.push({ to, subject, text }) };
const kv = memoryStore();
let f;
const auth = createAuth(kv, { now, loginLimiter: big(), registerLimiter: big(), onRegister: (u) => f.sendVerify(u) });
const fetchFake = async (url) => ({ json: async () => ({ access_token: 'tok' }) });
const providers = { github: { id: 'cid', secret: 's', authUrl: 'https://gh/auth', tokenUrl: 'https://gh/token', scope: 'user:email', email: async () => 'OAuth@Test.de' } };
f = createFeatures(kv, auth, mailer, { now, env: { APP_URL: 'https://app.test' }, limiter: big(), providers, fetch: fetchFake, allowedReturn: (u) => u.startsWith('https://app.test') });
// Registrierung → Bestätigungsmail
const r = await auth.register({ email: 'byte@test.de', password: 'pixelpixel' }, 'ip');
assert.strictEqual(r.status, 201);
assert.strictEqual(sent.length, 1);
const vtok = /#verify=([\w-]+)/.exec(sent[0].text)[1];
assert.strictEqual((await auth.me('Bearer ' + r.body.token)).body.user.emailVerified, false);
assert.strictEqual((await f.verify({ token: 'x'.repeat(30) })).status, 400);
assert.strictEqual((await f.verify({ token: vtok })).status, 200);
assert.strictEqual((await f.verify({ token: vtok })).status, 400, 'nur einmal');
assert.strictEqual((await auth.me('Bearer ' + r.body.token)).body.user.emailVerified, true);
// Token nur gehasht gespeichert
const rawAll = JSON.stringify(await kv.get('tokens', 'none'));
assert.ok(!rawAll.includes(vtok));
// Passwort vergessen: gleiche Antwort für unbekannte Mail
const n0 = sent.length;
assert.deepStrictEqual((await f.forgot({ email: 'niemand@test.de' }, 'ip')).body, { ok: true });
assert.strictEqual(sent.length, n0);
assert.deepStrictEqual((await f.forgot({ email: 'BYTE@test.de' }, 'ip')).body, { ok: true });
const rtok = /#reset=([\w-]+)/.exec(sent[sent.length - 1].text)[1];
assert.strictEqual((await f.reset({ token: rtok, password: 'kurz' }, 'ip')).status, 400);
// abgelaufen nach 1h
const t0 = t; t += f.RESET_TTL + 1;
assert.strictEqual((await f.reset({ token: rtok, password: 'neuespasswort' }, 'ip')).body.error, 'invalid_token');
t = t0;
await f.forgot({ email: 'byte@test.de' }, 'ip');
const rtok2 = /#reset=([\w-]+)/.exec(sent[sent.length - 1].text)[1];
assert.strictEqual((await f.reset({ token: rtok2, password: 'neuespasswort' }, 'ip')).status, 200);
assert.strictEqual((await auth.me('Bearer ' + r.body.token)).status, 401, 'Sessions beendet');
assert.ok(await verifyPassword('neuespasswort', (await kv.get('users', emailKey('byte@test.de'))).pw));
assert.strictEqual((await f.reset({ token: rtok2, password: 'nochmalneu1' }, 'ip')).status, 400, 'einmalig');
// OAuth
const st = await f.oauthStart('github', new URLSearchParams({ return: 'https://evil.example/' }));
assert.strictEqual(st.status, 302); assert.ok(st.location.startsWith('https://gh/auth?'));
const state = new URL(st.location).searchParams.get('state');
assert.strictEqual((await f.oauthStart('google', new URLSearchParams())).status, 404, 'Google ohne env aus');
const cbBad = await f.oauthCallback('github', new URLSearchParams({ state: 'falsch', code: 'c' }));
assert.ok(cbBad.location.includes('oauth_error=state'));
const cb = await f.oauthCallback('github', new URLSearchParams({ state, code: 'c' }));
assert.ok(cb.location.startsWith('https://app.test/#oauth_code='), 'fremde Return-URL ersetzt: ' + cb.location);
const once = cb.location.split('oauth_code=')[1];
const ex = await f.oauthExchange({ code: once });
assert.strictEqual(ex.status, 200); assert.strictEqual(ex.body.user.email, 'oauth@test.de');
assert.strictEqual((await f.oauthExchange({ code: once })).status, 400, 'Einmal-Code');
const OB = 'Bearer ' + ex.body.token;
assert.strictEqual((await auth.me(OB)).body.user.emailVerified, true);
assert.strictEqual((await auth.login({ email: 'oauth@test.de', password: 'irgendwas1' }, 'ip')).status, 401, 'OAuth-Konto ohne Passwort');
// Rangliste
const l = await auth.login({ email: 'byte@test.de', password: 'neuespasswort' }, 'ip'); const B = 'Bearer ' + l.body.token;
assert.strictEqual((await f.lbPost(undefined, { optIn: true, name: 'X' })).status, 401);
assert.strictEqual((await f.lbPost(B, { optIn: true, name: '<' })).status, 400);
assert.strictEqual((await f.lbPost(B, { optIn: true, name: 'Byte<script>', xp: 500, level: 5, streak: 3 })).status, 200);
assert.strictEqual((await f.lbPost(OB, { optIn: true, name: 'Oauth', xp: 900, level: 7, streak: 1 })).status, 200);
let rows = (await f.lbGet()).body.rows;
assert.deepStrictEqual(rows.map((x) => x.name), ['Oauth', 'Bytescript']);
assert.ok(!JSON.stringify(rows).includes('@'), 'keine E-Mails');
await f.lbPost(OB, { optIn: false });
assert.strictEqual((await f.lbGet()).body.rows.length, 1);
// Backups
const sync = require('../brain/sync.js');
const a = await auth.authenticate(B); const id = 'user:' + a.user.id;
for (let v = 1; v <= 15; v++) {
const prev = await kv.get('sync', id);
await sync.handleAsync(kv, 'PUT', id, { version: v, state: { xp: v * 10 } });
if (prev) await f.snapshot(a.user.id, prev, true);
}
let bl = (await f.backupsList(B)).body.backups;
assert.strictEqual(bl.length, 10); assert.strictEqual(bl[0].xp, 140);
const rs = await f.backupsRestore(B, { index: 4 });
assert.strictEqual(rs.status, 200); assert.strictEqual(rs.body.state.xp, 100); assert.strictEqual(rs.body.version, 16);
assert.strictEqual((await kv.get('sync', id)).state.xp, 100);
assert.strictEqual((await f.backupsList(B)).body.backups[0].xp, 150, 'aktueller Stand vor Restore gesichert');
assert.strictEqual((await f.backupsRestore(B, { index: 99 })).status, 404);
// automatische Snapshots höchstens stündlich
const before = (await kv.get('backups', a.user.id))[0].savedAt;
await f.snapshot(a.user.id, { version: 1, state: { xp: 1 } });
assert.strictEqual((await kv.get('backups', a.user.id))[0].savedAt, before);
// Fehler-Endpoint
assert.strictEqual((await f.reportError({}, 'ip')).status, 400);
assert.strictEqual((await f.reportError({ message: 'Boom Bearer abcdefghijklmnop', url: 'https://x/#reset=geheim' }, 'ip')).status, 200);
const errs = await kv.get('errors', 'recent');
assert.ok(!JSON.stringify(errs).includes('abcdefghijklmnop') && !JSON.stringify(errs).includes('geheim'));
assert.strictEqual((await f.listErrors('Bearer x')).status, 401);
console.log('features tests ok');
})().catch((e) => { console.error(e); process.exit(1); });