import 'reflect-metadata'; import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { NestFactory } from '@nestjs/core'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; import { IS_PUBLIC_KEY } from '../auth/decorators/public.decorator'; import { DesktopController } from './desktop.controller'; import { DesktopModule } from './desktop.module'; import { DesktopService, safeOrigin } from './desktop.service'; /** * DesktopService/DesktopController.spec — HTTP-Durchstich ueber * NestFactory (Phase 18, Task 1). Kein `fs`-Mock: ein echtes * Temp-Verzeichnis mit einer kleinen Zufallsdatei und einem von Hand * geschriebenen manifest.json, dessen sha256 unabhaengig ueber * crypto.createHash berechnet wird -- der Pruefling erzeugt den * Erwartungswert nicht selbst. * * `DesktopService.getManifest()` liest manifest.json bei JEDEM Aufruf neu * (kein Cache) -- writeManifest() darf die Datei deshalb zwischen Tests * ueberschreiben, ohne den laufenden HTTP-Server neu zu starten. */ const ORIGINAL_ENV = process.env.DESKTOP_DIST_DIR; let tempDir: string; let app: Awaited>; let baseUrl: string; const PACKAGE_NAME = 'test-package.bin'; let packageSize: number; let packageSha256: string; /** Origin des "eigenen" Servers, wie ihn der Desktop-Client als `base` mitschickt. */ const ORIGIN = 'https://tessera.example.com'; /** Beliebige Base64-Zeile -- die API reicht die Signatur nur durch, prueft sie nicht. */ const SIG = 'dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZQo='; type ManifestHead = { version?: string; channel?: string; commit?: string; buildTime?: string; updateVersion?: string; }; function writeManifest( files: Record, head: ManifestHead = {}, ) { fs.writeFileSync( path.join(tempDir, 'manifest.json'), JSON.stringify({ version: '1.1.0', channel: 'dev', commit: 'abc1234', buildTime: '2026-09-16T00:00:00Z', ...head, files, }), ); } /** Standard-Eintrag fuer linux, optional signiert. */ function linuxEntry(signature?: string) { return { linux: { name: PACKAGE_NAME, size: packageSize, sha256: packageSha256, ...(signature === undefined ? {} : { signature }), }, }; } function updateUrl(query: Record) { const params = new URLSearchParams(query); return `${baseUrl}/desktop/update?${params.toString()}`; } beforeAll(async () => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-desktop-')); const packageBytes = crypto.randomBytes(64 * 1024); fs.writeFileSync(path.join(tempDir, PACKAGE_NAME), packageBytes); packageSize = packageBytes.length; packageSha256 = crypto.createHash('sha256').update(packageBytes).digest('hex'); writeManifest({ linux: { name: PACKAGE_NAME, size: packageSize, sha256: packageSha256 } }); process.env.DESKTOP_DIST_DIR = tempDir; app = await NestFactory.create(DesktopModule, { logger: false }); await app.listen(0); const address = app.getHttpServer().address(); const port = typeof address === 'object' && address ? address.port : 0; baseUrl = `http://127.0.0.1:${port}`; }); afterAll(async () => { await app.close(); fs.rmSync(tempDir, { recursive: true, force: true }); if (ORIGINAL_ENV === undefined) { delete process.env.DESKTOP_DIST_DIR; } else { process.env.DESKTOP_DIST_DIR = ORIGINAL_ENV; } }); afterEach(() => { // Default-Manifest fuer den naechsten Test wiederherstellen (Tests 6/7 // ueberschreiben es bewusst mit einer anderen Form). writeManifest({ linux: { name: PACKAGE_NAME, size: packageSize, sha256: packageSha256 } }); }); describe('DesktopService/DesktopController — HTTP-Durchstich (Phase 18)', () => { it('Test 1 (latest, Manifest vorhanden): 200 mit Kopf-Feldern und relativer Download-URL', async () => { const res = await fetch(`${baseUrl}/desktop/latest`); expect(res.status).toBe(200); const body = await res.json(); expect(body).toEqual({ version: '1.1.0', channel: 'dev', commit: 'abc1234', buildTime: '2026-09-16T00:00:00Z', files: { linux: { name: PACKAGE_NAME, size: packageSize, sha256: packageSha256, url: '/desktop/download/linux', }, }, }); }); it('Test 2 (getLatest ohne Manifest): eigene Instanz mit leerem Temp-Verzeichnis wirft NotFoundException', () => { const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-desktop-empty-')); const previous = process.env.DESKTOP_DIST_DIR; process.env.DESKTOP_DIST_DIR = emptyDir; try { const service = new DesktopService(); expect(() => service.getLatest()).toThrow(NotFoundException); } finally { process.env.DESKTOP_DIST_DIR = previous; fs.rmSync(emptyDir, { recursive: true, force: true }); } }); it('Test 3 (download/linux): 200, attachment-Header und Body-Hash stimmen mit dem Manifest ueberein', async () => { const res = await fetch(`${baseUrl}/desktop/download/linux`); expect(res.status).toBe(200); expect(res.headers.get('content-disposition')).toBe(`attachment; filename="${PACKAGE_NAME}"`); expect(res.headers.get('content-type')).toBe('application/octet-stream'); expect(res.headers.get('content-length')).toBe(String(packageSize)); const buffer = Buffer.from(await res.arrayBuffer()); const hash = crypto.createHash('sha256').update(buffer).digest('hex'); expect(hash).toBe(packageSha256); }); it('Test 4 (Plattform-Whitelist + Traversal ueber HTTP): mac und ..%2F..%2Fetc%2Fpasswd enden mit 400', async () => { const resMac = await fetch(`${baseUrl}/desktop/download/mac`); expect(resMac.status).toBe(400); const resTraversal = await fetch(`${baseUrl}/desktop/download/..%2F..%2Fetc%2Fpasswd`); expect(resTraversal.status).toBe(400); }); it('Test 5 (Whitelist vor Dateisystem): nicht existierendes Verzeichnis + mac wirft BadRequestException, nicht NotFoundException', () => { const missingDir = path.join(os.tmpdir(), `tessera-desktop-does-not-exist-${Date.now()}`); const previous = process.env.DESKTOP_DIST_DIR; process.env.DESKTOP_DIST_DIR = missingDir; try { const service = new DesktopService(); expect(() => service.getPackage('mac')).toThrow(BadRequestException); } finally { process.env.DESKTOP_DIST_DIR = previous; } }); it('Test 6 (Manifest nur mit windows): download/linux endet mit 404', async () => { writeManifest({ windows: { name: 'Tessera-Setup-1.1.0.exe', size: 123, sha256: 'a'.repeat(64) }, }); const res = await fetch(`${baseUrl}/desktop/download/linux`); expect(res.status).toBe(404); }); it('Test 7 (manipulierter Name im Manifest): "../x.AppImage" endet mit 404', async () => { writeManifest({ linux: { name: '../x.AppImage', size: 123, sha256: 'a'.repeat(64) }, }); const res = await fetch(`${baseUrl}/desktop/download/linux`); expect(res.status).toBe(404); }); it('Test 7a (dot-only Name im Manifest, CR-01): ".." endet mit 404', async () => { writeManifest({ linux: { name: '..', size: 123, sha256: 'a'.repeat(64) }, }); const res = await fetch(`${baseUrl}/desktop/download/linux`); expect(res.status).toBe(404); }); it('Test 7b (dot-only Name im Manifest, CR-01): "." endet mit 404', async () => { writeManifest({ linux: { name: '.', size: 123, sha256: 'a'.repeat(64) }, }); const res = await fetch(`${baseUrl}/desktop/download/linux`); expect(res.status).toBe(404); }); it('Test 7c (Name mit Traversal-Segment ausserhalb desktopDistDir, CR-01): "../manifest.json" endet mit 404', async () => { writeManifest({ linux: { name: '../manifest.json', size: 123, sha256: 'a'.repeat(64) }, }); const res = await fetch(`${baseUrl}/desktop/download/linux`); expect(res.status).toBe(404); }); it('Test 9 (WR-03, ungueltiger Eintrag im Manifest): name fehlt -- getLatest wirft NotFoundException statt "undefined" als Datei zu suchen', () => { // Bewusst am `writeManifest()`-Helper vorbei direkt geschrieben -- dessen // Parametertyp verlangt `name`, hier soll aber genau dessen Fehlen // geprueft werden (kaputtes Manifest, kein TS-Typfehler im Test). fs.writeFileSync( path.join(tempDir, 'manifest.json'), JSON.stringify({ version: '1.1.0', channel: 'dev', commit: 'abc1234', buildTime: '2026-09-16T00:00:00Z', files: { linux: { size: 123, sha256: 'a'.repeat(64) } }, }), ); const service = new DesktopService(); expect(() => service.getLatest()).toThrow(NotFoundException); }); it('Test 10 (WR-03, ungueltiger Eintrag im Manifest): sha256 ist kein 64-stelliger Hex-String -- 404', async () => { writeManifest({ linux: { name: PACKAGE_NAME, size: packageSize, sha256: 'not-a-hash' }, }); const res = await fetch(`${baseUrl}/desktop/download/linux`); expect(res.status).toBe(404); }); it('Test 11 (bewusst oeffentlich): getLatest, download und update tragen @Public()', () => { expect(Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.getLatest)).toBe(true); expect(Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.download)).toBe(true); expect(Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.update)).toBe(true); }); it('Test 12 (update, beta, signiert): 200 im dynamischen Updater-Format mit absoluter URL aus base', async () => { writeManifest(linuxEntry(SIG), { channel: 'beta', updateVersion: '1.1.0-beta.gabc1234' }); const res = await fetch( updateUrl({ target: 'linux', arch: 'x86_64', current: '1.1.0', base: ORIGIN }), ); expect(res.status).toBe(200); expect(res.headers.get('content-type')).toContain('application/json'); expect(await res.json()).toEqual({ version: '1.1.0-beta.gabc1234', pub_date: '2026-09-16T00:00:00Z', url: `${ORIGIN}/api-proxy/desktop/download/linux`, signature: SIG, notes: 'Tessera 1.1.0-beta.gabc1234', }); }); it('Test 13 (update, live): version und notes tragen die reine X.Y.Z', async () => { writeManifest(linuxEntry(SIG), { channel: 'live', updateVersion: '1.1.0' }); const res = await fetch( updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: ORIGIN }), ); expect(res.status).toBe(200); const body = await res.json(); expect(body.version).toBe('1.1.0'); expect(body.notes).toBe('Tessera 1.1.0'); }); it('Test 14 (base mit Schlussstrich): url wird aus dem Origin ohne Schlussstrich gebildet', async () => { writeManifest(linuxEntry(SIG), { channel: 'live', updateVersion: '1.1.0' }); const res = await fetch( updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: `${ORIGIN}/` }), ); expect(res.status).toBe(200); const body = await res.json(); expect(body.url).toBe(`${ORIGIN}/api-proxy/desktop/download/linux`); }); it('Test 15 (ohne Signatur): Standard-Manifest -> 204 ohne Body', async () => { const res = await fetch( updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: ORIGIN }), ); expect(res.status).toBe(204); expect(await res.text()).toBe(''); }); it('Test 16 (updateVersion fehlt oder ungueltig trotz Signatur): 204', async () => { writeManifest(linuxEntry(SIG)); const resMissing = await fetch( updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: ORIGIN }), ); expect(resMissing.status).toBe(204); writeManifest(linuxEntry(SIG), { channel: 'beta', updateVersion: '1.1.0-beta.abc1234' }); const resInvalid = await fetch( updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: ORIGIN }), ); expect(resInvalid.status).toBe(204); }); it('Test 17 (Plattform/Architektur): darwin, windows ohne Eintrag, aarch64 und fehlendes target -> 204', async () => { writeManifest(linuxEntry(SIG), { channel: 'live', updateVersion: '1.1.0' }); const cases: Record[] = [ { target: 'darwin', arch: 'x86_64', current: '1.0.0', base: ORIGIN }, { target: 'windows', arch: 'x86_64', current: '1.0.0', base: ORIGIN }, { target: 'linux', arch: 'aarch64', current: '1.0.0', base: ORIGIN }, { arch: 'x86_64', current: '1.0.0', base: ORIGIN }, ]; for (const query of cases) { const res = await fetch(updateUrl(query)); expect(res.status, JSON.stringify(query)).toBe(204); } }); it('Test 18 (base-Validierung, HTTP): fehlendes, fremdes oder unreines base -> 400', async () => { writeManifest(linuxEntry(SIG), { channel: 'live', updateVersion: '1.1.0' }); const missing = await fetch(updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0' })); expect(missing.status).toBe(400); const bad = [ 'ftp://host', 'https://user:pw@host', 'https://host/pfad', 'https://host/?x=1', 'https://host/#f', 'kein url', ]; for (const base of bad) { const res = await fetch( updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base }), ); expect(res.status, base).toBe(400); } }); it('Test 19 (safeOrigin direkt): nur reine http(s)-Origins, kleingeschrieben, ohne Schlussstrich', () => { expect(safeOrigin('https://tessera.example.com')).toBe('https://tessera.example.com'); expect(safeOrigin('http://localhost:3000/')).toBe('http://localhost:3000'); expect(safeOrigin('HTTPS://Tessera.Example.com')).toBe('https://tessera.example.com'); expect(safeOrigin(['https://a', 'https://b'])).toBeNull(); expect(safeOrigin(undefined)).toBeNull(); expect(safeOrigin('')).toBeNull(); expect(safeOrigin('https://host/pfad')).toBeNull(); expect(safeOrigin('javascript:alert(1)')).toBeNull(); }); it('Test 20 (Manifest fehlt): update -> 204', async () => { fs.rmSync(path.join(tempDir, 'manifest.json')); const res = await fetch( updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: ORIGIN }), ); expect(res.status).toBe(204); }); it('Test 21 (Manifest-Validierung): signature als Zahl macht den Eintrag ungueltig -- download/linux 404', async () => { // Am `writeManifest()`-Helper vorbei (dessen Typ verlangt einen String). fs.writeFileSync( path.join(tempDir, 'manifest.json'), JSON.stringify({ version: '1.1.0', channel: 'dev', commit: 'abc1234', buildTime: '2026-09-16T00:00:00Z', files: { linux: { name: PACKAGE_NAME, size: packageSize, sha256: packageSha256, signature: 123 }, }, }), ); const res = await fetch(`${baseUrl}/desktop/download/linux`); expect(res.status).toBe(404); }); });