d00b6ff79f
- Anmelde-Client (getapppassword, cloud/user, Widerruf, Login Flow v2 mit fester Abfrageadresse, Link aus Basis und Token neu gebaut), Anmeldebremse 3/15 min je Benutzer und 8/30 min je Server, Ablaufspeicher für Browser-Anmeldungen (20 min, höchstens 200, eine je Benutzer) - Kontodienst: Verbinden, Trennen mit Widerruf, Sitzung mit Zugangsschlüssel-Sperre, frisch ausgestellte oder ersetzte App-Passwörter bleiben nie verwaist; jeder Kontozugriff über forTenant mit Mandant UND Benutzer aus dem Token - Migration 20261008183000: Spalte ncLoginName (App-Passwort gilt nur für den Anmeldenamen der Ausstellung, gemessen mit E-Mail-Anmeldung gegen Nextcloud 34) - Verbindungsbildschirm und Kontoleiste, Texte de/en, RLS-Inventar fortgeschrieben, E2E-Skript e2e-connect.sh Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
318 lines
11 KiB
TypeScript
318 lines
11 KiB
TypeScript
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<string, string> = {},
|
|
): 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<typeof authFailureToException>[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' } });
|
|
});
|
|
});
|