feat(quick-261009-dkv): Oeffentliche Links nach den Regeln der Nextcloud, Uebersichten und Annehmen, Schnittstelle

- Links anlegen und aendern: Passwort, Ablaufdatum und Bezeichnung nur im JSON-Koerper,
  Vorpruefung aus den frisch gelesenen Faehigkeiten (Passwort/Ablauf verlangt, Hochladen,
  mehrere Links), echte Datumspruefung, nur geaenderte Felder beim Aendern
- Routen shares/mine, shares/received (angenommen plus offen, 404/405 bei offen = leer)
  und POST shares/:id/accept am Ende der Klasse
- Fehlerzweige fuer Passwort und Ablauf mit Tests; nie Passwort in Antwort oder Fehler
- Live-Test e2e-shares.sh: Abschnitte links (Pflicht-Passwort und Pflicht-Ablauf per occ)
  und received (eingehend, offen, annehmen, verlassen)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 11:19:31 +02:00
parent bf893afc61
commit fe429dbe01
9 changed files with 1034 additions and 43 deletions
@@ -518,3 +518,476 @@ describe('NextcloudFilesSharesService — remove, lesen, Richtlinie', () => {
expect([401, 403]).not.toContain(statusOf(e));
});
});
// --- Links (quick-261009-dkv, Aufgabe 2) -----------------------------------------------------------
const PW = 'Geheim-Pass-2026!';
function linkJson(over: Record<string, unknown> = {}) {
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<string, unknown> = {}, over: Record<string, unknown> = {}) =>
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<string, unknown> = {}) => ({
status: 200,
text: ocs([linkJson(over)]),
});
const caps = (pub: Record<string, unknown> = {}): Reply => ({
status: 200,
text: LINK_CAPS(pub),
});
it('sendet nur die geaenderten Felder', async () => {
const cases: Array<[Record<string, unknown>, 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<string, unknown> = {}) =>
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/<Kennung>', 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);
});
});