fix(api): Dateien bereits veröffentlichter oder gesperrter Karten nicht wiederverwenden

Veröffentlichen lehnt mit 403 "Diese Bilder sind bereits veröffentlicht oder
gesperrt." ab, wenn eine Datei kein read("any") trägt (ausgeblendet) oder von
einer Galerie-Zeile des Autors schon als Bild oder Vorschau verwendet wird
(auch überkreuz, auch ausgeblendete Karten). Die Nutzungsprüfung liest die
Zeilen des Autors über den vorhandenen ownerId-Index seitenweise mit Cursor
(auf die Datei-ID-Spalten gibt es keinen Index) und läuft zusammen mit dem
Anlegen je Autor nacheinander. 92 Tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 14:27:29 +00:00
1 parent cd55d4c891
commit 7bbe0a9fe6
6 files changed
+235 -63

No files matched your search

+126 -18
View File
@@ -1005,33 +1005,141 @@ test('Fehlende Tabelle oder Bucket ist kein „Karte nicht verfügbar“, sonder
test('Veröffentlichen legt beide Dateien auf genau die sichtbaren Rechte fest (kanonischer Zustand)', async () => {
const { post, fake } = await setup();
fake.addFile({ id: 'img1', owner: ME, permissions: ['read("any")', 'delete("user:user-me")', 'update("user:user-me")', 'read("users")'] });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', permissions: ['delete("user:user-me")', 'write("user:user-me")'] });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', permissions: ['read("any")', 'delete("user:user-me")', 'write("user:user-me")'] });
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 201);
assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-me")']);
assert.deepEqual(fake.file('prev1').$permissions, ['read("any")', 'delete("user:user-me")']);
});
test('Ein 409 beim Veröffentlichen fasst die Dateirechte nicht an (auch nicht die einer ausgeblendeten Karte)', async () => {
test('Ein 409 beim Veröffentlichen fasst die Dateirechte der neu hochgeladenen Dateien nicht an', async () => {
const { post, fake } = await setup();
// Karte 1 ist schon veröffentlicht und wurde ausgeblendet; ihr Autor schickt dieselben Dateien erneut.
const hiddenFile = ['read("user:user-me")', 'read("label:admin")', 'delete("user:user-me")'];
fake.addFile({ id: 'img1', owner: ME, permissions: hiddenFile });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', permissions: hiddenFile });
const rowId = galleryRowId(ME, 'card1');
fake.addRow(
'gallery',
rowId,
{ title: 'Karte', imageFileId: 'img1', previewFileId: 'prev1', sourceCardId: 'card1', ownerId: ME, hidden: true, reportCount: 3 },
hiddenRowPermissions(ME),
);
seedUploads(fake);
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201);
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
// Dieselbe private Karte nochmal, mit frisch hochgeladenen Dateien (z. B. zweiter Tab): 409, Dateien bleiben unverändert.
const extra = ['read("any")', 'delete("user:user-me")', 'update("user:user-me")'];
fake.addFile({ id: 'img2', owner: ME, permissions: extra });
fake.addFile({ id: 'prev2', owner: ME, mimeType: 'image/webp', permissions: extra });
const res = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img2', previewFileId: 'prev2' }), JWT_ME);
assert.equal(res.status, 409);
assert.deepEqual(fake.file('img1').$permissions, hiddenFile);
assert.deepEqual(fake.file('prev1').$permissions, hiddenFile);
assert.equal(fake.row('gallery', rowId).hidden, true);
assert.equal(fake.calls.filter((c) => c.method === 'PUT').length, 0, 'kein PUT auf Dateien');
assert.deepEqual(fake.file('img2').$permissions, extra);
assert.deepEqual(fake.file('prev2').$permissions, extra);
assert.equal(fake.calls.filter((c) => c.method === 'PUT' && /\/files\/(img2|prev2)$/.test(c.path)).length, 0, 'kein PUT auf die neuen Dateien');
});
const TAKEN = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.';
test('Dateien einer ausgeblendeten Karte lassen sich nicht unter anderer sourceCardId neu veröffentlichen (403)', async () => {
const { post, fake } = await setup();
const { imageFileId, previewFileId } = seedCard(fake, { id: 'hid', hidden: true, reportCount: 3 });
const hiddenFile = ['read("user:user-me")', 'read("label:admin")', 'delete("user:user-me")'];
const res = await post('/api/veroeffentlichen', publishBody({ imageFileId, previewFileId, sourceCardId: 'andere-karte' }), JWT_ME);
assert.equal(res.status, 403);
assert.equal(res.json.error, TAKEN);
assert.equal(fake.rows('gallery').length, 1, 'keine neue Zeile');
assert.equal(fake.row('gallery', 'hid').hidden, true);
assert.deepEqual(fake.file(imageFileId).$permissions, hiddenFile);
assert.deepEqual(fake.file(previewFileId).$permissions, hiddenFile);
assert.equal(fake.calls.filter((c) => c.method === 'PUT').length, 0, 'Dateirechte bleiben unangetastet');
// auch mit der ursprünglichen sourceCardId bleibt es bei 403 (Dateien gesperrt) und nicht 409
const same = await post('/api/veroeffentlichen', publishBody({ imageFileId, previewFileId, sourceCardId: 'src-hid' }), JWT_ME);
assert.equal(same.status, 403);
});
test('Dateien einer sichtbaren Karte lassen sich nicht unter anderer sourceCardId neu veröffentlichen (403)', async () => {
const { post, fake } = await setup();
const { imageFileId, previewFileId } = seedCard(fake, { id: 'vis' });
fake.addFile({ id: 'fresh-img', owner: ME });
fake.addFile({ id: 'fresh-prev', owner: ME, mimeType: 'image/webp' });
for (const [label, overrides] of [
['beide Dateien', { imageFileId, previewFileId }],
['nur das Bild', { imageFileId, previewFileId: 'fresh-prev' }],
['nur die Vorschau', { imageFileId: 'fresh-img', previewFileId }],
]) {
const res = await post('/api/veroeffentlichen', publishBody({ ...overrides, sourceCardId: 'andere-karte' }), JWT_ME);
assert.equal(res.status, 403, label);
assert.equal(res.json.error, TAKEN, label);
}
assert.equal(fake.rows('gallery').length, 1);
// frische Dateien gehen
const ok = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'fresh-img', previewFileId: 'fresh-prev', sourceCardId: 'andere-karte' }), JWT_ME);
assert.equal(ok.status, 201);
});
test('Überkreuz-Verwendung: Bild einer Karte als Vorschau der neuen (und umgekehrt) wird abgelehnt', async () => {
const { post, fake } = await setup();
seedCard(fake, { id: 'vis' }); // Bild vis-img, Vorschau vis-prev
fake.addFile({ id: 'fresh-img', owner: ME });
fake.addFile({ id: 'fresh-prev', owner: ME, mimeType: 'image/webp' });
// vis-img (PNG) darf Vorschau sein, vis-prev (WebP) darf Bild sein: Format allein verhindert die Kreuzung nicht
const asPreview = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'fresh-img', previewFileId: 'vis-img', sourceCardId: 'x1' }), JWT_ME);
assert.equal(asPreview.status, 403);
assert.equal(asPreview.json.error, TAKEN);
const asImage = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'vis-prev', previewFileId: 'fresh-prev', sourceCardId: 'x2' }), JWT_ME);
assert.equal(asImage.status, 403);
assert.equal(asImage.json.error, TAKEN);
assert.equal(fake.rows('gallery').length, 1);
});
test('Dateien ohne read("any") (nicht frisch hochgeladen) werden abgelehnt (403)', async () => {
const { post, fake } = await setup();
fake.addFile({ id: 'img1', owner: ME, permissions: ['delete("user:user-me")'] });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' });
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 403);
assert.equal(res.json.error, TAKEN);
fake.addFile({ id: 'img1', owner: ME });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', permissions: ['read("user:user-me")', 'delete("user:user-me")'] });
res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 403);
assert.equal(fake.rows('gallery').length, 0);
});
test('Prüfung der Dateinutzung blättert durch alle Karten des Autors (250 Karten) und fragt nur dessen Zeilen ab', async () => {
const { post, fake } = await setup();
for (let i = 0; i < 250; i += 1) {
const owner = i % 50 === 0 ? OTHER : ME;
fake.addRow(
'gallery',
`row${i}`,
{ title: `K${i}`, imageFileId: `f${i}a`, previewFileId: `f${i}b`, sourceCardId: `s${i}`, ownerId: owner, hidden: false, reportCount: 0 },
['read("any")'],
);
}
fake.addFile({ id: 'f230a', owner: ME });
fake.addFile({ id: 'f230b', owner: ME, mimeType: 'image/webp' });
const taken = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'f230a', previewFileId: 'f230b', sourceCardId: 'neu1' }), JWT_ME);
assert.equal(taken.status, 403, 'Karte 230 liegt auf der dritten Seite');
assert.equal(taken.json.error, TAKEN);
const lists = fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith('/tables/gallery/rows'));
assert.equal(lists.length, 3, 'drei Seiten à 100');
for (const call of lists) assert.ok(decodeURIComponent(call.search).includes('"attribute":"ownerId","values":["user-me"]'));
seedUploads(fake);
const ok = await post('/api/veroeffentlichen', publishBody({ sourceCardId: 'neu2' }), JWT_ME);
assert.equal(ok.status, 201);
// Karten anderer Autoren zählen nicht (deren Dateien kann dieser Autor ohnehin nicht verwenden)
assert.ok(fake.row('gallery', 'row0'));
});
test('Gleichzeitiges Veröffentlichen derselben Dateien unter zwei sourceCardIds: genau eines gelingt', async () => {
const { post, fake } = await setup();
seedUploads(fake);
// Das Anlegen der ersten Zeile verzögern, damit beide Anfragen die Prüfung vor dem Anlegen erreichen würden.
fake.delayNext((c) => c.method === 'POST' && c.path.endsWith('/tables/gallery/rows'), 80);
const results = await Promise.all([
post('/api/veroeffentlichen', publishBody({ sourceCardId: 'c1' }), JWT_ME),
post('/api/veroeffentlichen', publishBody({ sourceCardId: 'c2' }), JWT_ME),
]);
assert.deepEqual(results.map((r) => r.status).sort(), [201, 403]);
assert.equal(fake.rows('gallery').length, 1);
});
test('Veröffentlichen: scheitert das Festlegen der Dateirechte, wird die Zeile zurückgenommen', async () => {