import { Readable } from 'node:stream'; import { describe, expect, it, vi } from 'vitest'; import { NextcloudCallGate } from './nextcloud-call-gate'; import { type NcSession } from './nextcloud-files.types'; import { NextcloudFilesSharesService } from './nextcloud-files-shares.service'; import type { NcTransportRequest, NextcloudTransport } from './nextcloud-http'; import { NextcloudLoginGuard } from './nextcloud-login-guard'; const SESSION: NcSession = { baseUrl: 'https://cloud.example/nc', ncUserId: 'anna', authorization: 'Basic YW5uYTphcHAtcHctMTIz', credentialKey: 'k1', }; const OCS = 'https://cloud.example/nc/ocs/v2.php'; const SHARES = `${OCS}/apps/files_sharing/api/v1/shares`; const BY_PATH = `${SHARES}?path=%2FProjekte&reshares=true`; const CAPS_URL = `${OCS}/cloud/capabilities`; const DAV = 'https://cloud.example/nc/remote.php/dav/files/anna'; const NS = 'xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns"'; function statXml(name: string, folder: boolean, letters: string): string { return ` /nc/remote.php/dav/files/anna/${name} ${folder ? '' : ''} 7${letters} ${folder ? '' : '5text/plain'} HTTP/1.1 200 OK`; } const ocs = (data: unknown, status = 200, message = 'OK') => JSON.stringify({ ocs: { meta: { statuscode: status, message }, data } }); const CAPS = (over: Record = {}) => ocs({ capabilities: { password_policy: { minLength: 10 }, files_sharing: { api_enabled: true, group_sharing: true, public: { enabled: true, upload: true }, sharee: { minSearchStringLength: 0 }, ...over, }, }, }); function shareJson(over: Record = {}) { return { id: '17', share_type: 0, uid_owner: 'anna', displayname_owner: 'Anna Müller', permissions: 15, can_edit: true, can_delete: true, stime: 1791532378, path: '/Projekte', item_type: 'folder', item_permissions: 31, mimetype: 'httpd/unix-directory', file_target: '/Projekte', share_with: 'ben', share_with_displayname: 'Ben Beispiel', ...over, }; } type Reply = { status: number; text?: string; headers?: Record }; function setup(replies: Reply[]) { const calls: NcTransportRequest[] = []; const queue = [...replies]; const transport: NextcloudTransport = async (req) => { calls.push(req); const reply = queue.shift(); if (!reply) throw new Error(`unerwarteter Aufruf ${req.method} ${req.url}`); return { statusCode: reply.status, headers: reply.headers ?? {}, body: Readable.from(reply.text !== undefined ? [Buffer.from(reply.text)] : []), }; }; const account = { getSession: vi.fn(async (..._a: unknown[]) => SESSION), markExpired: vi.fn(async (..._a: unknown[]) => {}), }; const guard = new NextcloudLoginGuard(); const clock = { t: 1_000_000 }; guard.now = () => clock.t; const service = new NextcloudFilesSharesService( account as never, new NextcloudCallGate(), transport, guard, ); return { service, account, calls, queue, clock, guard }; } const codeOf = (e: unknown) => (e as { response: { code: string } }).response.code; const bodyOf = (e: unknown) => (e as { response: Record }).response; const statusOf = (e: unknown) => (e as { getStatus(): number }).getStatus(); async function failure(promise: Promise): Promise { try { await promise; } catch (e) { return e; } throw new Error('es wurde kein Fehler geworfen'); } /** Antworten fuer ein erfolgreiches create(): stat, Faehigkeiten, Liste des Pfads, POST. */ function createReplies( over: { stat?: Reply; caps?: Reply; list?: Reply; post?: Reply } = {}, ): Reply[] { return [ over.stat ?? { status: 207, text: statXml('Projekte', true, 'RGDNVCK') }, over.caps ?? { status: 200, text: CAPS() }, over.list ?? { status: 200, text: ocs([]) }, over.post ?? { status: 200, text: ocs(shareJson()) }, ]; } const INPUT = { path: '/Projekte', kind: 'user', shareWith: 'ben', access: 'edit' } as const; describe('NextcloudFilesSharesService — create', () => { it('sendet PROPFIND, Faehigkeiten, Liste des Pfads, POST in dieser Reihenfolge und liefert die Freigabe', async () => { const { service, account, calls } = setup(createReplies()); const share = await service.create('t1', 'u1', INPUT); expect(account.getSession).toHaveBeenCalledWith('t1', 'u1'); expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual([ `PROPFIND ${DAV}/Projekte`, `GET ${CAPS_URL}`, `GET ${BY_PATH}`, `POST ${SHARES}`, ]); expect(calls[0].headers.depth).toBe('0'); expect(calls[3].headers['content-type']).toBe('application/json'); expect(calls[3].body).toBe( '{"path":"/Projekte","shareType":0,"shareWith":"ben","permissions":15}', ); expect(share).toMatchObject({ id: '17', kind: 'user', shareWith: 'ben', access: 'edit' }); }); it('Gruppe: shareType 1', async () => { const { service, calls } = setup( createReplies({ post: { status: 200, text: ocs(shareJson({ share_type: 1, share_with: 'team' })) }, }), ); const share = await service.create('t1', 'u1', { ...INPUT, kind: 'group', shareWith: 'team' }); expect(calls[3].body).toBe( '{"path":"/Projekte","shareType":1,"shareWith":"team","permissions":15}', ); expect(share.kind).toBe('group'); }); it('Datei: Bearbeiten sendet 3, Ansehen sendet 1', async () => { for (const [access, permissions] of [ ['edit', 3], ['view', 1], ] as const) { const { service, calls } = setup([ { status: 207, text: statXml('Bericht.txt', false, 'RGDNVW') }, { status: 200, text: CAPS() }, { status: 200, text: ocs([]) }, { status: 200, text: ocs(shareJson({ path: '/Bericht.txt', item_type: 'file', permissions })), }, ]); await service.create('t1', 'u1', { ...INPUT, path: '/Bericht.txt', access }); expect(calls[2].url).toBe(`${SHARES}?path=%2FBericht.txt&reshares=true`); expect(calls[3].body).toBe( `{"path":"/Bericht.txt","shareType":0,"shareWith":"ben","permissions":${permissions}}`, ); } }); it('Hochladen fuer eine Person, oder Bearbeiten bei Buchstaben RG: 400 shareAccessInvalid, kein POST', async () => { const upload = setup(createReplies()); const e1 = await failure(upload.service.create('t1', 'u1', { ...INPUT, access: 'upload' })); expect(codeOf(e1)).toBe('shareAccessInvalid'); expect(statusOf(e1)).toBe(400); expect(upload.calls.some((c) => c.method === 'POST')).toBe(false); const readonly = setup( createReplies({ stat: { status: 207, text: statXml('Projekte', true, 'RG') } }), ); const e2 = await failure(readonly.service.create('t1', 'u1', INPUT)); expect(codeOf(e2)).toBe('shareAccessInvalid'); expect(readonly.calls.some((c) => c.method === 'POST')).toBe(false); }); it('der Empfaenger hat schon eine Freigabe: 409 shareAlreadyExists, kein POST', async () => { const { service, calls } = setup( createReplies({ list: { status: 200, text: ocs([shareJson()]) } }), ); const e = await failure(service.create('t1', 'u1', INPUT)); expect(codeOf(e)).toBe('shareAlreadyExists'); expect(statusOf(e)).toBe(409); expect(calls.some((c) => c.method === 'POST')).toBe(false); }); it('gleiche Kennung als Gruppe zaehlt nicht als vorhandene Personenfreigabe', async () => { const { service } = setup( createReplies({ list: { status: 200, text: ocs([shareJson({ share_type: 1, share_with: 'ben' })]) }, }), ); await expect(service.create('t1', 'u1', INPUT)).resolves.toMatchObject({ id: '17' }); }); it('Gruppen ausgeschaltet: 409 sharingDisabled mit der Gruppenmeldung, kein POST', async () => { const { service, calls } = setup( createReplies({ caps: { status: 200, text: CAPS({ group_sharing: false }) } }), ); const e = await failure( service.create('t1', 'u1', { ...INPUT, kind: 'group', shareWith: 'team' }), ); expect(codeOf(e)).toBe('sharingDisabled'); expect(statusOf(e)).toBe(409); expect(bodyOf(e).message).toBe('Teilen mit Gruppen ist in Ihrer Nextcloud ausgeschaltet.'); expect(bodyOf(e).scope).toBe('groups'); expect(calls.some((c) => c.method === 'POST')).toBe(false); }); it('Freigabe-Schnittstelle aus: 409 sharingDisabled', async () => { const { service, calls } = setup( createReplies({ caps: { status: 200, text: CAPS({ api_enabled: false }) } }), ); const e = await failure(service.create('t1', 'u1', INPUT)); expect(codeOf(e)).toBe('sharingDisabled'); expect(bodyOf(e).message).toBe('Teilen ist in Ihrer Nextcloud ausgeschaltet.'); expect(calls.some((c) => c.method === 'POST')).toBe(false); }); it('leerer Pfad oder Wurzel: 400 invalidPath ohne jeden Aufruf', async () => { for (const path of ['', '/']) { const { service, account, calls } = setup([]); const e = await failure(service.create('t1', 'u1', { ...INPUT, path })); expect(codeOf(e)).toBe('invalidPath'); expect(calls).toHaveLength(0); expect(account.getSession).not.toHaveBeenCalled(); } }); it('Eintrag nicht vorhanden: 404 notFound, danach kein Freigabe-Aufruf', async () => { const { service, calls } = setup([{ status: 404 }]); const e = await failure(service.create('t1', 'u1', INPUT)); expect(codeOf(e)).toBe('notFound'); expect(calls).toHaveLength(1); }); it.each([ [404, undefined, 422, 'shareRecipientInvalid'], [403, 'You cannot share a folder that contains other shares', 422, 'shareRejected'], [400, undefined, 422, 'shareRejected'], [500, undefined, 502, 'nextcloudError'], [503, undefined, 503, 'nextcloudMaintenance'], ])('POST antwortet %i -> %i %s', async (nc, message, http, code) => { const { service } = setup( createReplies({ post: { status: nc, text: JSON.stringify({ ocs: { meta: { statuscode: nc, message: message ?? 'x' }, data: [] }, }), }, }), ); const e = await failure(service.create('t1', 'u1', INPUT)); expect(codeOf(e)).toBe(code); expect(statusOf(e)).toBe(http); expect([401, 403]).not.toContain(statusOf(e)); if (code === 'shareRejected' && message) expect(bodyOf(e).ncMessage).toBe(message); }); it('POST antwortet 401: 409 connectionExpired und die Verbindung ist als abgelaufen markiert', async () => { const { service, account } = setup(createReplies({ post: { status: 401 } })); const e = await failure(service.create('t1', 'u1', INPUT)); expect(codeOf(e)).toBe('connectionExpired'); expect(statusOf(e)).toBe(409); expect(account.markExpired).toHaveBeenCalledTimes(1); expect(account.markExpired).toHaveBeenCalledWith('t1', 'u1'); }); it('POST antwortet 429: 503 nextcloudLocked', async () => { const { service } = setup( createReplies({ post: { status: 429, headers: { 'retry-after': '60' } } }), ); const e = await failure(service.create('t1', 'u1', INPUT)); expect(codeOf(e)).toBe('nextcloudLocked'); expect(statusOf(e)).toBe(503); }); }); describe('NextcloudFilesSharesService — Begrenzung neuer Freigaben', () => { it('15 in 10 Minuten gehen durch, die 16. ist 429 tooManyShares ohne Aufruf, nach 10 Minuten geht es wieder', async () => { const replies: Reply[] = []; for (let i = 0; i < 15; i++) replies.push(...createReplies()); // der abgewiesene 16. Versuch verbraucht nur die Vorpruefungen, nie den POST replies.push(...createReplies().slice(0, 3)); replies.push(...createReplies()); const { service, calls, clock } = setup(replies); for (let i = 0; i < 15; i++) { await service.create('t1', 'u1', { ...INPUT, shareWith: `ben${i}` }); } const before = calls.length; const e = await failure(service.create('t1', 'u1', { ...INPUT, shareWith: 'ben15' })); expect(codeOf(e)).toBe('tooManyShares'); expect(statusOf(e)).toBe(429); expect(bodyOf(e).retryAfterSeconds).toBe(600); // Vorpruefungen laufen, aber der POST wird nicht gesendet. expect(calls.slice(before).some((c) => c.method === 'POST')).toBe(false); clock.t += 10 * 60 * 1000; await expect( service.create('t1', 'u1', { ...INPUT, shareWith: 'ben15' }), ).resolves.toBeTruthy(); }); it('eine durch Vorpruefung abgelehnte Eingabe zaehlt nicht mit', async () => { const replies: Reply[] = []; for (let i = 0; i < 3; i++) { replies.push( ...createReplies({ list: { status: 200, text: ocs([shareJson({ share_with: 'x' })]) }, }).slice(0, 3), ); } for (let i = 0; i < 15; i++) replies.push(...createReplies()); const { service } = setup(replies); for (let i = 0; i < 3; i++) { const e = await failure(service.create('t1', 'u1', { ...INPUT, shareWith: 'x' })); expect(codeOf(e)).toBe('shareAlreadyExists'); } for (let i = 0; i < 15; i++) await service.create('t1', 'u1', { ...INPUT, shareWith: `b${i}` }); }); }); describe('NextcloudFilesSharesService — update', () => { const GET17 = `GET ${SHARES}/17`; it('Ansehen: GET der Freigabe, dann PUT nur mit der neuen Berechtigung', async () => { const { service, calls } = setup([ { status: 200, text: ocs([shareJson()]) }, { status: 200, text: ocs(shareJson({ permissions: 1 })) }, ]); const share = await service.update('t1', 'u1', '17', { access: 'view' }); expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual([GET17, `PUT ${SHARES}/17`]); expect(calls[1].body).toBe('{"permissions":1}'); expect(share.access).toBe('view'); }); it('Freigabe nicht aenderbar (can_edit false): 422 shareRejected ohne PUT', async () => { const { service, calls } = setup([ { status: 200, text: ocs([shareJson({ can_edit: false })]) }, ]); const e = await failure(service.update('t1', 'u1', '17', { access: 'view' })); expect(codeOf(e)).toBe('shareRejected'); expect(statusOf(e)).toBe(422); expect(calls).toHaveLength(1); }); it('PUT 404 -> 404 shareNotFound', async () => { const { service } = setup([ { status: 200, text: ocs([shareJson()]) }, { status: 404, text: ocs([], 404, 'Wrong share ID, share does not exist') }, ]); const e = await failure(service.update('t1', 'u1', '17', { access: 'view' })); expect(codeOf(e)).toBe('shareNotFound'); expect(statusOf(e)).toBe(404); }); it('PUT 400 und 403 -> 422 shareRejected mit Meldung der Nextcloud', async () => { for (const nc of [400, 403]) { const { service } = setup([ { status: 200, text: ocs([shareJson()]) }, { status: nc, text: ocs([], nc, 'Failed to update share.') }, ]); const e = await failure(service.update('t1', 'u1', '17', { access: 'view' })); expect(codeOf(e)).toBe('shareRejected'); expect(statusOf(e)).toBe(422); expect(bodyOf(e).ncMessage).toBe('Failed to update share.'); } }); it("Kennung 'abc' und eine 21-stellige Kennung: 404 shareNotFound ohne Aufruf", async () => { for (const id of ['abc', '1'.repeat(21), '../x', '']) { const { service, calls } = setup([]); const e = await failure(service.update('t1', 'u1', id, { access: 'view' })); expect(codeOf(e)).toBe('shareNotFound'); expect(calls).toHaveLength(0); } }); it('ohne Aenderung kommt die aktuelle Freigabe zurueck, es gibt kein PUT', async () => { const { service, calls } = setup([{ status: 200, text: ocs([shareJson()]) }]); const share = await service.update('t1', 'u1', '17', {}); expect(share.id).toBe('17'); expect(calls).toHaveLength(1); }); it('gleiche Berechtigung wie bisher: kein PUT', async () => { const { service, calls } = setup([{ status: 200, text: ocs([shareJson()]) }]); await service.update('t1', 'u1', '17', { access: 'edit' }); expect(calls).toHaveLength(1); }); it('Bearbeiten bei einem Eintrag ohne Schreibrecht und Hochladen sind shareAccessInvalid', async () => { const ro = setup([ { status: 200, text: ocs([shareJson({ permissions: 1, item_permissions: 17 })]) }, ]); expect(codeOf(await failure(ro.service.update('t1', 'u1', '17', { access: 'edit' })))).toBe( 'shareAccessInvalid', ); const up = setup([{ status: 200, text: ocs([shareJson()]) }]); expect(codeOf(await failure(up.service.update('t1', 'u1', '17', { access: 'upload' })))).toBe( 'shareAccessInvalid', ); }); it('GET der Freigabe 404 -> shareNotFound; 401 markiert die Verbindung', async () => { const gone = setup([{ status: 404, text: ocs([], 404, 'Wrong share ID') }]); expect(codeOf(await failure(gone.service.update('t1', 'u1', '17', { access: 'view' })))).toBe( 'shareNotFound', ); const dead = setup([{ status: 401 }]); const e = await failure(dead.service.update('t1', 'u1', '17', { access: 'view' })); expect(codeOf(e)).toBe('connectionExpired'); expect(dead.account.markExpired).toHaveBeenCalledTimes(1); }); }); describe('NextcloudFilesSharesService — remove, lesen, Richtlinie', () => { it('remove: DELETE auf die Kennung', async () => { const { service, calls } = setup([{ status: 200, text: ocs([]) }]); await expect(service.remove('t1', 'u1', '17')).resolves.toEqual({ deleted: true }); expect(calls[0].method).toBe('DELETE'); expect(calls[0].url).toBe(`${SHARES}/17`); expect(calls[0].body).toBeNull(); }); it('remove: 404 -> shareNotFound, 403 -> 422 shareRejected, falsche Kennung ohne Aufruf', async () => { const nf = setup([{ status: 404, text: ocs([], 404, 'Wrong share ID') }]); expect(codeOf(await failure(nf.service.remove('t1', 'u1', '17')))).toBe('shareNotFound'); const forbidden = setup([{ status: 403, text: ocs([], 403, 'Could not delete share') }]); const e = await failure(forbidden.service.remove('t1', 'u1', '17')); expect(codeOf(e)).toBe('shareRejected'); expect(statusOf(e)).toBe(422); expect(bodyOf(e).ncMessage).toBe('Could not delete share'); const bad = setup([]); expect(codeOf(await failure(bad.service.remove('t1', 'u1', 'abc')))).toBe('shareNotFound'); expect(bad.calls).toHaveLength(0); }); it('Freigaben eines Pfads: Pfad normalisiert, Wurzel ist invalidPath', async () => { const { service, calls } = setup([ { status: 200, text: ocs([shareJson(), { share_type: 4, id: '9' }]) }, ]); const out = await service.sharesForPath('t1', 'u1', '/Projekte/'); expect(calls[0].url).toBe(BY_PATH); expect(out.path).toBe('/Projekte'); expect(out.shares.map((s) => s.id)).toEqual(['17']); expect(out.hidden).toBe(1); const root = setup([]); expect(codeOf(await failure(root.service.sharesForPath('t1', 'u1', '/')))).toBe('invalidPath'); const nf = setup([{ status: 404, text: ocs([], 404, 'Wrong path') }]); expect(codeOf(await failure(nf.service.sharesForPath('t1', 'u1', '/x')))).toBe('notFound'); }); it('Empfaengersuche: genau die erwartete Adresse; leerer Suchbegriff ruft nichts auf', async () => { const { service, calls } = setup([ { status: 200, text: ocs({ users: [ { label: 'Ben Beispiel', value: { shareType: 0, shareWith: 'ben' }, shareWithDisplayNameUnique: 'ben', }, ], groups: [], }), }, ]); const out = await service.sharees('t1', 'u1', ' ben ', 'folder'); expect(calls[0].url).toBe( `${OCS}/apps/files_sharing/api/v1/sharees?search=ben&itemType=folder&perPage=20&shareType%5B0%5D=0&shareType%5B1%5D=1`, ); expect(out.sharees).toEqual([ { kind: 'user', id: 'ben', label: 'Ben Beispiel', detail: 'ben' }, ]); const empty = setup([]); await expect(empty.service.sharees('t1', 'u1', ' ', 'file')).resolves.toEqual({ sharees: [] }); expect(empty.calls).toHaveLength(0); }); it('Richtlinie: zwei Aufrufe -> zwei Anfragen an die Faehigkeiten (kein Zwischenspeicher)', async () => { const { service, calls } = setup([ { status: 200, text: CAPS() }, { status: 200, text: CAPS({ group_sharing: false }) }, ]); const first = await service.policy('t1', 'u1'); const second = await service.policy('t1', 'u1'); expect(calls.map((c) => c.url)).toEqual([CAPS_URL, CAPS_URL]); expect(first.groupsEnabled).toBe(true); expect(second.groupsEnabled).toBe(false); }); it('nie 401 oder 403 nach aussen, auch nicht bei 403 aus der Suche', async () => { const { service } = setup([{ status: 403, text: ocs([], 403, 'nope') }]); const e = await failure(service.sharees('t1', 'u1', 'be', 'file')); expect([401, 403]).not.toContain(statusOf(e)); }); }); // --- Links (quick-261009-dkv, Aufgabe 2) ----------------------------------------------------------- const PW = 'Geheim-Pass-2026!'; function linkJson(over: Record = {}) { return shareJson({ id: '31', share_type: 3, share_with: null, share_with_displayname: null, permissions: 1, token: 'AbC123', url: 'https://cloud.example/nc/index.php/s/AbC123', password: 'redacted', label: 'Kunde', ...over, }); } /** Faehigkeiten mit Link-Regeln: `public` wird mit den angegebenen Werten ueberlagert. */ const LINK_CAPS = (pub: Record = {}, over: Record = {}) => CAPS({ public: { enabled: true, upload: true, ...pub }, ...over }); const LINK_INPUT = { path: '/Projekte', kind: 'link', access: 'view', password: PW, expireDate: '', label: 'Kunde', } as const; function linkReplies( over: { stat?: Reply; caps?: Reply; list?: Reply; post?: Reply } = {}, withList = false, ): Reply[] { const replies: Reply[] = [ over.stat ?? { status: 207, text: statXml('Projekte', true, 'RGDNVCK') }, over.caps ?? { status: 200, text: LINK_CAPS() }, ]; if (withList) replies.push(over.list ?? { status: 200, text: ocs([]) }); replies.push(over.post ?? { status: 200, text: ocs(linkJson()) }); return replies; } describe('NextcloudFilesSharesService — Link anlegen', () => { it('sendet genau den erwarteten JSON-Koerper und gibt weder Passwort noch Kennung heraus', async () => { const { service, calls } = setup(linkReplies()); const share = await service.create('t1', 'u1', LINK_INPUT); expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual([ `PROPFIND ${DAV}/Projekte`, `GET ${CAPS_URL}`, `POST ${SHARES}`, ]); expect(calls[2].body).toBe( '{"path":"/Projekte","shareType":3,"permissions":1,"password":"Geheim-Pass-2026!","expireDate":"","label":"Kunde"}', ); expect(share).toMatchObject({ id: '31', kind: 'link', hasPassword: true, url: 'https://cloud.example/nc/index.php/s/AbC123', label: 'Kunde', }); const text = JSON.stringify(share); expect(text).not.toContain(PW); expect(text).not.toContain('redacted'); expect(text).not.toContain('"token"'); }); it('ohne Passwort, Ablauf und Bezeichnung werden diese Felder nicht gesendet', async () => { const { service, calls } = setup(linkReplies()); await service.create('t1', 'u1', { path: '/Projekte', kind: 'link', access: 'view' }); expect(calls[2].body).toBe('{"path":"/Projekte","shareType":3,"permissions":1}'); }); it('Nur hochladen bei einem Ordner sendet 4; bei einer Datei und ohne erlaubtes Hochladen: shareAccessInvalid ohne POST', async () => { const ok = setup(linkReplies()); await ok.service.create('t1', 'u1', { ...LINK_INPUT, access: 'upload' }); expect(ok.calls[2].body).toContain('"permissions":4'); const file = setup( linkReplies({ stat: { status: 207, text: statXml('Bericht.txt', false, 'RGDNVW') } }), ); const e1 = await failure( file.service.create('t1', 'u1', { ...LINK_INPUT, path: '/Bericht.txt', access: 'upload' }), ); expect(codeOf(e1)).toBe('shareAccessInvalid'); expect(statusOf(e1)).toBe(400); expect(file.calls.some((c) => c.method === 'POST')).toBe(false); for (const access of ['edit', 'upload'] as const) { const off = setup(linkReplies({ caps: { status: 200, text: LINK_CAPS({ upload: false }) } })); const e2 = await failure(off.service.create('t1', 'u1', { ...LINK_INPUT, access })); expect(codeOf(e2)).toBe('shareAccessInvalid'); expect(off.calls.some((c) => c.method === 'POST')).toBe(false); } }); it('Ordner ohne Schreibrecht (RG): Nur hochladen und Bearbeiten sind shareAccessInvalid', async () => { for (const access of ['edit', 'upload'] as const) { const { service, calls } = setup( linkReplies({ stat: { status: 207, text: statXml('Projekte', true, 'RG') } }), ); const e = await failure(service.create('t1', 'u1', { ...LINK_INPUT, access })); expect(codeOf(e)).toBe('shareAccessInvalid'); expect(calls.some((c) => c.method === 'POST')).toBe(false); } }); it('Links ausgeschaltet: 409 linkSharingDisabled', async () => { const { service, calls } = setup( linkReplies({ caps: { status: 200, text: CAPS({ public: { enabled: false } }) } }), ); const e = await failure(service.create('t1', 'u1', LINK_INPUT)); expect(codeOf(e)).toBe('linkSharingDisabled'); expect(statusOf(e)).toBe(409); expect(calls.some((c) => c.method === 'POST')).toBe(false); }); it('nur ein Link erlaubt und schon einer da: 409 shareAlreadyExists; mehrere erlaubt: keine Listenabfrage', async () => { const one = setup( linkReplies( { caps: { status: 200, text: LINK_CAPS({ multiple_links: false }) }, list: { status: 200, text: ocs([linkJson()]) }, }, true, ), ); const e = await failure(one.service.create('t1', 'u1', LINK_INPUT)); expect(codeOf(e)).toBe('shareAlreadyExists'); expect(one.calls.some((c) => c.method === 'POST')).toBe(false); const many = setup(linkReplies()); await many.service.create('t1', 'u1', LINK_INPUT); expect(many.calls.some((c) => c.url === BY_PATH)).toBe(false); }); it('Passwort verlangt und keins gesendet: 400 sharePasswordRequired nach nur zwei Aufrufen', async () => { for (const password of [undefined, '']) { const { service, calls } = setup( linkReplies({ caps: { status: 200, text: LINK_CAPS({ password: { enforced: true } }) }, }), ); const e = await failure(service.create('t1', 'u1', { ...LINK_INPUT, password })); expect(codeOf(e)).toBe('sharePasswordRequired'); expect(statusOf(e)).toBe(400); expect(calls.map((c) => c.method)).toEqual(['PROPFIND', 'GET']); } }); it('Ablauf verlangt und fehlend oder leer: 400 shareExpiryRequired ohne POST', async () => { for (const expireDate of [undefined, '']) { const { service, calls } = setup( linkReplies({ caps: { status: 200, text: LINK_CAPS({ expire_date: { enabled: true, days: '7', enforced: true } }), }, }), ); const e = await failure(service.create('t1', 'u1', { ...LINK_INPUT, expireDate })); expect(codeOf(e)).toBe('shareExpiryRequired'); expect(calls.some((c) => c.method === 'POST')).toBe(false); } }); it('Ablauf verlangt und ein Datum gesendet: das Datum steht im Koerper', async () => { const { service, calls } = setup( linkReplies({ caps: { status: 200, text: LINK_CAPS({ expire_date: { enabled: true, days: '7', enforced: true } }), }, post: { status: 200, text: ocs(linkJson({ expiration: '2026-12-01 00:00:00' })) }, }), ); const share = await service.create('t1', 'u1', { ...LINK_INPUT, expireDate: '2026-12-01' }); expect(calls[2].body).toContain('"expireDate":"2026-12-01"'); expect(share.expiration).toBe('2026-12-01'); }); it("kein echtes Datum ('2026-02-30'): 400 shareExpiryInvalid ohne POST", async () => { const { service, calls } = setup(linkReplies()); const e = await failure( service.create('t1', 'u1', { ...LINK_INPUT, expireDate: '2026-02-30' }), ); expect(codeOf(e)).toBe('shareExpiryInvalid'); expect(statusOf(e)).toBe(400); expect(calls.some((c) => c.method === 'POST')).toBe(false); }); it('Passwort, Ablauf und Bezeichnung werden bei Personen und Gruppen nie weitergegeben', async () => { const { service, calls } = setup(createReplies()); await service.create('t1', 'u1', { ...INPUT, password: PW, expireDate: '2026-12-01', label: 'x', }); expect(calls[3].body).toBe( '{"path":"/Projekte","shareType":0,"shareWith":"ben","permissions":15}', ); }); it('Person oder Gruppe ohne Empfaenger: 422 shareRecipientInvalid ohne Aufruf', async () => { const { service, calls } = setup([]); const e = await failure( service.create('t1', 'u1', { path: '/Projekte', kind: 'user', access: 'view' }), ); expect(codeOf(e)).toBe('shareRecipientInvalid'); expect(calls).toHaveLength(0); }); it.each([ [400, 'Password is too short', { password: PW, expireDate: '' }, 'sharePasswordRejected', 400], [ 404, 'Expiration date is in the past', { password: PW, expireDate: '2026-12-01' }, 'shareExpiryInvalid', 400, ], [ 403, 'Passwords are enforced for link and mail shares', { password: PW, expireDate: '' }, 'shareRejected', 422, ], [404, 'Could not create share', { password: PW, expireDate: '' }, 'notFound', 404], ] as const)('POST antwortet %i (%s) -> %s', async (nc, message, extra, code, http) => { const { service } = setup(linkReplies({ post: { status: nc, text: ocs([], nc, message) } })); const e = await failure(service.create('t1', 'u1', { ...LINK_INPUT, ...extra })); expect(codeOf(e)).toBe(code); expect(statusOf(e)).toBe(http); expect([401, 403]).not.toContain(statusOf(e)); if (code !== 'notFound') expect(bodyOf(e).ncMessage).toBe(message); expect(JSON.stringify(bodyOf(e))).not.toContain(PW); }); }); describe('NextcloudFilesSharesService — Link aendern', () => { const PUT31 = `PUT ${SHARES}/31`; const linkWith = (over: Record = {}) => ({ status: 200, text: ocs([linkJson(over)]), }); const caps = (pub: Record = {}): Reply => ({ status: 200, text: LINK_CAPS(pub), }); it('sendet nur die geaenderten Felder', async () => { const cases: Array<[Record, string]> = [ [{ password: 'Neu-Pass-2026!x' }, '{"password":"Neu-Pass-2026!x"}'], [{ expireDate: '' }, '{"expireDate":""}'], [{ access: 'upload' }, '{"permissions":4}'], [{ label: 'Angebot' }, '{"label":"Angebot"}'], ]; for (const [input, body] of cases) { const { service, calls } = setup([ linkWith({ expiration: '2026-12-01 00:00:00' }), caps(), { status: 200, text: ocs(linkJson()) }, ]); await service.update('t1', 'u1', '31', input); expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual([ `GET ${SHARES}/31`, `GET ${CAPS_URL}`, PUT31, ]); expect(calls[2].body).toBe(body); } }); it('unveraenderte Werte ergeben kein PUT', async () => { const { service, calls } = setup([ linkWith({ expiration: '2026-12-01 00:00:00', label: 'Kunde', password: null }), caps(), ]); await service.update('t1', 'u1', '31', { access: 'view', expireDate: '2026-12-01', label: 'Kunde', password: '', }); expect(calls.some((c) => c.method === 'PUT')).toBe(false); }); it("Passwort '' bei verlangtem Passwort: 400 sharePasswordRequired ohne PUT", async () => { const { service, calls } = setup([linkWith(), caps({ password: { enforced: true } })]); const e = await failure(service.update('t1', 'u1', '31', { password: '' })); expect(codeOf(e)).toBe('sharePasswordRequired'); expect(calls.some((c) => c.method === 'PUT')).toBe(false); }); it("Ablauf '' bei verlangtem Ablauf: 400 shareExpiryRequired ohne PUT", async () => { const { service, calls } = setup([ linkWith({ expiration: '2026-12-01 00:00:00' }), caps({ expire_date: { enabled: true, days: '7', enforced: true } }), ]); const e = await failure(service.update('t1', 'u1', '31', { expireDate: '' })); expect(codeOf(e)).toBe('shareExpiryRequired'); expect(calls.some((c) => c.method === 'PUT')).toBe(false); }); it("Passwort '' ohne Pflicht entfernt das Passwort (nur wenn eins gesetzt ist)", async () => { const { service, calls } = setup([ linkWith(), caps(), { status: 200, text: ocs(linkJson({ password: null })) }, ]); const share = await service.update('t1', 'u1', '31', { password: '' }); expect(calls[2].body).toBe('{"password":""}'); expect(share.hasPassword).toBe(false); }); it('Links ausgeschaltet: 409 linkSharingDisabled', async () => { const { service } = setup([ linkWith(), { status: 200, text: CAPS({ public: { enabled: false } }) }, ]); expect(codeOf(await failure(service.update('t1', 'u1', '31', { label: 'x' })))).toBe( 'linkSharingDisabled', ); }); it('Bearbeiten oder Hochladen ohne erlaubtes Hochladen: shareAccessInvalid', async () => { const { service } = setup([linkWith(), caps({ upload: false })]); expect(codeOf(await failure(service.update('t1', 'u1', '31', { access: 'edit' })))).toBe( 'shareAccessInvalid', ); }); it('kein echtes Datum: 400 shareExpiryInvalid ohne PUT', async () => { const { service, calls } = setup([linkWith(), caps()]); const e = await failure(service.update('t1', 'u1', '31', { expireDate: '2026-13-01' })); expect(codeOf(e)).toBe('shareExpiryInvalid'); expect(calls.some((c) => c.method === 'PUT')).toBe(false); }); it.each([ [400, { password: 'schwach' }, 'sharePasswordRejected', 400], [400, { expireDate: '2027-01-01' }, 'shareExpiryInvalid', 400], [404, { expireDate: '2027-01-01' }, 'shareExpiryInvalid', 400], [404, { label: 'x' }, 'shareNotFound', 404], [400, { label: 'x' }, 'shareRejected', 422], [403, { label: 'x' }, 'shareRejected', 422], ] as const)('PUT antwortet %i mit %j -> %s', async (nc, input, code, http) => { const { service } = setup([ linkWith(), caps(), { status: nc, text: ocs([], nc, 'Failed to update share.') }, ]); const e = await failure(service.update('t1', 'u1', '31', input)); expect(codeOf(e)).toBe(code); expect(statusOf(e)).toBe(http); if (code !== 'shareNotFound') expect(bodyOf(e).ncMessage).toBe('Failed to update share.'); expect(JSON.stringify(bodyOf(e))).not.toContain('schwach'); }); it('bei Personenfreigaben werden Link-Felder verworfen', async () => { const { service, calls } = setup([{ status: 200, text: ocs([shareJson()]) }]); await service.update('t1', 'u1', '17', { password: 'x', expireDate: '2026-12-01', label: 'y' }); expect(calls).toHaveLength(1); }); }); describe('NextcloudFilesSharesService — Uebersichten und Annehmen', () => { const received = (over: Record = {}) => shareJson({ id: '40', uid_owner: 'ben', displayname_owner: 'Ben Beispiel', share_with: 'anna', path: '/Ben-Ordner', file_target: '/Ben-Ordner', permissions: 1, can_edit: false, ...over, }); it('mine: GET auf die Freigaben ohne Abfrage, Arten 0/1/3, andere nur als Zahl', async () => { const { service, calls } = setup([ { status: 200, text: ocs([shareJson(), linkJson(), { share_type: 4, id: '5' }]), }, ]); const out = await service.mine('t1', 'u1'); expect(calls).toHaveLength(1); expect(`${calls[0].method} ${calls[0].url}`).toBe(`GET ${SHARES}`); expect(out.shares.map((s) => s.kind)).toEqual(['user', 'link']); expect(out.hidden).toBe(1); expect(out.truncated).toBe(false); }); it('received: angenommene plus offene Freigaben, nur Arten 0/1, offene mit pending', async () => { const { service, calls } = setup([ { status: 200, text: ocs([ received(), received({ id: '41', share_type: 1 }), linkJson({ uid_owner: 'ben' }), ]), }, { status: 200, text: ocs([received({ id: '42', file_target: '/Ben-Briefkasten' })]) }, ]); const out = await service.received('t1', 'u1'); expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual([ `GET ${SHARES}?shared_with_me=true`, `GET ${SHARES}/pending`, ]); expect(out.shares.map((s) => s.id)).toEqual(['40', '41']); expect(out.pending.map((s) => [s.id, s.pending])).toEqual([['42', true]]); expect(out.pending[0].name).toBe('Ben-Briefkasten'); expect(out.shares.every((s) => s.pending === undefined)).toBe(true); expect(out.hidden).toBe(1); }); it('received: eine offene Freigabe steht nicht doppelt bei den angenommenen', async () => { const { service } = setup([ { status: 200, text: ocs([received()]) }, { status: 200, text: ocs([received()]) }, ]); const out = await service.received('t1', 'u1'); expect(out.shares).toHaveLength(0); expect(out.pending.map((s) => s.id)).toEqual(['40']); }); it.each([ 404, 405, ])('received: die offene Liste antwortet %i -> leer, kein Fehler', async (nc) => { const { service } = setup([ { status: 200, text: ocs([received()]) }, { status: nc, text: ocs([], nc, 'nope') }, ]); const out = await service.received('t1', 'u1'); expect(out.pending).toEqual([]); expect(out.shares).toHaveLength(1); }); it('received: 500 bei der offenen Liste ist ein Fehler, 401 markiert die Verbindung', async () => { const boom = setup([ { status: 200, text: ocs([]) }, { status: 500, text: ocs([], 500, 'x') }, ]); expect(codeOf(await failure(boom.service.received('t1', 'u1')))).toBe('nextcloudError'); const dead = setup([{ status: 401 }]); expect(codeOf(await failure(dead.service.received('t1', 'u1')))).toBe('connectionExpired'); expect(dead.account.markExpired).toHaveBeenCalledTimes(1); }); it('accept: POST auf pending/', async () => { const { service, calls } = setup([{ status: 200, text: ocs([]) }]); await expect(service.accept('t1', 'u1', '17')).resolves.toEqual({ accepted: true }); expect(`${calls[0].method} ${calls[0].url}`).toBe(`POST ${SHARES}/pending/17`); expect(calls[0].body).toBeNull(); }); it('accept: 404 -> shareNotFound, falsche Kennung ohne Aufruf', async () => { const nf = setup([{ status: 404, text: ocs([], 404, 'Wrong share ID') }]); expect(codeOf(await failure(nf.service.accept('t1', 'u1', '17')))).toBe('shareNotFound'); const bad = setup([]); expect(codeOf(await failure(bad.service.accept('t1', 'u1', 'x')))).toBe('shareNotFound'); expect(bad.calls).toHaveLength(0); }); });