290 lines
17 KiB
JavaScript
290 lines
17 KiB
JavaScript
// 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: ohne Rückfrage, 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, [], 'es gibt keine Berechtigungsabfrage mehr');
|
|
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 mit falscher Adresse bricht ab, ohne etwas zu senden', async () => {
|
|
const { login } = await fresh();
|
|
globalThis.fetch = makeFetch({});
|
|
await assert.rejects(login('http://support.example.de', 'a@b.de', 'x', ''), /HTTPS/);
|
|
assert.equal(globalThis.fetch.log.length, 0);
|
|
});
|
|
|
|
test('Abmelden sperrt das Token und entfernt die Daten', async () => {
|
|
const { logout, setSession, getSession } = await fresh();
|
|
await setSession(SESSION); await chrome.storage.local.set({ badgeCount: 3, lastSeen: 'x' });
|
|
globalThis.fetch = makeFetch({ 'POST logout.php': { body: { ok: true } } });
|
|
await logout();
|
|
assert.equal(globalThis.fetch.log[0].key, 'POST logout.php');
|
|
assert.equal(await getSession(), null);
|
|
assert.deepEqual(await chrome.storage.local.get(['badgeCount', 'lastSeen']), {});
|
|
});
|
|
|
|
test('apiBeacon: Anfrage geht sofort und mit keepalive raus (Popup schließt sich)', async () => {
|
|
const { apiBeacon } = await fresh();
|
|
globalThis.fetch = makeFetch({ 'POST ticket_unlock.php': { body: { ok: true } } });
|
|
apiBeacon(SESSION, 'ticket_unlock.php', { number: 'T-1' });
|
|
assert.equal(globalThis.fetch.log.length, 1, 'synchron abgeschickt, ohne auf Speicherzugriffe zu warten');
|
|
const { init } = globalThis.fetch.log[0];
|
|
assert.equal(init.keepalive, true); assert.equal(init.method, 'POST'); assert.equal(init.credentials, 'omit');
|
|
assert.equal(init.headers.Authorization, `Bearer ${SESSION.token}`);
|
|
assert.deepEqual(JSON.parse(init.body), { number: 'T-1' });
|
|
apiBeacon(null, 'ticket_unlock.php', {});
|
|
assert.equal(globalThis.fetch.log.length, 1, 'ohne Sitzung wird nichts gesendet');
|
|
});
|
|
|
|
test('Abmelden löscht auch nicht gesendete Entwürfe, aber nicht die Einstellungen', async () => {
|
|
const { clearSession, setSession, setSettings } = await fresh();
|
|
await setSession(SESSION); await setSettings({ theme: 'dark' });
|
|
await chrome.storage.local.set({ 'draft:T-1': 'halber Text', 'draft:T-2': 'mehr', lastLogin: { serverUrl: SERVER } });
|
|
await clearSession();
|
|
const left = await chrome.storage.local.get(null);
|
|
assert.deepEqual(Object.keys(left).sort(), ['lastLogin', 'settings']);
|
|
});
|
|
|
|
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', '<script>', 'a'.repeat(41)]) assert.throws(() => ticketPath(bad), undefined, bad);
|
|
assert.equal(plainToHtml('a < b & "c"\nzweite\r\ndritte'), 'a < b & "c"<br>zweite<br>dritte');
|
|
assert.equal(plainToHtml('<img src=x onerror=alert(1)>'), '<img src=x onerror=alert(1)>');
|
|
});
|
|
|
|
test('Weboberfläche öffnen: nur die eigene Adresse und nur die Anmelde-Seite', async () => {
|
|
const { openWeb, setSession } = await fresh();
|
|
await setSession(SESSION);
|
|
globalThis.fetch = makeFetch({ 'POST web_pages.php': (u, init) => ({ body: { url: `${SERVER}/staff/app_login.php?t=abc` } }) });
|
|
await openWeb('/staff/ticket_view.php?n=T-1');
|
|
assert.equal(env.calls.tabs[0].url, `${SERVER}/staff/app_login.php?t=abc`);
|
|
assert.deepEqual(JSON.parse(globalThis.fetch.log[0].init.body), { path: '/staff/ticket_view.php?n=T-1' });
|
|
for (const evil of ['https://evil.example/staff/app_login.php?t=1', `${SERVER}/other.php`, 'http://support.example.de/staff/app_login.php', 'javascript:alert(1)']) {
|
|
globalThis.fetch = makeFetch({ 'POST web_pages.php': { body: { url: evil } } });
|
|
env.calls.tabs.length = 0;
|
|
await assert.rejects(openWeb('/staff/ticket_view.php?n=T-1'), undefined, evil);
|
|
assert.equal(env.calls.tabs.length, 0, 'es wurde kein Tab geöffnet: ' + evil);
|
|
}
|
|
});
|
|
|
|
// ── Hintergrunddienst ─────────────────────────────────────────────────────────────────────────────
|
|
async function freshBackground() {
|
|
const core = await fresh();
|
|
const bg = await import('../background.js?' + Math.random());
|
|
return { core, bg };
|
|
}
|
|
|
|
test('Hintergrund: erster Abruf legt nur den Bezugspunkt fest', async () => {
|
|
const { core, bg } = await freshBackground();
|
|
await core.setSession(SESSION);
|
|
globalThis.fetch = makeFetch({ 'GET activity.php': { body: { newReplies: 0, newUnassigned: 0, serverTime: '2026-10-25 10:00:00', health: [] } } });
|
|
const r = await bg.poll();
|
|
assert.equal(globalThis.fetch.log[0].url.searchParams.has('since'), false);
|
|
assert.equal(r.fresh, 0);
|
|
assert.equal((await chrome.storage.local.get('lastSeen')).lastSeen, '2026-10-25 10:00:00');
|
|
assert.equal(env.calls.notifications.length, 0);
|
|
});
|
|
|
|
test('Hintergrund: Neues wird gezählt, gemeldet und summiert; Serverzeit wird weitergereicht', async () => {
|
|
const { core, bg } = await freshBackground();
|
|
await core.setSession(SESSION);
|
|
await chrome.storage.local.set({ lastSeen: '2026-10-25 10:00:00', badgeCount: 1 });
|
|
globalThis.fetch = makeFetch({ 'GET activity.php': { body: { newReplies: 2, newUnassigned: 1, serverTime: '2026-10-25 10:01:00' } } });
|
|
const r = await bg.poll();
|
|
assert.equal(globalThis.fetch.log[0].url.searchParams.get('since'), '2026-10-25 10:00:00');
|
|
assert.equal(r.fresh, 3); assert.equal(r.total, 4);
|
|
assert.equal(env.calls.badge.text, '4');
|
|
assert.match(env.calls.notifications[0].message, /1 neu und nicht zugewiesen, 2 Aktualisierungen/);
|
|
assert.equal((await chrome.storage.local.get('lastSeen')).lastSeen, '2026-10-25 10:01:00');
|
|
});
|
|
|
|
test('Hintergrund: Benachrichtigungen lassen sich ausschalten, der Zähler bleibt', async () => {
|
|
const { core, bg } = await freshBackground();
|
|
await core.setSession(SESSION); await core.setSettings({ notifications: false });
|
|
await chrome.storage.local.set({ lastSeen: 'x' });
|
|
globalThis.fetch = makeFetch({ 'GET activity.php': { body: { newReplies: 0, newUnassigned: 5, serverTime: 'y' } } });
|
|
await bg.poll();
|
|
assert.equal(env.calls.notifications.length, 0); assert.equal(env.calls.badge.text, '5');
|
|
});
|
|
|
|
test('Hintergrund: Netzwerkfehler bleiben still und verlieren nichts', async () => {
|
|
const { core, bg } = await freshBackground();
|
|
await core.setSession(SESSION); await chrome.storage.local.set({ lastSeen: 'alt', badgeCount: 2 });
|
|
globalThis.fetch = async () => { throw new TypeError('offline'); };
|
|
const r = await bg.poll();
|
|
assert.equal(r.error, 'NETWORK');
|
|
const s = await chrome.storage.local.get(['lastSeen', 'badgeCount']);
|
|
assert.equal(s.lastSeen, 'alt'); assert.equal(s.badgeCount, 2); assert.equal(env.calls.notifications.length, 0);
|
|
});
|
|
|
|
test('Hintergrund: abgelaufene Anmeldung zeigt „!“ und bittet um neue Anmeldung', async () => {
|
|
const { core, bg } = await freshBackground();
|
|
await core.setSession(SESSION); await chrome.storage.local.set({ lastSeen: 'x' });
|
|
globalThis.fetch = makeFetch({ 'GET activity.php': { status: 401, body: { error: { code: 'UNAUTHENTICATED', message: 'abgelaufen' } } } });
|
|
await bg.poll();
|
|
assert.equal(env.calls.badge.text, '!');
|
|
assert.equal(env.calls.notifications[0].id, 'deskhub-login');
|
|
assert.equal(await core.getSession(), null);
|
|
// danach (ohne Anmeldung) passiert nichts mehr
|
|
globalThis.fetch = makeFetch({});
|
|
const r = await bg.poll();
|
|
assert.equal(r.skipped, true); assert.equal(globalThis.fetch.log.length, 0);
|
|
});
|
|
|
|
test('Hintergrund: Meldungsarten lassen sich einzeln abschalten', async () => {
|
|
const { core, bg } = await freshBackground();
|
|
await core.setSession(SESSION); await core.setSettings({ notifyUnassigned: false });
|
|
await chrome.storage.local.set({ lastSeen: 'x' });
|
|
globalThis.fetch = makeFetch({ 'GET activity.php': { body: { newReplies: 2, newUnassigned: 7, serverTime: 'y' } } });
|
|
const r = await bg.poll();
|
|
assert.equal(r.fresh, 2, 'nicht zugewiesene Tickets zählen nicht, wenn abgeschaltet');
|
|
assert.match(env.calls.notifications[0].message, /2 Aktualisierungen/);
|
|
assert.doesNotMatch(env.calls.notifications[0].message, /nicht zugewiesen/);
|
|
});
|
|
|
|
test('Zeitangaben: Serverzeit lesen und kurz anzeigen', async () => {
|
|
const { parseServerTime, timeAgo, fmtBytes, initials, safeColor } = await fresh();
|
|
const now = new Date(2026, 9, 31, 12, 0, 0);
|
|
assert.equal(parseServerTime('2026-10-31 04:03:00').getHours(), 4);
|
|
assert.equal(parseServerTime('unsinn'), null);
|
|
assert.equal(timeAgo('2026-10-31 11:59:30', now), 'gerade eben');
|
|
assert.equal(timeAgo('2026-10-31 11:40:00', now), 'vor 20 Min');
|
|
assert.equal(timeAgo('2026-10-31 04:03:00', now), 'heute 04:03');
|
|
assert.equal(timeAgo('2026-10-30 23:33:00', now), 'gestern 23:33');
|
|
assert.equal(timeAgo('2026-10-20 08:05:00', now), '20.10. 08:05');
|
|
assert.equal(timeAgo('', now), '');
|
|
assert.equal(timeAgo('2026-10-31 17:00:00', now), 'heute 17:00', 'Zukunft: Wiedervorlage');
|
|
assert.equal(timeAgo('2026-11-01 08:00:00', now), 'morgen 08:00');
|
|
assert.equal(timeAgo('2026-11-05 08:00:00', now), '05.11. 08:00');
|
|
assert.equal(fmtBytes(500), '500 B'); assert.equal(fmtBytes(2048), '2 KB'); assert.equal(fmtBytes(3 * 1048576), '3.0 MB');
|
|
assert.equal(initials('Lukas Muster'), 'LM'); assert.equal(initials(''), '?');
|
|
assert.equal(safeColor('#0f766e'), '#0f766e'); assert.equal(safeColor('red;background:url(x)'), null);
|
|
});
|
|
|
|
test('Wiedervorlage: nur künftige Zeitpunkte, im Serverformat', async () => {
|
|
globalThis.document = undefined;
|
|
const { snoozeChoices } = await import('../views/ticket.js');
|
|
const now = new Date(2026, 9, 31, 18, 0, 0); // nach 17 Uhr: „Heute 17:00“ entfällt
|
|
const labels = snoozeChoices(now).map(([l]) => l);
|
|
assert.ok(!labels.includes('Heute 17:00')); assert.ok(labels.includes('Morgen 08:00')); assert.ok(labels.includes('In 1 Stunde'));
|
|
for (const [, v] of snoozeChoices(now)) assert.match(v, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:00$/);
|
|
assert.deepEqual(snoozeChoices(now).find(([l]) => l === 'Morgen 08:00'), ['Morgen 08:00', '2026-11-01 08:00:00']);
|
|
});
|
|
|
|
test('Manifest: Serverzugriff ohne Rückfrage, nur nötige Rechte, strenge Inhaltsrichtlinie', async () => {
|
|
const { readFile } = await import('node:fs/promises');
|
|
const m = JSON.parse(await readFile(new URL('../manifest.json', import.meta.url), 'utf8'));
|
|
assert.equal(m.manifest_version, 3);
|
|
assert.deepEqual([...m.permissions].sort(), ['alarms', 'contextMenus', 'notifications', 'sidePanel', 'storage']);
|
|
assert.equal(m.optional_host_permissions, undefined, 'keine nachträgliche Berechtigungsabfrage');
|
|
assert.ok(m.host_permissions.includes('https://*/*'), 'Zugriff auf den Server ist von Anfang an erlaubt');
|
|
assert.match(m.content_security_policy.extension_pages, /script-src 'self'/);
|
|
assert.doesNotMatch(m.content_security_policy.extension_pages, /unsafe-inline|unsafe-eval/);
|
|
for (const f of Object.values(m.icons)) await readFile(new URL('../' + f, import.meta.url));
|
|
});
|