7704372c3c
- FavoriteLink: neue Spalten uploadedIconMime/iconVersion (Migration 20260923160000) - favorite-icon-files.ts: Erkennung PNG/JPEG/GIF/WebP/ICO/SVG, Pfadbildung ohne Byte aus der Anfrage im Pfad (T-LRR-01), best-effort Dateientfernung - FavoritesService: uploadIcon/removeUploadedIcon, Vorrang der hochgeladenen Datei in getIconBytes, Abrufprobe fuer eine neue iconUrl (422 statt stiller Speicherung), iconVersion-Erhoehung bei jeder Aenderung der Symbolquelle - FavoritesController: POST/DELETE /favorites/:id/icon, Cache-Control private - T-LRR-07 (Restrisiko aus dem Plan-Threat-Model geschlossen, ueber den Plan hinaus): DashboardService.removeWidget/deleteDashboard raeumen jetzt die Symboldateien der per Datenbank-Kaskade mitgeloeschten Favoriten auf (best effort, nie blockierend) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
200 lines
7.7 KiB
TypeScript
200 lines
7.7 KiB
TypeScript
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
|
import {
|
|
FAVORITE_ICON_MAX_BYTES,
|
|
detectFavoriteIconMime,
|
|
favoriteIconAbsolutePath,
|
|
favoriteIconExtension,
|
|
removeFavoriteIconFileBestEffort,
|
|
resolveFavoriteIconsDir,
|
|
} from './favorite-icon-files';
|
|
|
|
/**
|
|
* favorite-icon-files.spec — NEU (quick-260923-lrr).
|
|
*
|
|
* Erkennung (Muster dashboard-image-rules.spec.ts): PNG/JPEG/GIF/WebP wie
|
|
* `detectImageMime`, dazu ICO (Kopfstueck, kein CUR) und SVG (Praefix-Form,
|
|
* kein `<html>` davor). Pfadbildung: Endung aus dem Typ, Segmente nur aus
|
|
* Buchstaben/Ziffern/Bindestrich, Ergebnis muss im Symbolverzeichnis liegen
|
|
* (T-LRR-01). `FAVORITE_ICONS_DIR` steuert das Verzeichnis in Tests, wie
|
|
* `DASHBOARD_IMAGES_DIR` es fuer die Bilderrahmen-Bilder tut.
|
|
*/
|
|
function bytes(...parts: (number[] | string)[]): Uint8Array {
|
|
const out: number[] = [];
|
|
for (const p of parts) {
|
|
if (typeof p === 'string') {
|
|
for (const ch of p) out.push(ch.charCodeAt(0));
|
|
} else {
|
|
out.push(...p);
|
|
}
|
|
}
|
|
return Uint8Array.from(out);
|
|
}
|
|
|
|
describe('detectFavoriteIconMime (quick-260923-lrr)', () => {
|
|
it('PNG/JPEG/GIF/WebP-Signaturen ergeben dieselben Typen wie detectImageMime', () => {
|
|
expect(
|
|
detectFavoriteIconMime(bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], [0, 0, 0, 13])),
|
|
).toBe('image/png');
|
|
expect(detectFavoriteIconMime(bytes([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10], 'JFIF'))).toBe('image/jpeg');
|
|
expect(detectFavoriteIconMime(bytes('GIF89a', [1, 0, 1, 0]))).toBe('image/gif');
|
|
expect(detectFavoriteIconMime(bytes('RIFF', [0x24, 0x00, 0x00, 0x00], 'WEBP', 'VP8 '))).toBe(
|
|
'image/webp',
|
|
);
|
|
});
|
|
|
|
it('Bytes 00 00 01 00 (mindestens 6 Bytes) ergeben image/x-icon', () => {
|
|
expect(detectFavoriteIconMime(bytes([0x00, 0x00, 0x01, 0x00, 0x01, 0x00]))).toBe('image/x-icon');
|
|
});
|
|
|
|
it('zu kurzes ICO-Kopfstueck (weniger als 6 Bytes) ergibt null', () => {
|
|
expect(detectFavoriteIconMime(bytes([0x00, 0x00, 0x01, 0x00]))).toBeNull();
|
|
});
|
|
|
|
it('00 00 02 00 (CUR-Cursor-Datei) ergibt null — nur Typ 1 (ICO) wird erkannt', () => {
|
|
expect(detectFavoriteIconMime(bytes([0x00, 0x00, 0x02, 0x00, 0x01, 0x00]))).toBeNull();
|
|
});
|
|
|
|
it('gueltige SVG-Formen ergeben image/svg+xml', () => {
|
|
expect(detectFavoriteIconMime(bytes('<svg xmlns="http://www.w3.org/2000/svg"></svg>'))).toBe(
|
|
'image/svg+xml',
|
|
);
|
|
expect(
|
|
detectFavoriteIconMime(bytes('<?xml version="1.0"?>\n<svg xmlns="http://www.w3.org/2000/svg"/>')),
|
|
).toBe('image/svg+xml');
|
|
// fuehrendes BOM
|
|
expect(
|
|
detectFavoriteIconMime(bytes([0xef, 0xbb, 0xbf], '<svg xmlns="http://www.w3.org/2000/svg"></svg>')),
|
|
).toBe('image/svg+xml');
|
|
// fuehrendes Leerzeichen
|
|
expect(detectFavoriteIconMime(bytes(' <svg></svg>'))).toBe('image/svg+xml');
|
|
// Kommentar vor <svg
|
|
expect(
|
|
detectFavoriteIconMime(bytes('<!-- Kommentar -->\n<svg xmlns="http://www.w3.org/2000/svg"></svg>')),
|
|
).toBe('image/svg+xml');
|
|
// DOCTYPE svg vor <svg
|
|
expect(
|
|
detectFavoriteIconMime(
|
|
bytes(
|
|
'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n<svg></svg>',
|
|
),
|
|
),
|
|
).toBe('image/svg+xml');
|
|
});
|
|
|
|
it('ungueltige Formen ergeben null', () => {
|
|
expect(detectFavoriteIconMime(bytes('<html><svg></svg></html>'))).toBeNull();
|
|
expect(detectFavoriteIconMime(bytes('<!DOCTYPE html>\n<html></html>'))).toBeNull();
|
|
expect(detectFavoriteIconMime(new Uint8Array(0))).toBeNull();
|
|
expect(detectFavoriteIconMime(bytes('Dies ist keine Bilddatei, sondern Text.'))).toBeNull();
|
|
expect(detectFavoriteIconMime(bytes('%PDF-1.7\n%\xe2\xe3'))).toBeNull();
|
|
expect(detectFavoriteIconMime(bytes([0x00, 0x00, 0x02, 0x00, 0x01, 0x00]))).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('favoriteIconExtension (quick-260923-lrr)', () => {
|
|
it('bildet die sechs bekannten Typen ab, unbekannter Typ ergibt null', () => {
|
|
expect(favoriteIconExtension('image/png')).toBe('png');
|
|
expect(favoriteIconExtension('image/jpeg')).toBe('jpg');
|
|
expect(favoriteIconExtension('image/gif')).toBe('gif');
|
|
expect(favoriteIconExtension('image/webp')).toBe('webp');
|
|
expect(favoriteIconExtension('image/x-icon')).toBe('ico');
|
|
expect(favoriteIconExtension('image/svg+xml')).toBe('svg');
|
|
expect(favoriteIconExtension('application/pdf')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('resolveFavoriteIconsDir / favoriteIconAbsolutePath (quick-260923-lrr)', () => {
|
|
let dir: string;
|
|
const ORIGINAL_ENV = process.env.FAVORITE_ICONS_DIR;
|
|
|
|
beforeAll(() => {
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-'));
|
|
process.env.FAVORITE_ICONS_DIR = dir;
|
|
});
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
if (ORIGINAL_ENV === undefined) {
|
|
delete process.env.FAVORITE_ICONS_DIR;
|
|
} else {
|
|
process.env.FAVORITE_ICONS_DIR = ORIGINAL_ENV;
|
|
}
|
|
});
|
|
|
|
it('resolveFavoriteIconsDir beachtet FAVORITE_ICONS_DIR', () => {
|
|
expect(resolveFavoriteIconsDir()).toBe(path.resolve(dir));
|
|
});
|
|
|
|
it('liegt unter resolveFavoriteIconsDir()/<userId>/<id>.<ext>', () => {
|
|
const result = favoriteIconAbsolutePath('user-1', 'fav-1', 'image/png');
|
|
expect(result).toBe(path.join(resolveFavoriteIconsDir(), 'user-1', 'fav-1.png'));
|
|
});
|
|
|
|
it('unbekannter Typ ergibt null', () => {
|
|
expect(favoriteIconAbsolutePath('user-1', 'fav-1', 'application/pdf')).toBeNull();
|
|
});
|
|
|
|
it('Segmente mit .., /, \\ oder leer ergeben null', () => {
|
|
expect(favoriteIconAbsolutePath('..', 'fav-1', 'image/png')).toBeNull();
|
|
expect(favoriteIconAbsolutePath('user-1', '../etc/passwd', 'image/png')).toBeNull();
|
|
expect(favoriteIconAbsolutePath('a/b', 'fav-1', 'image/png')).toBeNull();
|
|
expect(favoriteIconAbsolutePath('a\\b', 'fav-1', 'image/png')).toBeNull();
|
|
expect(favoriteIconAbsolutePath('', 'fav-1', 'image/png')).toBeNull();
|
|
expect(favoriteIconAbsolutePath('user-1', '', 'image/png')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('removeFavoriteIconFileBestEffort (quick-260923-lrr, T-LRR-07)', () => {
|
|
let dir: string;
|
|
const ORIGINAL_ENV = process.env.FAVORITE_ICONS_DIR;
|
|
|
|
beforeAll(() => {
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-rm-'));
|
|
process.env.FAVORITE_ICONS_DIR = dir;
|
|
});
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
if (ORIGINAL_ENV === undefined) {
|
|
delete process.env.FAVORITE_ICONS_DIR;
|
|
} else {
|
|
process.env.FAVORITE_ICONS_DIR = ORIGINAL_ENV;
|
|
}
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(path.join(dir, 'user-1'), { recursive: true, force: true });
|
|
});
|
|
|
|
it('entfernt eine vorhandene Datei und liefert true', async () => {
|
|
const absolute = favoriteIconAbsolutePath('user-1', 'fav-1', 'image/png');
|
|
expect(absolute).not.toBeNull();
|
|
fs.mkdirSync(path.dirname(absolute as string), { recursive: true });
|
|
fs.writeFileSync(absolute as string, Buffer.from([1, 2, 3]));
|
|
|
|
const result = await removeFavoriteIconFileBestEffort('user-1', 'fav-1', 'image/png');
|
|
|
|
expect(result).toBe(true);
|
|
expect(fs.existsSync(absolute as string)).toBe(false);
|
|
});
|
|
|
|
it('fehlende Datei -> false, wirft nicht', async () => {
|
|
await expect(removeFavoriteIconFileBestEffort('user-1', 'fehlt', 'image/png')).resolves.toBe(false);
|
|
});
|
|
|
|
it('unbekannter Typ -> false, wirft nicht', async () => {
|
|
await expect(
|
|
removeFavoriteIconFileBestEffort('user-1', 'fav-1', 'application/pdf'),
|
|
).resolves.toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Grenzen (quick-260923-lrr)', () => {
|
|
it('FAVORITE_ICON_MAX_BYTES ist 512 KiB', () => {
|
|
expect(FAVORITE_ICON_MAX_BYTES).toBe(512 * 1024);
|
|
});
|
|
});
|