'use strict'; /** * Integrasjonstester: booter serveren på tilfeldig port med en midlertidig * datakatalog og tester REST-API + WebSocket-broadcast ende-til-ende. * Kjør med: npm test */ const { test, before, after } = require('node:test'); const assert = require('node:assert'); const fs = require('fs'); const os = require('os'); const path = require('path'); const WebSocket = require('ws'); const { createServer } = require('../server'); let server, baseUrl, dataDir; function req(method, p, body = null, headers = {}) { return fetch(baseUrl + p, { method, headers: { 'Content-Type': 'application/json', 'X-Remote-User': 'testuser', ...headers }, body: body !== null ? JSON.stringify(body) : undefined, }); } before(async () => { dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'netops-test-')); ({ server } = createServer({ dataDir })); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); baseUrl = `http://127.0.0.1:${server.address().port}`; }); after(() => { server.close(); fs.rmSync(dataDir, { recursive: true, force: true }); }); test('state starter tomt og identifiserer bruker fra header', async () => { const res = await req('GET', '/api/state'); assert.equal(res.status, 200); const data = await res.json(); assert.equal(data.user, 'testuser'); assert.deepEqual(data.tasks, []); }); test('seed fyller tom database, og nekter når data finnes', async () => { let res = await req('POST', '/api/seed'); assert.equal(res.status, 200); const { count } = await res.json(); assert.equal(count, 16); res = await req('POST', '/api/seed'); assert.equal(res.status, 409); }); test('create normaliserer og setter audit-felter', async () => { const res = await req('POST', '/api/tasks', { title: 'Test BGP-peer', category: 'BGP', priority: 'P1', status: 'ugyldig-status', estHours: 5000, tags: ['x', '', 'y'], deadline: 'ikke-en-dato', desc: 'hei\x00der', }); assert.equal(res.status, 201); const { task } = await res.json(); assert.equal(task.status, 'todo'); // ugyldig → default assert.equal(task.estHours, 999); // klampet assert.deepEqual(task.tags, ['x', 'y']); // tomme fjernet assert.equal(task.deadline, ''); // ugyldig format assert.equal(task.desc, 'heider'); // kontrolltegn fjernet assert.equal(task.createdBy, 'testuser'); assert.equal(task.v, 1); }); test('create uten tittel feiler', async () => { const res = await req('POST', '/api/tasks', { desc: 'mangler tittel' }); assert.equal(res.status, 400); }); test('update med riktig versjon ok, feil versjon gir 409 med serverens task', async () => { let res = await req('POST', '/api/tasks', { title: 'Versjonstest' }); const { task } = await res.json(); res = await req('PUT', `/api/tasks/${task.id}`, { ...task, status: 'progress', v: task.v }); assert.equal(res.status, 200); const { task: t2 } = await res.json(); assert.equal(t2.status, 'progress'); assert.equal(t2.v, 2); // Lagring fra utdatert versjon skal avvises res = await req('PUT', `/api/tasks/${task.id}`, { ...task, status: 'blocked', v: 1 }); assert.equal(res.status, 409); const conflict = await res.json(); assert.equal(conflict.task.v, 2); assert.equal(conflict.task.status, 'progress'); }); test('done setter completed, gjenåpning nullstiller', async () => { let res = await req('POST', '/api/tasks', { title: 'Ferdigtest' }); let { task } = await res.json(); res = await req('PUT', `/api/tasks/${task.id}`, { v: task.v, status: 'done' }); ({ task } = await res.json()); assert.equal(task.completed, new Date().toISOString().slice(0, 10)); assert.equal(task.completedBy, 'testuser'); // Delvis update skal beholde øvrige felter assert.equal(task.title, 'Ferdigtest'); res = await req('PUT', `/api/tasks/${task.id}`, { v: task.v, status: 'todo' }); ({ task } = await res.json()); assert.equal(task.completed, null); }); test('kommentarer: legg til, slett egne, nekt andres', async () => { let res = await req('POST', '/api/tasks', { title: 'Kommentartest' }); const { task } = await res.json(); res = await req('POST', `/api/tasks/${task.id}/comments`, { text: 'Første kommentar' }); assert.equal(res.status, 201); const { comment } = await res.json(); assert.equal(comment.by, 'testuser'); // Annen bruker får ikke slette res = await req('DELETE', `/api/tasks/${task.id}/comments/${comment.id}`, null, { 'X-Remote-User': 'kari' }); assert.equal(res.status, 400); res = await req('DELETE', `/api/tasks/${task.id}/comments/${comment.id}`); assert.equal(res.status, 200); const { task: after } = await res.json(); assert.equal(after.comments.length, 0); }); test('delete fjerner oppgave, 404 ved ukjent id', async () => { let res = await req('POST', '/api/tasks', { title: 'Slettetest' }); const { task } = await res.json(); res = await req('DELETE', `/api/tasks/${task.id}`); assert.equal(res.status, 200); res = await req('DELETE', `/api/tasks/${task.id}`); assert.equal(res.status, 404); }); test('aktivitetslogg føres', async () => { const res = await req('GET', '/api/state'); const { activity } = await res.json(); assert.ok(activity.length > 0); assert.ok(activity.some(a => a.action === 'create')); assert.ok(activity.some(a => a.action === 'done')); assert.ok(activity.some(a => a.action === 'comment')); }); test('websocket: mottar sync ved tilkobling og broadcast ved endringer', async () => { const wsUrl = baseUrl.replace('http', 'ws') + '/ws'; const wsClient = new WebSocket(wsUrl, { headers: { 'X-Remote-User': 'kari' } }); const messages = []; wsClient.on('message', raw => messages.push(JSON.parse(raw))); await new Promise((resolve, reject) => { wsClient.on('open', resolve); wsClient.on('error', reject); }); // Vent på hello + sync await new Promise(r => setTimeout(r, 200)); const hello = messages.find(m => m.type === 'hello'); assert.equal(hello.user, 'kari'); assert.ok(messages.some(m => m.type === 'sync' && Array.isArray(m.tasks))); // En REST-endring fra annen bruker skal pushes til kari const res = await req('POST', '/api/tasks', { title: 'WS-broadcast-test' }); const { task } = await res.json(); await new Promise(r => setTimeout(r, 200)); const broadcastMsg = messages.find(m => m.type === 'task' && m.op === 'create' && m.task.id === task.id); assert.ok(broadcastMsg, 'broadcast ikke mottatt'); assert.equal(broadcastMsg.by, 'testuser'); assert.equal(broadcastMsg.activity.action, 'create'); wsClient.close(); }); test('replace erstatter alt og persisterer til disk', async () => { const res = await req('POST', '/api/replace', [ { title: 'Eneste oppgave', category: 'NMS', priority: 'P4' }, { ugyldig: 'uten tittel' }, ]); const { count } = await res.json(); assert.equal(count, 1); // Vent på at skrivekøen tømmes, og verifiser fila på disk await new Promise(r => setTimeout(r, 300)); const onDisk = JSON.parse(fs.readFileSync(path.join(dataDir, 'tasks.json'), 'utf8')); assert.equal(onDisk.length, 1); assert.equal(onDisk[0].title, 'Eneste oppgave'); }); test('update med comment legger til status-kommentar og aktivitets-detalj', async () => { let res = await req('POST', '/api/tasks', { title: 'Kommentar-ved-status' }); const { task } = await res.json(); res = await req('PUT', `/api/tasks/${task.id}`, { v: task.v, status: 'done', comment: 'Verifisert i lab, rullet ut' }); assert.equal(res.status, 200); const { task: done } = await res.json(); assert.equal(done.status, 'done'); assert.equal(done.comments.length, 1); assert.equal(done.comments[0].kind, 'status'); assert.equal(done.comments[0].status, 'done'); assert.equal(done.comments[0].text, 'Verifisert i lab, rullet ut'); // Aktivitetsloggen skal nevne kommentaren res = await req('GET', '/api/state'); const { activity } = await res.json(); const entry = activity.find(a => a.taskId === task.id && a.action === 'done'); assert.ok(entry.detail.some(d => d.includes('Verifisert i lab'))); }); test('update uten comment legger ikke til kommentar', async () => { let res = await req('POST', '/api/tasks', { title: 'Ingen-kommentar' }); const { task } = await res.json(); res = await req('PUT', `/api/tasks/${task.id}`, { v: task.v, status: 'progress' }); const { task: upd } = await res.json(); assert.equal(upd.comments.length, 0); }); test('dagsplan: tom som standard, sett ordnet liste, hent tilbake', async () => { // Lag to oppgaver å planlegge const a = (await (await req('POST', '/api/tasks', { title: 'Plan A' })).json()).task; const b = (await (await req('POST', '/api/tasks', { title: 'Plan B' })).json()).task; let res = await req('GET', '/api/plan?date=2026-06-15'); assert.equal(res.status, 200); assert.deepEqual((await res.json()).items, []); res = await req('PUT', '/api/plan', { date: '2026-06-15', items: [b.id, a.id] }); assert.equal(res.status, 200); assert.deepEqual((await res.json()).items, [b.id, a.id]); // rekkefølge bevart res = await req('GET', '/api/plan?date=2026-06-15'); assert.deepEqual((await res.json()).items, [b.id, a.id]); }); test('dagsplan: er per bruker', async () => { const a = (await (await req('POST', '/api/tasks', { title: 'Privat plan' })).json()).task; await req('PUT', '/api/plan', { date: '2026-06-16', items: [a.id] }, { 'X-Remote-User': 'jon' }); // Annen bruker ser sin egen (tomme) plan const res = await req('GET', '/api/plan?date=2026-06-16', null, { 'X-Remote-User': 'kari' }); assert.deepEqual((await res.json()).items, []); }); test('dagsplan: ugyldige/dupliserte/ukjente ider filtreres bort', async () => { const a = (await (await req('POST', '/api/tasks', { title: 'Gyldig plan-task' })).json()).task; const res = await req('PUT', '/api/plan', { date: '2026-06-17', items: [a.id, a.id, 't_finnesikke', 12345, null], }); assert.deepEqual((await res.json()).items, [a.id]); // dedupet + kun eksisterende }); test('dagsplan: sletting av oppgave fjerner den fra planen', async () => { const a = (await (await req('POST', '/api/tasks', { title: 'Skal slettes fra plan' })).json()).task; await req('PUT', '/api/plan', { date: '2026-06-18', items: [a.id] }); await req('DELETE', `/api/tasks/${a.id}`); const res = await req('GET', '/api/plan?date=2026-06-18'); assert.deepEqual((await res.json()).items, []); }); test('dagsplan: ugyldig dato faller tilbake til i dag', async () => { const res = await req('GET', '/api/plan?date=tull'); const data = await res.json(); assert.match(data.date, /^\d{4}-\d{2}-\d{2}$/); }); test('dagsplan: navn lagres og hentes', async () => { const a = (await (await req('POST', '/api/tasks', { title: 'Navngitt plan-task' })).json()).task; let res = await req('PUT', '/api/plan', { date: '2026-06-20', items: [a.id], name: 'Vedlikeholdsvindu DC-OSL' }); assert.equal((await res.json()).name, 'Vedlikeholdsvindu DC-OSL'); res = await req('GET', '/api/plan?date=2026-06-20'); const data = await res.json(); assert.equal(data.name, 'Vedlikeholdsvindu DC-OSL'); assert.deepEqual(data.items, [a.id]); }); test('dagsplan: flytt slår sammen til måldato og tømmer kilden', async () => { const a = (await (await req('POST', '/api/tasks', { title: 'Flytt A' })).json()).task; const b = (await (await req('POST', '/api/tasks', { title: 'Flytt B' })).json()).task; await req('PUT', '/api/plan', { date: '2026-07-01', items: [a.id], name: 'Mandagsplan' }); await req('PUT', '/api/plan', { date: '2026-07-02', items: [b.id] }); let res = await req('PUT', '/api/plan/move', { from: '2026-07-01', to: '2026-07-02' }); assert.equal(res.status, 200); const moved = await res.json(); assert.deepEqual(moved.items, [b.id, a.id]); // måldatoens eksisterende først, så kildens // Kilden er tom nå res = await req('GET', '/api/plan?date=2026-07-01'); assert.deepEqual((await res.json()).items, []); }); test('dagsplan: deling — privat plan er skjult for andre, delt er synlig', async () => { const a = (await (await req('POST', '/api/tasks', { title: 'Delt plan-task' })).json()).task; // jon lager en delt plan (default shared=true) await req('PUT', '/api/plan', { date: '2026-07-10', items: [a.id] }, { 'X-Remote-User': 'jon' }); // kari kan se den let res = await req('GET', '/api/plan?date=2026-07-10&user=jon', null, { 'X-Remote-User': 'kari' }); assert.equal(res.status, 200); assert.deepEqual((await res.json()).items, [a.id]); // jon gjør den privat res = await req('PUT', '/api/plan/share', { date: '2026-07-10', shared: false }, { 'X-Remote-User': 'jon' }); assert.equal((await res.json()).shared, false); // kari får 403 res = await req('GET', '/api/plan?date=2026-07-10&user=jon', null, { 'X-Remote-User': 'kari' }); assert.equal(res.status, 403); // jon ser fortsatt sin egen res = await req('GET', '/api/plan?date=2026-07-10', null, { 'X-Remote-User': 'jon' }); assert.deepEqual((await res.json()).items, [a.id]); }); test('dagsplan: planIndex viser delte planer + egne private', async () => { const a = (await (await req('POST', '/api/tasks', { title: 'Index-task' })).json()).task; // jon: privat plan på en egen dato await req('PUT', '/api/plan', { date: '2026-07-15', items: [a.id] }, { 'X-Remote-User': 'jon' }); await req('PUT', '/api/plan/share', { date: '2026-07-15', shared: false }, { 'X-Remote-User': 'jon' }); // jon ser sin private i sin planIndex let res = await req('GET', '/api/state', null, { 'X-Remote-User': 'jon' }); let idx = (await res.json()).planIndex; assert.ok(idx[a.id] && idx[a.id].some(e => e.user === 'jon' && e.date === '2026-07-15')); // kari ser den IKKE (privat, ikke hennes) res = await req('GET', '/api/state', null, { 'X-Remote-User': 'kari' }); idx = (await res.json()).planIndex; assert.ok(!idx[a.id] || !idx[a.id].some(e => e.user === 'jon' && e.date === '2026-07-15')); }); test('kategorier: legg til egendefinert, og bruk den på en oppgave', async () => { let res = await req('POST', '/api/categories', { name: 'Trådløst' }); assert.equal(res.status, 201); const { category } = await res.json(); assert.match(category.id, /^[A-Z0-9]+$/); assert.equal(category.name, 'Trådløst'); // Kategorien er nå i /api/state res = await req('GET', '/api/state'); const cats = (await res.json()).categories; assert.ok(cats.some(c => c.id === category.id && c.custom)); // Den kan brukes på en oppgave (godtas av validering) res = await req('POST', '/api/tasks', { title: 'Wifi-oppgave', category: category.id }); assert.equal((await res.json()).task.category, category.id); }); test('kategorier: tomt navn avvises, ukjent kategori-id på oppgave faller til default', async () => { let res = await req('POST', '/api/categories', { name: ' ' }); assert.equal(res.status, 400); res = await req('POST', '/api/tasks', { title: 'Ukjent kat', category: 'FINNESIKKE' }); assert.equal((await res.json()).task.category, 'L3'); }); test('websocket: delt plan broadcastes til alle, privat kun til eier', async () => { const wsUrl = baseUrl.replace('http', 'ws') + '/ws'; const mk = user => new Promise((resolve, reject) => { const c = new WebSocket(wsUrl, { headers: { 'X-Remote-User': user } }); c.msgs = []; c.on('message', raw => c.msgs.push(JSON.parse(raw))); c.on('open', () => resolve(c)); c.on('error', reject); }); const jon = await mk('testuser'); // samme bruker som req() bruker const kari = await mk('kari'); await new Promise(r => setTimeout(r, 150)); const a = (await (await req('POST', '/api/tasks', { title: 'WS-delt-plan' })).json()).task; // Delt plan (default) → begge får den await req('PUT', '/api/plan', { date: '2026-08-01', items: [a.id] }); await new Promise(r => setTimeout(r, 150)); assert.ok(jon.msgs.some(m => m.type === 'plan' && m.date === '2026-08-01' && m.items.includes(a.id)), 'jon fikk delt plan'); assert.ok(kari.msgs.some(m => m.type === 'plan' && m.date === '2026-08-01' && m.items.includes(a.id)), 'kari fikk delt plan'); // Gjør privat → kari får tom melding (fjerner badge), jon får ekte jon.msgs.length = 0; kari.msgs.length = 0; await req('PUT', '/api/plan/share', { date: '2026-08-01', shared: false }); await new Promise(r => setTimeout(r, 150)); const jonMsg = jon.msgs.find(m => m.type === 'plan' && m.date === '2026-08-01'); const kariMsg = kari.msgs.find(m => m.type === 'plan' && m.date === '2026-08-01'); assert.ok(jonMsg && jonMsg.items.includes(a.id), 'jon ser fortsatt sin private plan'); assert.ok(kariMsg && kariMsg.items.length === 0, 'kari får tom plan-melding for privat'); jon.close(); kari.close(); }); test('cookie-auth: login setter cookie, ugyldig navn avvises', async () => { let res = await fetch(baseUrl + '/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'jon.v' }), }); assert.equal(res.status, 200); assert.match(res.headers.get('set-cookie'), /netops_user=jon\.v/); // Brukeren identifiseres via cookien res = await fetch(baseUrl + '/api/state', { headers: { Cookie: 'netops_user=jon.v' } }); assert.equal((await res.json()).user, 'jon.v'); res = await fetch(baseUrl + '/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: '