import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createNextcloudClient, nextcloudConfigFromEnv, decodeXml, xmlResponses, xmlProp, xmlHasEmpty } from '../server/nextcloud.js'; const PRIV_RW = ''; const PRIV_RO = ''; const cal = (href, name, priv, owner, comps = '') => `${href}${name}/remote.php/dav/principals/users/${owner}/${priv}${comps}${owner.toUpperCase()}HTTP/1.1 200 OK`; /** Simulierte Nextcloud: Login-Name ist die E-Mail, Benutzer-ID "ferdinand". */ function fakeNextcloud() { const calls = []; const store = new Map(); let created = false; const res = (status, text = '', headers = {}) => ({ status, text: async () => text, headers: { get: (k) => headers[k.toLowerCase()] ?? null }, arrayBuffer: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0, 0, 0, 0, 0, 0, 0, 0]).buffer }); const fetchImpl = async (url, opts) => { calls.push({ method: opts.method, url, auth: opts.headers.Authorization, body: opts.body, depth: opts.headers.Depth }); const u = new URL(url); if (opts.method === 'PROPFIND') { if (u.pathname === '/remote.php/dav/') return res(207, '/remote.php/dav//remote.php/dav/principals/users/ferdinand/'); if (u.pathname === '/remote.php/dav/principals/users/ferdinand/') return res(207, '/remote.php/dav/principals/users/ferdinand//remote.php/dav/calendars/ferdinand//remote.php/dav/addressbooks/users/ferdinand/Ferdinand Urban'); if (u.pathname === '/remote.php/dav/calendars/ferdinand/' && opts.headers.Depth === '1') return res(207, `/remote.php/dav/calendars/ferdinand/${cal('/remote.php/dav/calendars/ferdinand/personal/', 'Persönlich', PRIV_RW, 'ferdinand')}${cal('/remote.php/dav/calendars/ferdinand/morning-meeting_shared_by_hilde/', 'Morning Meeting (Hildegard Kau)', PRIV_RW, 'hilde')}${cal('/remote.php/dav/calendars/ferdinand/geburtstage_shared_by_hilde/', 'Geburtstage & Co.', PRIV_RO, 'hilde')}${cal('/remote.php/dav/calendars/ferdinand/app-generated--deck--board-1/', 'Deck', PRIV_RO, 'ferdinand', '')}`); if (u.pathname === '/remote.php/dav/calendars/ferdinand/morning-meeting_shared_by_hilde/') return res(207, 'x'); if (u.pathname === '/remote.php/dav/calendars/ferdinand/morning-meeting/') return created ? res(207, 'x') : res(404); if (u.pathname === '/remote.php/dav/addressbooks/users/ferdinand/' && opts.headers.Depth === '1') return res(207, '/remote.php/dav/addressbooks/users/ferdinand//remote.php/dav/addressbooks/users/ferdinand/kontakte/Kontakte/remote.php/dav/addressbooks/users/ferdinand/z-server-generated--system/Konten'); return res(404); } if (opts.method === 'MKCALENDAR') { created = true; return res(201); } if (opts.method === 'PUT') { const existed = store.has(url); store.set(url, opts.body); return res(existed ? 204 : 201); } if (opts.method === 'DELETE') return store.delete(url) ? res(204) : res(404); if (opts.method === 'REPORT') { if (u.pathname.endsWith('/z-server-generated--system/')) return res(207, '/x/ferdinand.vcfBEGIN:VCARD \nVERSION:3.0 \nUID:ferdinand \nFN:Ferdinand Urban \nN:Urban;Ferdinand;;; \nEND:VCARD \n'); const expand = /${u.pathname}abc.ics"1"BEGIN:VCALENDAR \nBEGIN:VEVENT \nUID:${expand ? 'expanded' : 'master'} \nSUMMARY:Alex Koop (frei) \nDTSTART;VALUE=DATE:20261005 \nDTEND;VALUE=DATE:20261006 \nEND:VEVENT \nEND:VCALENDAR \n`); } if (opts.method === 'GET' && u.pathname.startsWith('/index.php/avatar/')) { return u.pathname.includes('/ferdinand/') ? res(200, '', { 'content-type': 'image/png', 'x-nc-iscustomavatar': '1' }) : res(200, '', { 'content-type': 'image/png', 'x-nc-iscustomavatar': '0' }); } return res(405); }; return { fetchImpl, calls, store }; } const mk = (fake) => createNextcloudClient({ baseUrl: 'https://cld2.example.de', user: 'urban@team-tradeo.de', password: 'geheim', calendar: 'morning-meeting', displayName: 'Morning ', fetchImpl: fake.fetchImpl }); test('Konfiguration aus Umgebung, Kalender-ID wird normalisiert', () => { const c = nextcloudConfigFromEnv({ NEXTCLOUD_URL: 'https://cloud.example.de/', NEXTCLOUD_USER: 'team', NEXTCLOUD_APP_PASSWORD: 'x', NEXTCLOUD_CALENDAR: 'Morning Meeting!' }); assert.equal(c.baseUrl, 'https://cloud.example.de'); assert.equal(c.calendar, 'morning-meeting-'); assert.ok(c.configured); assert.ok(!nextcloudConfigFromEnv({}).configured); }); test('XML-Hilfen sind präfix-unabhängig und dekodieren Entities', () => { const xml = '/a/A & B ö'; const [r] = xmlResponses(xml); assert.equal(decodeXml(xmlProp(r, 'displayname')), 'A & B ö'); assert.ok(xmlHasEmpty(xmlProp(r, 'resourcetype'), 'calendar')); assert.ok(!xmlHasEmpty(xmlProp(r, 'resourcetype'), 'addressbook')); }); test('Discovery: Login per E-Mail, Benutzer-ID aus dem Principal', async () => { const fake = fakeNextcloud(); const client = mk(fake); const d = await client.discover(); assert.equal(d.userId, 'ferdinand'); assert.equal(d.displayName, 'Ferdinand Urban'); assert.equal(d.calendarHome, 'https://cld2.example.de/remote.php/dav/calendars/ferdinand/'); assert.equal(d.addressbookHome, 'https://cld2.example.de/remote.php/dav/addressbooks/users/ferdinand/'); assert.equal(fake.calls[0].auth, 'Basic ' + Buffer.from('urban@team-tradeo.de:geheim').toString('base64')); await client.discover(); assert.equal(fake.calls.length, 2, 'Discovery wird gecacht'); }); test('Kalenderliste: nur Ereigniskalender, Schreibrecht und Freigabe erkannt', async () => { const client = mk(fakeNextcloud()); const cals = await client.listCalendars(); assert.deepEqual(cals.map((c) => c.id), ['geburtstage_shared_by_hilde', 'morning-meeting_shared_by_hilde', 'personal']); const mm = cals.find((c) => c.id === 'morning-meeting_shared_by_hilde'); assert.equal(mm.writable, true); assert.equal(mm.shared, true); assert.equal(mm.ownerName, 'HILDE'); assert.equal(cals.find((c) => c.id === 'geburtstage_shared_by_hilde').writable, false); assert.equal(cals.find((c) => c.id === 'personal').shared, false); assert.equal(cals.find((c) => c.id === 'personal').name, 'Persönlich'); }); test('ensureCalendar: vorhandenes Ziel prüfen oder eigenen Kalender anlegen', async () => { const fake = fakeNextcloud(); const client = mk(fake); const target = 'https://cld2.example.de/remote.php/dav/calendars/ferdinand/morning-meeting_shared_by_hilde/'; assert.deepEqual(await client.ensureCalendar(target), { href: target, created: false }); const own = await client.ensureCalendar(null); assert.equal(own.created, true); assert.equal(own.href, 'https://cld2.example.de/remote.php/dav/calendars/ferdinand/morning-meeting/'); const mkc = fake.calls.find((c) => c.method === 'MKCALENDAR'); assert.ok(mkc.body.includes('Morning <Meeting>')); assert.deepEqual(await client.ensureCalendar(null), { href: own.href, created: false }); await assert.rejects(client.ensureCalendar('https://cld2.example.de/remote.php/dav/calendars/ferdinand/weg/'), /Zielkalender nicht erreichbar/); }); test('Termine schreiben, abfragen (expand) und löschen', async () => { const fake = fakeNextcloud(); const client = mk(fake); const href = 'https://cld2.example.de/remote.php/dav/calendars/ferdinand/morning-meeting_shared_by_hilde/'; await client.putEvent(href, 'rotatory-2026-10-05@cld2.example.de', 'BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n'); assert.equal(fake.calls.at(-1).url, href + 'rotatory-2026-10-05%40cld2.example.de.ics'); const events = await client.queryEvents(href, '2026-10-01', '2026-10-31', { expand: true }); assert.equal(events.length, 1); assert.ok(events[0].ics.includes('UID:expanded')); assert.ok(events[0].ics.includes('\r\n'), 'Zeilenumbrüche aus wiederhergestellt'); const report = fake.calls.at(-1); assert.ok(report.body.includes('')); assert.ok(report.body.includes('')); assert.equal(await client.deleteEvent(href, 'rotatory-2026-10-05@cld2.example.de'), true); assert.equal(await client.deleteEvent(href, 'rotatory-2026-10-05@cld2.example.de'), false); assert.equal(await client.deleteEvent(href, href + 'irgendwas.ics'), false, 'href-Form akzeptiert'); }); test('Adressbücher, Kontakte und Avatare', async () => { const client = mk(fakeNextcloud()); const books = await client.listAddressBooks(); assert.equal(books[0].system, true); assert.equal(books[0].name, 'Konten'); assert.equal(books.length, 2); const cards = await client.queryContacts(books[0].href); assert.equal(cards.length, 1); assert.ok(cards[0].includes('FN:Ferdinand Urban')); const avatar = await client.fetchAvatar('ferdinand'); assert.equal(avatar.mime, 'image/png'); assert.equal(await client.fetchAvatar('niemand'), null, 'Buchstaben-Avatar zählt nicht'); }); test('fetchBinary schickt das App-Passwort nie an fremde Hosts', async () => { const fake = fakeNextcloud(); const client = mk(fake); const before = fake.calls.length; assert.equal(await client.fetchBinary('https://evil.example/avatar.png'), null); assert.equal(await client.fetchBinary('kein url'), null); assert.equal(fake.calls.length, before, 'kein Request abgesetzt'); }); test('falsche Zugangsdaten und Ausfall liefern verständliche Meldungen', async () => { const client = createNextcloudClient({ baseUrl: 'https://c.example', user: 'u', password: 'p', fetchImpl: async () => ({ status: 401, text: async () => '' }) }); await assert.rejects(client.test(), /Zugangsdaten/); const down = createNextcloudClient({ baseUrl: 'https://c.example', user: 'u', password: 'p', fetchImpl: async () => { throw new Error('fetch failed', { cause: new Error('ECONNREFUSED') }); } }); await assert.rejects(down.test(), /nicht erreichbar: ECONNREFUSED/); const notNc = createNextcloudClient({ baseUrl: 'https://c.example', user: 'u', password: 'p', fetchImpl: async () => ({ status: 207, text: async () => '' }) }); await assert.rejects(notNc.test(), /keinen Principal/); });