Upload via GUI (23 Dateien)

This commit is contained in:
2026-11-04 07:55:00 +00:00
parent 44d3b7308d
commit 8b64172140
7 changed files with 465 additions and 283 deletions
+1
View File
@@ -9,6 +9,7 @@ DeskHub in der Browserleiste: **Tickets lesen und beantworten**, Aufgaben, eigen
- **Ticket-Ansicht direkt in der Erweiterung:** Verlauf als Chat (Kunde, Mitarbeiter, interne Notizen, Kommentare, Anhänge), **Antworten** (mit Textbausteinen, Signatur-Auswahl und Status nach dem Senden), **interne Notiz**, **Status ändern** (mit Abschlussgrund), **zuweisen** (auch „Mir zuweisen“), **Wiedervorlage**, **Beobachten**, Ticket im Browser öffnen. Strg+Enter sendet. Ein angefangener Text bleibt als Entwurf erhalten, auch wenn sich das Popup schließt.
- **Aufgaben:** meine, alle offenen, erledigte; abhaken, neue Aufgabe mit Fälligkeit, Bearbeiter und Ticket, löschen.
- **Neu:** Ticket anlegen (Anfrager, Betreff, Beschreibung, Abteilung, Priorität, Bearbeiter, Kunden benachrichtigen).
- **Update-Hinweis:** Alle 6 Stunden fragt die Erweiterung den Server, ob in der Plugin-Webseite eine neuere Version liegt, und zeigt dann eine Benachrichtigung und auf der Startseite einen Hinweis mit Weg zur Download-Seite.
- **Mehr:** Konto, Meldungen einzeln ein-/ausschalten, Darstellung (automatisch, hell, dunkel), Team-Übersicht mit Status, Weboberfläche öffnen, Abmelden.
- **Seitenleiste:** Das Symbol oben rechts im Popup öffnet DeskHub in der Seitenleiste von Chrome (bleibt offen, volle Höhe). Alternativ als eigenes Fenster.
- **Zähler und Benachrichtigungen:** Jede Minute fragt die Erweiterung den Server nach Neuem; die Zahl am Symbol zählt, was seit dem letzten Öffnen dazugekommen ist.
+1 -1
View File
@@ -71,7 +71,7 @@ async function navigate(view, p = {}) {
const nav = h('nav', { class: 'nav', 'aria-label': 'Hauptmenü' }, TABS.map(([key, label, ic]) => h('button', { class: key === activeTab ? 'on' : '', onclick: () => ctx.go(key), 'aria-label': label }, icon(ic, 20), label)));
app.replaceChildren(header, content, nav);
chrome.runtime.sendMessage({ type: 'reset-badge' }).catch(() => {});
chrome.runtime.sendMessage({ type: 'poll-now' }).catch(() => {}); // Zähler aktualisieren (zum Beispiel nach dem Öffnen eines Tickets)
try { await VIEWS[view]?.(body, ctx, p); } catch (e) { body.replaceChildren(h('div', { class: 'msg err', role: 'alert' }, errText(e))); }
}
+49 -8
View File
@@ -1,8 +1,9 @@
// Hintergrunddienst: prüft jede Minute auf Neues, zeigt Zähler und Benachrichtigungen, Rechtsklick-Menü,
// Tastenkürzel und die Adressleisten-Suche („dh“ + Leertaste).
import { apiCall, getSession, getSettings, truncate } from './lib/core.js';
import { apiCall, getSession, getSettings, truncate, checkForExtensionUpdate } from './lib/core.js';
const ALARM = 'deskhub-poll';
const UPDATE_ALARM = 'deskhub-update';
const MENU = 'deskhub-create';
const APP = 'app.html';
@@ -12,7 +13,22 @@ async function setBadge(count, color = '#0f766e') {
}
async function ensureAlarm() {
if (!(await chrome.alarms.get(ALARM))) chrome.alarms.create(ALARM, { periodInMinutes: 1, delayInMinutes: 0.1 });
// Alle 30 Sekunden (Chrome erlaubt das ab Version 120, ältere runden auf eine Minute); eine ältere Uhr mit anderem Takt wird ersetzt
const current = await chrome.alarms.get(ALARM);
if (!current || current.periodInMinutes !== 0.5) chrome.alarms.create(ALARM, { periodInMinutes: 0.5, delayInMinutes: 0.1 });
if (!(await chrome.alarms.get(UPDATE_ALARM))) chrome.alarms.create(UPDATE_ALARM, { periodInMinutes: 360, delayInMinutes: 1 });
}
/** Neue Version der Erweiterung? Einmal pro Version eine Benachrichtigung, der Hinweis steht außerdem auf der Startseite. */
export async function checkUpdate(force = false) {
const info = await checkForExtensionUpdate({ force });
if (!info) return null;
const { updateNotified } = await chrome.storage.local.get('updateNotified');
if (updateNotified !== info.version) {
await chrome.storage.local.set({ updateNotified: info.version });
chrome.notifications.create('deskhub-update', { type: 'basic', iconUrl: 'icons/icon128.png', title: 'DeskHub-Erweiterung', message: `Version ${info.version} ist verfügbar. Auf der Startseite der Erweiterung steht, wie du sie bekommst.` });
}
return info;
}
async function ensureSetup() {
await ensureAlarm();
@@ -24,16 +40,39 @@ async function ensureSetup() {
ensureAlarm();
chrome.runtime.onInstalled.addListener(ensureSetup);
chrome.runtime.onStartup.addListener(async () => { await ensureSetup(); poll(); });
chrome.runtime.onStartup.addListener(async () => { await ensureSetup(); poll(); checkUpdate(); });
/** Fragt den Server nach Neuem seit dem letzten Mal. Merkt sich die Serverzeit als Bezugspunkt (kein Uhrzeit-Abgleich mit dem PC nötig). */
/**
* Fragt den Server nach dem echten Stand: Der Zähler zeigt, wie viele Tickets jetzt Aufmerksamkeit brauchen (offen, dir zugewiesen oder noch
* niemandem, seit der letzten Kundennachricht nicht geöffnet). Wird ein Ticket geöffnet, beantwortet oder übernommen, sinkt er beim nächsten
* Abruf von selbst, auch wenn das in der Weboberfläche oder im Windows-Programm geschah. Benachrichtigt wird nur für Tickets, die neu dazukommen.
* Ältere Server ohne diese Angabe liefern nur „seit dem letzten Mal“: Dafür bleibt die frühere, aufsummierende Zählung als Rückfall.
*/
export async function poll() {
const session = await getSession();
if (!session) { await setBadge(0); return { skipped: true }; }
const { lastSeen, badgeCount = 0 } = await chrome.storage.local.get(['lastSeen', 'badgeCount']);
const { lastSeen, badgeCount = 0, knownIds = null } = await chrome.storage.local.get(['lastSeen', 'badgeCount', 'knownIds']);
try {
const { data } = await apiCall('activity.php', { query: lastSeen ? { since: lastSeen } : {} });
const settings = await getSettings();
if (data.attention && Array.isArray(data.attention.unassigned) && Array.isArray(data.attention.mine)) {
const unassigned = settings.notifyUnassigned ? data.attention.unassigned : [];
const mine = settings.notifyMine ? data.attention.mine : [];
const known = new Set(knownIds || []);
const freshUnassigned = knownIds === null ? 0 : unassigned.filter((id) => !known.has(id)).length;
const freshMine = knownIds === null ? 0 : mine.filter((id) => !known.has(id)).length;
const ids = [...unassigned, ...mine];
await chrome.storage.local.set({ lastSeen: data.serverTime, badgeCount: ids.length, knownIds: ids });
await setBadge(ids.length);
const fresh = freshUnassigned + freshMine;
if (fresh > 0 && settings.notifications) {
const parts = [];
if (freshUnassigned) parts.push(`${freshUnassigned} neu und nicht zugewiesen`);
if (freshMine) parts.push(`${freshMine} Aktualisierung${freshMine === 1 ? '' : 'en'} bei deinen Tickets`);
chrome.notifications.create('deskhub-new', { type: 'basic', iconUrl: 'icons/icon128.png', title: 'DeskHub', message: parts.join(', '), priority: 1 });
}
return { fresh, total: ids.length };
}
const unassigned = settings.notifyUnassigned ? data.newUnassigned || 0 : 0;
const replies = settings.notifyMine ? data.newReplies || 0 : 0;
const fresh = unassigned + replies;
@@ -58,7 +97,7 @@ export async function poll() {
}
}
chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === ALARM) poll(); });
chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === ALARM) poll(); else if (alarm.name === UPDATE_ALARM) checkUpdate(); });
const openWindow = (query = '') => chrome.windows.create({ url: chrome.runtime.getURL(`${APP}?mode=window${query}`), type: 'popup', width: 460, height: 760 });
@@ -98,10 +137,12 @@ chrome.notifications.onClicked.addListener(async (id) => {
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
(async () => {
if (msg?.type === 'reset-badge') { await chrome.storage.local.set({ badgeCount: 0 }); await setBadge(0); sendResponse({ ok: true }); }
// Der Zähler wird nicht mehr beim Öffnen geleert: Er folgt dem Stand auf dem Server. „reset-badge“ (ältere Fenster) fragt deshalb nur neu ab.
if (msg?.type === 'reset-badge') sendResponse(await poll());
else if (msg?.type === 'poll-now') sendResponse(await poll());
else if (msg?.type === 'check-update') sendResponse({ update: await checkUpdate(true) });
else if (msg?.type === 'presence') { await apiCall('presence.php', { method: 'POST', body: {} }).catch(() => {}); sendResponse({ ok: true }); }
else if (msg?.type === 'session-changed') { await ensureAlarm(); await chrome.storage.local.remove(['lastSeen', 'badgeCount']); await setBadge(0); sendResponse(await poll()); }
else if (msg?.type === 'session-changed') { await ensureAlarm(); await chrome.storage.local.remove(['lastSeen', 'badgeCount', 'knownIds']); await setBadge(0); sendResponse(await poll()); }
else sendResponse({ ok: false });
})().catch((e) => { try { sendResponse({ error: (e && e.code) || 'ERROR' }); } catch { /* Fenster schon zu */ } });
return true; // Antwort kommt asynchron
+35
View File
@@ -134,6 +134,41 @@ export async function openWeb(path) {
return chrome.tabs.create({ url: u.href });
}
/** Vergleicht zwei Versionsnummern ("2.0.0" gegen "2.1"): negativ, null oder positiv. Nicht-Zahlen zählen als 0. */
export function compareVersions(a, b) {
const pa = String(a).split(/[.+-]/).map((x) => parseInt(x, 10) || 0);
const pb = String(b).split(/[.+-]/).map((x) => parseInt(x, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length, 3); i++) {
const d = (pa[i] || 0) - (pb[i] || 0);
if (d !== 0) return d;
}
return 0;
}
const UPDATE_CHECK_EVERY_MS = 6 * 3600 * 1000;
/**
* Fragt den Server, ob es eine neuere Version dieser Erweiterung gibt (die Plugin-Webseite liefert sie über DeskHub). Das Ergebnis steht
* in storage.local unter "update": { version, notes, size, checkedAt } oder null, wenn die Erweiterung aktuell ist. Höchstens alle 6 Stunden,
* außer mit force. Netzwerkfehler ändern nichts.
*/
export async function checkForExtensionUpdate({ force = false } = {}) {
const session = await getSession();
if (!session) return null;
const { update, updateCheckedAt = 0 } = await chrome.storage.local.get(['update', 'updateCheckedAt']);
if (!force && Date.now() - updateCheckedAt < UPDATE_CHECK_EVERY_MS) return update || null;
try {
const { data } = await apiCall('app_update.php', { query: { platform: 'chrome' } });
const current = chrome.runtime.getManifest().version;
const newer = data?.available && data.version && compareVersions(data.version, current) > 0;
const info = newer ? { version: String(data.version), notes: String(data.notes || ''), size: Number(data.size) || 0, checkedAt: Date.now() } : null;
await chrome.storage.local.set({ update: info, updateCheckedAt: Date.now() });
return info;
} catch {
return update || null; // Fehler: beim nächsten Mal erneut
}
}
/** Maskiert Text und macht Zeilenumbrüche zu <br>: Der Server nimmt Ticket-Nachrichten als HTML an und bereinigt sie zusätzlich. */
export function plainToHtml(text) {
return String(text ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\r\n|\r|\n/g, '<br>');
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "DeskHub",
"version": "2.0.0",
"version": "2.1.1",
"description": "DeskHub in der Browserleiste: Tickets lesen und beantworten, Aufgaben, Status, Benachrichtigungen und schnell ein Ticket anlegen.",
"minimum_chrome_version": "116",
"icons": { "16": "icons/icon16.png", "32": "icons/icon32.png", "48": "icons/icon48.png", "128": "icons/icon128.png" },
+366 -272
View File
@@ -1,289 +1,383 @@
// 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';
// 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: { getManifest: () => ({ version: '2.0.0' }), 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 &lt; b &amp; "c"<br>zweite<br>dritte');
assert.equal(plainToHtml('<img src=x onerror=alert(1)>'), '&lt;img src=x onerror=alert(1)&gt;');
});
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));
});
test('Versionen vergleichen', async () => {
const { compareVersions } = await fresh();
assert.ok(compareVersions('2.1.0', '2.0.0') > 0);
assert.ok(compareVersions('2.0.10', '2.0.9') > 0, 'Zahlen, nicht Text');
assert.equal(compareVersions('2.0', '2.0.0'), 0);
assert.ok(compareVersions('2.0.0', '2.0.1') < 0);
assert.ok(compareVersions('10.0.0', '9.9.9') > 0);
assert.equal(compareVersions('2.0.0-beta', '2.0.0'), 0, 'Zusätze zählen nicht');
});
test('Update-Prüfung: neuere Version wird gemerkt, aktuelle nicht, höchstens alle 6 Stunden', async () => {
const { checkForExtensionUpdate, setSession } = await fresh();
// ohne Anmeldung nichts
globalThis.fetch = makeFetch({});
assert.equal(await checkForExtensionUpdate(), null);
assert.equal(globalThis.fetch.log.length, 0);
await setSession(SESSION);
let version = '2.1.0';
globalThis.fetch = makeFetch({ 'GET app_update.php': () => ({ body: { available: true, platform: 'chrome', version, sha256: 'x', size: 35609, notes: 'Neues Design' } }) });
const info = await checkForExtensionUpdate();
assert.equal(info.version, '2.1.0'); assert.equal(info.notes, 'Neues Design');
assert.equal(globalThis.fetch.log[0].url.searchParams.get('platform'), 'chrome');
assert.equal(env.store.update.version, '2.1.0');
// zweiter Aufruf direkt danach fragt den Server nicht noch einmal
await checkForExtensionUpdate();
assert.equal(globalThis.fetch.log.length, 1);
// mit force und inzwischen gleicher Version: kein Hinweis mehr
version = '2.0.0';
assert.equal(await checkForExtensionUpdate({ force: true }), null);
assert.equal(env.store.update, null);
// Serverfehler lassen den bisherigen Stand unverändert
version = '2.5.0';
await checkForExtensionUpdate({ force: true });
globalThis.fetch = async () => { throw new TypeError('fail'); };
const again = await checkForExtensionUpdate({ force: true });
assert.equal(again.version, '2.5.0', 'bei einem Netzwerkfehler bleibt der letzte Stand');
// keine Version veröffentlicht
globalThis.fetch = makeFetch({ 'GET app_update.php': { body: { available: false, platform: 'chrome' } } });
assert.equal(await checkForExtensionUpdate({ force: true }), null);
});
// ── 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 &lt; b &amp; "c"<br>zweite<br>dritte');
assert.equal(plainToHtml('<img src=x onerror=alert(1)>'), '&lt;img src=x onerror=alert(1)&gt;');
});
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 () => {
// ── Zähler nach echtem Stand (neuer Server) ───────────────────────────────────
test('Zähler: zeigt den echten Stand, erster Abruf meldet nichts', 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: [] } } });
globalThis.fetch = makeFetch({ 'GET activity.php': { body: { newReplies: 0, newUnassigned: 0, attention: { unassigned: [5], mine: [7] }, serverTime: 't1' } } });
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);
assert.equal(env.calls.badge.text, '2');
assert.equal(r.total, 2); assert.equal(r.fresh, 0);
assert.equal(env.calls.notifications.length, 0, 'beim ersten Abruf (Bezugspunkt) keine Meldung');
});
test('Hintergrund: Neues wird gezählt, gemeldet und summiert; Serverzeit wird weitergereicht', async () => {
test('Zähler: sinkt von selbst, wenn das Ticket anderswo bearbeitet wurde, und bleibt nicht stehen', 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' } } });
await chrome.storage.local.set({ lastSeen: 't0', knownIds: [5], badgeCount: 1 });
globalThis.fetch = makeFetch({ 'GET activity.php': { body: { newReplies: 1, newUnassigned: 0, attention: { unassigned: [], mine: [] }, serverTime: 't2' } } });
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, '', 'kein Ticket braucht mehr Aufmerksamkeit: Zähler leer');
assert.equal(r.total, 0); assert.equal(r.fresh, 0);
assert.deepEqual((await chrome.storage.local.get('knownIds')).knownIds, []);
});
test('Zähler: meldet nur Tickets, die neu dazukommen, nicht schon bekannte', async () => {
const { core, bg } = await freshBackground();
await core.setSession(SESSION);
await chrome.storage.local.set({ lastSeen: 't0', knownIds: [5, 7] });
globalThis.fetch = makeFetch({ 'GET activity.php': { body: { attention: { unassigned: [9, 5], mine: [7, 11] }, serverTime: 't3' } } });
const r = await bg.poll();
assert.equal(r.total, 4); assert.equal(r.fresh, 2);
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');
assert.match(env.calls.notifications[0].message, /1 neu und nicht zugewiesen, 1 Aktualisierung bei deinen Tickets/);
env.calls.notifications.length = 0;
const r2 = await bg.poll();
assert.equal(r2.fresh, 0); assert.equal(env.calls.notifications.length, 0, 'gleicher Stand: keine zweite Meldung');
});
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 () => {
test('Zähler: Meldungsarten schalten auch den Zähler ab', 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/);
globalThis.fetch = makeFetch({ 'GET activity.php': { body: { attention: { unassigned: [1, 2, 3], mine: [4] }, serverTime: 't' } } });
await bg.poll();
assert.equal(env.calls.badge.text, '1');
});
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));
test('Zähler: wiederholte Abfragen ändern einen unveränderten Stand nicht (nichts wird nebenbei geleert)', async () => {
const { core, bg } = await freshBackground();
await core.setSession(SESSION);
await chrome.storage.local.set({ lastSeen: 't0', knownIds: [5, 6], badgeCount: 2 });
globalThis.fetch = makeFetch({ 'GET activity.php': { body: { attention: { unassigned: [5, 6], mine: [] }, serverTime: 't4' } } });
for (let i = 0; i < 3; i++) { const r = await bg.poll(); assert.equal(r.total, 2); assert.equal(r.fresh, 0); }
assert.equal(env.calls.badge.text, '2');
});
+12 -1
View File
@@ -1,6 +1,6 @@
// Start: Begrüßung, eigener Status, Kennzahlen, als Nächstes fällige Tickets.
import { h, clear, icon, toast, errText } from '../lib/dom.js';
import { apiCall, AVAILABILITY } from '../lib/core.js';
import { apiCall, AVAILABILITY, openWeb, compareVersions } from '../lib/core.js';
import { spinner, ticketList, showError, segmented } from '../lib/ui.js';
export async function renderHome(host, ctx) {
@@ -15,6 +15,17 @@ export async function renderHome(host, ctx) {
const stack = h('div', { class: 'stack' });
stack.append(h('div', {}, h('div', { class: 'muted' }, new Date().toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })), h('div', { class: 'hello' }, `Hallo ${first || ''}`.trim())));
// Neue Version der Erweiterung: Hinweis mit Weg zum Download (die Seite „Apps“ der Weboberfläche)
try {
const { update } = await chrome.storage.local.get('update');
if (update && compareVersions(update.version, chrome.runtime.getManifest().version) > 0) {
stack.append(h('div', { class: 'card' }, h('h2', {}, `Update verfügbar: ${update.version}`),
h('div', { class: 'muted' }, 'Eine neuere Version der Erweiterung liegt bereit. Auf der Seite „Apps“ laden, entpacken und unter chrome://extensions neu laden.'),
update.notes ? h('div', { class: 'muted', style: { marginTop: '6px', whiteSpace: 'pre-wrap' } }, update.notes) : null,
h('div', { style: { marginTop: '8px' } }, h('button', { class: 'btn', onclick: () => openWeb('/staff/apps.php').catch((e) => toast(errText(e), 'err')) }, 'Download-Seite öffnen'))));
}
} catch { /* ohne Hinweis weiter */ }
if (avail) {
let current = avail.myStatus;
const holder = h('div');