import { Readable } from 'node:stream'; import { describe, expect, it } from 'vitest'; import { authFailureToException, getAppPassword, getCurrentUser, ocsRequest, pollLoginFlow, revokeAppPassword, startLoginFlow, } from './nextcloud-auth-client'; import { NextcloudCallGate } from './nextcloud-call-gate'; import type { NcTransportRequest, NcTransportResponse, NextcloudTransport } from './nextcloud-http'; const BASE = 'http://cloud.example'; const TOKEN128 = 'A1b2C3d4'.repeat(16); // 128 alphanumerische Zeichen function reply( statusCode: number, body: unknown = '', headers: Record = {}, ): NcTransportResponse { const text = typeof body === 'string' ? body : JSON.stringify(body); return { statusCode, headers, body: Readable.from(text === '' ? [] : [Buffer.from(text)]) }; } function fake(handler: (req: NcTransportRequest) => NcTransportResponse) { const calls: NcTransportRequest[] = []; const transport: NextcloudTransport = async (req) => { calls.push(req); return handler(req); }; return { transport, calls }; } const ocs = (data: unknown) => ({ ocs: { meta: { status: 'ok' }, data } }); describe('getAppPassword', () => { it('sendet genau GET getapppassword mit Basic-Anmeldung und OCS-Kopfzeilen', async () => { const { transport, calls } = fake(() => reply(200, ocs({ apppassword: 'app-pw-123' }))); const gate = new NextcloudCallGate(); const res = await getAppPassword(transport, gate, BASE, 'anna', 'geheim'); expect(res).toEqual({ ok: true, appPassword: 'app-pw-123' }); expect(calls).toHaveLength(1); expect(calls[0].method).toBe('GET'); expect(calls[0].url).toBe('http://cloud.example/ocs/v2.php/core/getapppassword'); expect(calls[0].headers).toEqual({ 'user-agent': 'Tessera (Nextcloud-Dateien)', 'ocs-apirequest': 'true', accept: 'application/json', authorization: 'Basic YW5uYTpnZWhlaW0=', }); }); it('401: Art credentials, die Sperre markiert nichts', async () => { const { transport } = fake(() => reply(401)); const gate = new NextcloudCallGate(); const res = await getAppPassword(transport, gate, BASE, 'anna', 'falsch'); expect(res).toMatchObject({ ok: false, kind: 'credentials' }); expect(gate.isPaused('http://cloud.example').paused).toBe(false); }); it('403: app-password-given', async () => { const { transport } = fake(() => reply(403)); expect(await getAppPassword(transport, new NextcloudCallGate(), BASE, 'a', 'b')).toMatchObject({ ok: false, kind: 'app-password-given', }); }); it('429: locked, und die Sperre haelt den Ursprung an (naechster Aufruf ohne Transport)', async () => { const { transport, calls } = fake(() => reply(429, '', { 'retry-after': '600' })); const gate = new NextcloudCallGate(); const first = await getAppPassword(transport, gate, BASE, 'a', 'b'); expect(first).toMatchObject({ ok: false, kind: 'locked', retryAfterSeconds: 600 }); const second = await getAppPassword(transport, gate, BASE, 'a', 'b'); expect(second).toMatchObject({ ok: false, kind: 'locked' }); expect(calls).toHaveLength(1); }); it('503 ist Wartungsmodus, Antwort ohne apppassword ist invalid-response', async () => { expect( await getAppPassword( fake(() => reply(503)).transport, new NextcloudCallGate(), BASE, 'a', 'b', ), ).toMatchObject({ ok: false, kind: 'maintenance' }); expect( await getAppPassword( fake(() => reply(200, ocs({}))).transport, new NextcloudCallGate(), BASE, 'a', 'b', ), ).toMatchObject({ ok: false, kind: 'invalid-response' }); }); it('eine Weiterleitung wird nicht befolgt: redirect', async () => { const { transport, calls } = fake(() => reply(302, '', { location: 'http://evil.example/' })); expect(await getAppPassword(transport, new NextcloudCallGate(), BASE, 'a', 'b')).toMatchObject({ ok: false, kind: 'redirect', }); expect(calls).toHaveLength(1); }); }); describe('getCurrentUser', () => { it('liest id und display-name mit dem App-Passwort', async () => { const { transport, calls } = fake(() => reply(200, ocs({ id: 'anna', 'display-name': 'Anna Müller' })), ); const res = await getCurrentUser( transport, new NextcloudCallGate(), BASE, 'anna', 'app-pw-123', ); expect(res).toEqual({ ok: true, id: 'anna', displayName: 'Anna Müller' }); expect(calls[0].method).toBe('GET'); expect(calls[0].url).toBe('http://cloud.example/ocs/v2.php/cloud/user'); expect(calls[0].headers.authorization).toBe('Basic YW5uYTphcHAtcHctMTIz'); }); it('faellt auf displayname zurueck und erlaubt fehlenden Namen', async () => { const a = await getCurrentUser( fake(() => reply(200, ocs({ id: 'anna', displayname: 'Anna' }))).transport, new NextcloudCallGate(), BASE, 'anna', 'x', ); expect(a).toEqual({ ok: true, id: 'anna', displayName: 'Anna' }); const b = await getCurrentUser( fake(() => reply(200, ocs({ id: 'anna' }))).transport, new NextcloudCallGate(), BASE, 'anna', 'x', ); expect(b).toEqual({ ok: true, id: 'anna', displayName: null }); }); it('ohne id: invalid-response', async () => { const res = await getCurrentUser( fake(() => reply(200, ocs({}))).transport, new NextcloudCallGate(), BASE, 'anna', 'x', ); expect(res).toMatchObject({ ok: false, kind: 'invalid-response' }); }); }); describe('revokeAppPassword', () => { it('sendet DELETE apppassword mit dem App-Passwort', async () => { const { transport, calls } = fake(() => reply(200, ocs([]))); const res = await revokeAppPassword( transport, new NextcloudCallGate(), BASE, 'anna', 'app-pw-123', ); expect(res).toEqual({ ok: true }); expect(calls[0].method).toBe('DELETE'); expect(calls[0].url).toBe('http://cloud.example/ocs/v2.php/core/apppassword'); expect(calls[0].headers.authorization).toBe('Basic YW5uYTphcHAtcHctMTIz'); expect(calls[0].headersTimeoutMs).toBe(10_000); }); it('ein toter Zugangsschluessel geht gar nicht erst raus', async () => { const gate = new NextcloudCallGate(); gate.markDead('key-1'); const { transport, calls } = fake(() => reply(200, ocs([]))); const res = await revokeAppPassword(transport, gate, BASE, 'anna', 'x', 'key-1'); expect(res).toMatchObject({ ok: false, kind: 'credential-dead' }); expect(calls).toHaveLength(0); }); }); describe('startLoginFlow', () => { it('baut den Link aus Basis und Token neu und verwirft poll.endpoint und fremden Ursprung', async () => { const { transport, calls } = fake(() => reply(200, { poll: { token: TOKEN128, endpoint: 'http://evil.example/login/v2/poll' }, login: `http://evil.example/login/v2/flow/${TOKEN128}`, }), ); const res = await startLoginFlow(transport, new NextcloudCallGate(), BASE); expect(res).toEqual({ ok: true, loginUrl: `http://cloud.example/index.php/login/v2/flow/${TOKEN128}`, pollToken: TOKEN128, }); expect(calls).toHaveLength(1); expect(calls[0].method).toBe('POST'); expect(calls[0].url).toBe('http://cloud.example/index.php/login/v2'); }); it('login ohne passendes Token: invalid-response', async () => { const { transport } = fake(() => reply(200, { poll: { token: TOKEN128, endpoint: 'x' }, login: 'http://cloud.example/anderswo', }), ); expect(await startLoginFlow(transport, new NextcloudCallGate(), BASE)).toMatchObject({ ok: false, kind: 'invalid-response', }); }); it('ein unbrauchbares Abfrage-Token wird abgelehnt', async () => { const { transport } = fake(() => reply(200, { poll: { token: 'kurz', endpoint: 'x' }, login: `http://cloud.example/login/v2/flow/${TOKEN128}`, }), ); expect(await startLoginFlow(transport, new NextcloudCallGate(), BASE)).toMatchObject({ ok: false, kind: 'invalid-response', }); }); }); describe('pollLoginFlow', () => { it('fragt nur {Basis}/index.php/login/v2/poll mit token=... ab', async () => { const { transport, calls } = fake(() => reply(404)); const res = await pollLoginFlow(transport, new NextcloudCallGate(), BASE, TOKEN128); expect(res).toEqual({ ok: true, state: 'pending' }); expect(calls).toHaveLength(1); expect(calls[0].method).toBe('POST'); expect(calls[0].url).toBe('http://cloud.example/index.php/login/v2/poll'); expect(calls[0].body).toBe(`token=${TOKEN128}`); expect(calls[0].headers['content-type']).toBe('application/x-www-form-urlencoded'); }); it('200: granted mit loginName und appPassword, server wird ignoriert; evil.example nie angefragt', async () => { const { transport, calls } = fake(() => reply(200, { server: 'http://evil.example', loginName: 'zoe', appPassword: 'x' }), ); const res = await pollLoginFlow(transport, new NextcloudCallGate(), BASE, TOKEN128); expect(res).toEqual({ ok: true, state: 'granted', loginName: 'zoe', appPassword: 'x' }); expect(calls.every((c) => !c.url.includes('evil.example'))).toBe(true); }); it('200 ohne Zugangsdaten: invalid-response; 429: locked', async () => { expect( await pollLoginFlow( fake(() => reply(200, {})).transport, new NextcloudCallGate(), BASE, TOKEN128, ), ).toMatchObject({ ok: false, kind: 'invalid-response' }); expect( await pollLoginFlow( fake(() => reply(429)).transport, new NextcloudCallGate(), BASE, TOKEN128, ), ).toMatchObject({ ok: false, kind: 'locked' }); }); }); describe('Fehlerabbildung', () => { const body = (kind: Parameters[0]['kind']) => authFailureToException({ ok: false, kind, retryAfterSeconds: 42 }); it('antwortet nie mit 401 oder 403', () => { for (const kind of [ 'credentials', 'app-password-given', 'locked', 'maintenance', 'redirect', 'timeout', 'network', 'tls', 'invalid-response', 'credential-dead', 'upstream', ] as const) { const status = body(kind).getStatus(); expect([401, 403], kind).not.toContain(status); } }); it('locked traegt retryAfterSeconds', () => { expect(body('locked').getResponse()).toMatchObject({ code: 'nextcloudLocked', retryAfterSeconds: 42, }); expect(body('locked').getStatus()).toBe(503); }); }); describe('ocsRequest (allgemein)', () => { it('liefert ocs.data bei 2xx', async () => { const res = await ocsRequest( fake(() => reply(200, ocs({ hallo: 'welt' }))).transport, new NextcloudCallGate(), BASE, { method: 'GET', segments: ['cloud', 'capabilities'], authorization: 'Basic eDp5' }, ); expect(res).toEqual({ ok: true, status: 200, data: { hallo: 'welt' } }); }); });