// Tests des Kerns und des Hintergrunddienstes mit einem nachgebauten Browser (chrome.*) und einem nachgebauten Server (fetch). // Start: node --test test/ import test from 'node:test'; import assert from 'node:assert/strict'; // ── Nachbau von chrome.* ───────────────────────────────────────────────────────────────────────── function makeChrome() { const store = {}; const calls = { notifications: [], badge: { text: '', color: '' }, tabs: [], permissionsRequested: [], permissionsRemoved: [], menus: [], windows: [] }; const listener = () => ({ addListener() {} }); const chromeMock = { storage: { local: { async get(keys) { const out = {}; for (const k of [].concat(keys ?? Object.keys(store))) if (k in store) out[k] = structuredClone(store[k]); return out; }, async set(obj) { for (const [k, v] of Object.entries(obj)) store[k] = structuredClone(v); }, async remove(keys) { for (const k of [].concat(keys)) delete store[k]; }, } }, permissions: { granted: new Set(), allow: true, async contains({ origins }) { return origins.every((o) => chromeMock.permissions.granted.has(o)); }, async request({ origins }) { calls.permissionsRequested.push(...origins); if (chromeMock.permissions.allow) origins.forEach((o) => chromeMock.permissions.granted.add(o)); return chromeMock.permissions.allow; }, async remove({ origins }) { calls.permissionsRemoved.push(...origins); origins.forEach((o) => chromeMock.permissions.granted.delete(o)); return true; }, }, tabs: { async create(o) { calls.tabs.push(o); return o; } }, windows: { async create(o) { calls.windows.push(o); return o; } }, action: { async setBadgeText({ text }) { calls.badge.text = text; }, async setBadgeBackgroundColor({ color }) { calls.badge.color = color; }, async openPopup() {}, }, notifications: { create(id, o) { calls.notifications.push({ id, ...o }); }, clear() {}, onClicked: listener() }, alarms: { async get() { return null; }, create() {}, onAlarm: listener() }, contextMenus: { removeAll(cb) { cb?.(); }, create(o) { calls.menus.push(o); }, onClicked: listener() }, runtime: { onInstalled: listener(), onStartup: listener(), onMessage: listener(), getURL: (p) => 'chrome-extension://x/' + p, async sendMessage() { return {}; } }, }; return { chromeMock, store, calls }; } /** Nachbau des Servers: Antworten werden vorgegeben, alle Aufrufe mitgeschrieben. */ function makeFetch(routes) { const log = []; const fn = async (url, init = {}) => { const u = new URL(url); const key = `${init.method || 'GET'} ${u.pathname.split('/api/staff/v1/')[1]}`; log.push({ key, url: u, init }); const r = routes[key]; if (!r) return new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'unbekannt' } }), { status: 404 }); const res = typeof r === 'function' ? await r(u, init) : r; return new Response(JSON.stringify(res.body ?? null), { status: res.status ?? 200, headers: { 'content-type': 'application/json' } }); }; fn.log = log; return fn; } let env; async function fresh() { env = makeChrome(); globalThis.chrome = env.chromeMock; return await import('../lib/core.js?' + Math.random()); } const SERVER = 'https://support.example.de'; const SESSION = { serverUrl: SERVER, token: 'T'.repeat(48), expiresAt: '2099-01-01 00:00:00', staff: { id: 1, name: 'Lukas', permissions: { create_ticket: true } } }; test('Serveradresse: bereinigen und ablehnen', async () => { const { normalizeServerUrl } = await fresh(); assert.equal(normalizeServerUrl('support.example.de'), 'https://support.example.de'); assert.equal(normalizeServerUrl(' https://support.example.de/ '), 'https://support.example.de'); assert.equal(normalizeServerUrl('https://example.de/helpdesk/'), 'https://example.de/helpdesk'); assert.equal(normalizeServerUrl('http://127.0.0.1:8080'), 'http://127.0.0.1:8080'); for (const bad of ['', 'http://support.example.de', 'https://user:pw@example.de', 'https://example.de/?x=1', 'https://example.de/#a', 'ftp://example.de', 'http://localhost.evil.de']) assert.throws(() => normalizeServerUrl(bad), undefined, bad); }); test('apiCall sendet das Token in beiden Headern, keine Cookies, keine Weiterleitung', async () => { const { apiCall, setSession } = await fresh(); await setSession(SESSION); globalThis.fetch = makeFetch({ 'GET tickets.php': { body: { items: [] } } }); await apiCall('tickets.php', { query: { q: 'drucker', page: 2, leer: '' } }); const { init, url } = globalThis.fetch.log[0]; assert.equal(init.headers.Authorization, `Bearer ${SESSION.token}`); assert.equal(init.headers['X-DeskHub-Authorization'], `Bearer ${SESSION.token}`); assert.equal(init.credentials, 'omit'); assert.equal(init.redirect, 'error'); assert.equal(url.searchParams.get('q'), 'drucker'); assert.equal(url.searchParams.get('page'), '2'); assert.equal(url.searchParams.has('leer'), false); }); test('apiCall: 401 räumt die Anmeldung weg, Netzwerkfehler wird gemeldet', async () => { const { apiCall, setSession, getSession, ApiError } = await fresh(); await setSession(SESSION); globalThis.fetch = makeFetch({ 'GET me.php': { status: 401, body: { error: { code: 'UNAUTHENTICATED', message: 'Abgelaufen.' } } } }); await assert.rejects(apiCall('me.php'), (e) => e instanceof ApiError && e.status === 401 && e.message === 'Abgelaufen.'); assert.equal(await getSession(), null); await setSession(SESSION); globalThis.fetch = async () => { throw new TypeError('fail'); }; await assert.rejects(apiCall('me.php'), (e) => e.code === 'NETWORK' && e.status === 0); assert.notEqual(await getSession(), null, 'ein Netzwerkfehler meldet nicht ab'); }); test('Anmeldung: Berechtigung, Authenticator-Code, Speichern', async () => { const { login, getSession } = await fresh(); let step = 0; globalThis.fetch = makeFetch({ 'POST login.php': (u, init) => { const b = JSON.parse(init.body); assert.equal(init.headers.Authorization, undefined, 'beim Anmelden gibt es noch kein Token'); if (++step === 1) { assert.equal(b.totpCode, null); return { status: 202, body: { requiresTotp: true } }; } assert.equal(b.totpCode, '123456'); return { body: { token: 'N'.repeat(48), expiresAt: '2099-01-01', staff: { id: 2, name: 'Agent' } } }; } }); const r1 = await login('support.example.de', 'a@b.de', 'geheim', ''); assert.deepEqual(r1, { requiresTotp: true }); assert.deepEqual(env.calls.permissionsRequested, ['https://support.example.de/*'], 'nur dieser eine Server'); assert.equal(await getSession(), null, 'ohne Code noch keine Anmeldung'); const r2 = await login('support.example.de', 'a@b.de', 'geheim', '123456'); assert.equal(r2.session.staff.name, 'Agent'); assert.equal((await getSession()).serverUrl, SERVER); }); test('Anmeldung ohne erteilte Berechtigung bricht ab, ohne etwas zu senden', async () => { const { login } = await fresh(); env.chromeMock.permissions.allow = false; globalThis.fetch = makeFetch({}); await assert.rejects(login('support.example.de', 'a@b.de', 'x', ''), /Erlaubnis/); assert.equal(globalThis.fetch.log.length, 0); }); test('Abmelden sperrt das Token, entfernt Berechtigung und Daten', async () => { const { logout, setSession, getSession } = await fresh(); await setSession(SESSION); await chrome.storage.local.set({ badgeCount: 3, lastSeen: 'x' }); env.chromeMock.permissions.granted.add(SERVER + '/*'); globalThis.fetch = makeFetch({ 'POST logout.php': { body: { ok: true } } }); await logout(); assert.equal(globalThis.fetch.log[0].key, 'POST logout.php'); assert.deepEqual(env.calls.permissionsRemoved, [SERVER + '/*']); assert.equal(await getSession(), null); assert.deepEqual(await chrome.storage.local.get(['badgeCount', 'lastSeen']), {}); }); test('Ticketpfad und Textumwandlung', async () => { const { ticketPath, plainToHtml } = await fresh(); assert.equal(ticketPath('T-2026-0001'), '/staff/ticket_view.php?n=T-2026-0001'); for (const bad of ['', 'a b', '../x', 'x&y=1', '