// @ts-expect-error -- bun:test is a runtime built-in with no types installed import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { mkdtempSync, writeFileSync, readFileSync, existsSync, utimesSync, statSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { isValidSelfhstRef, selfhstCachePath, sanitizeSvg, getSelfhstIcon } from '../src/lib/server/selfhst-icons'; import { looksLikeImage } from '../src/lib/server/stack-icons'; describe('isValidSelfhstRef', () => { test('accepts normal references', () => { for (const r of ['plex', 'jellyfin', 'home-assistant', 'pihole', 'nextcloud', '2fauth']) { expect(isValidSelfhstRef(r)).toBe(true); } }); test('rejects path traversal and junk', () => { for (const r of ['../etc/passwd', 'a/b', 'foo.svg', 'Foo', 'a_b', 'a b', '', '..', './x', 'x/../y']) { expect(isValidSelfhstRef(r)).toBe(false); } }); test('rejects an over-long ref', () => { expect(isValidSelfhstRef('a'.repeat(65))).toBe(false); }); test('selfhstCachePath throws on an invalid ref (never builds a path from junk)', () => { expect(() => selfhstCachePath('../../etc/passwd')).toThrow(); expect(() => selfhstCachePath('ok-ref')).not.toThrow(); }); test('selfhstCachePath keeps the ref inside the cache dir', () => { const p = selfhstCachePath('plex'); expect(p.endsWith('/icon-cache/selfhst/plex.svg')).toBe(true); }); }); describe('sanitizeSvg (SVG-XSS defense)', () => { test('strips '); expect(out).not.toContain(' { const out = sanitizeSvg(''); expect(out).not.toMatch(/onload/i); expect(out).not.toMatch(/onclick/i); }); test('strips (can embed HTML)', () => { const out = sanitizeSvg(''); expect(out).not.toMatch(/foreignObject/i); }); test('strips javascript: URLs', () => { const out = sanitizeSvg('x'); expect(out.toLowerCase()).not.toContain('javascript:'); }); test('leaves a clean logo SVG intact', () => { const clean = ''; expect(sanitizeSvg(clean)).toBe(clean); }); }); describe('looksLikeImage (upload magic-byte check)', () => { test('accepts real image magic bytes', () => { expect(looksLikeImage(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 0]))).toBe(true); // PNG expect(looksLikeImage(Buffer.from([0xff, 0xd8, 0xff, 0xe0]))).toBe(true); // JPEG expect(looksLikeImage(Buffer.from('GIF89a'))).toBe(true); expect(looksLikeImage(Buffer.from('RIFF\0\0\0\0WEBP'))).toBe(true); expect(looksLikeImage(Buffer.from(''))).toBe(true); }); test('rejects arbitrary / non-image bytes', () => { expect(looksLikeImage(Buffer.from('#!/bin/sh\nrm -rf /'))).toBe(false); expect(looksLikeImage(Buffer.from([0x00, 0x01, 0x02, 0x03]))).toBe(false); expect(looksLikeImage(Buffer.from('MZ\x90\x00'))).toBe(false); // PE/exe expect(looksLikeImage(Buffer.alloc(0))).toBe(false); }); }); // getSelfhstIcon's cache-HIT paths are deterministic (no network). We seed the on-disk // cache under a temp DATA_DIR and assert what the cache read returns without ever // reaching the CDN. Stale/corrupt entries fall through to a fetch, which fails offline // and returns null - that null still proves the bad file was NOT served. describe('getSelfhstIcon cache-hit handling', () => { let dir: string; beforeAll(() => { dir = mkdtempSync(join(tmpdir(), 'selfhst-cache-')); process.env.DATA_DIR = dir; }); afterAll(() => { delete process.env.DATA_DIR; rmSync(dir, { recursive: true, force: true }); }); test('serves a valid cached SVG without refetching', async () => { const p = selfhstCachePath('plex'); writeFileSync(p, 'ok'); const buf = await getSelfhstIcon('plex'); expect(buf?.toString()).toBe('ok'); }); test('a fresh zero-byte tombstone returns null (no refetch)', async () => { const p = selfhstCachePath('jellyfin'); writeFileSync(p, Buffer.alloc(0)); // mtime = now -> within TTL expect(await getSelfhstIcon('jellyfin')).toBeNull(); expect(existsSync(p)).toBe(true); // tombstone kept }); // A ref that can NEVER exist upstream (valid syntactically, but no such icon) so the // fall-through fetch deterministically returns a 404/miss regardless of network. const MISSING = 'zzz-nonexistent-test-icon'; test('a corrupt (nonzero non-SVG) cache file is never served (dropped, refetched)', async () => { const p = selfhstCachePath(MISSING); writeFileSync(p, Buffer.from([0x00, 0x01, 0x02])); // binary, no leading '<' const buf = await getSelfhstIcon(MISSING); // Whatever happens, the corrupt bytes are NOT returned. Offline/404 -> null; the file // is either gone or replaced by a 0-byte tombstone, never the original garbage. expect(buf).toBeNull(); if (existsSync(p)) { expect(readFileSync(p).length).toBe(0); // tombstone, not the corrupt content } }); test('a stale tombstone (older than the TTL) is not treated as a live miss', async () => { const p = selfhstCachePath(MISSING); writeFileSync(p, Buffer.alloc(0)); const old = Date.now() / 1000 - 3 * 24 * 60 * 60; // 3 days ago (TTL is 1 day) utimesSync(p, old, old); const before = statSync(p).mtimeMs; await getSelfhstIcon(MISSING); // stale -> refetch (misses) -> fresh tombstone // The stale tombstone was acted on (dropped then rewritten), i.e. its mtime advanced - // proving it was NOT returned as a still-valid negative-cache hit. if (existsSync(p)) expect(statSync(p).mtimeMs).toBeGreaterThan(before); }); });