feat(18-01): Task 1 — Linux-Paket bis zum Download aus der API (Durchstich)

Die duenne Strecke Skript -> Abbild -> API beweisen (D-08, D-10):

- desktop-collect.sh sammelt AppImage/exe ein, schreibt manifest.json
  (Version, Kanal, Commit, Groesse, SHA-256) ohne Secrets
- apps/api/src/desktop/: neues Modul mit GET /desktop/latest und
  GET /desktop/download/:platform, beide @Public(); Plattform-Whitelist
  vor jedem Dateisystemzugriff, Dateiname ausschliesslich aus dem
  Manifest (T-18-01, T-18-02)
- packages/shared: DesktopPlatform/-Manifest(File)/-Latest(Response)
  Typen
- Dockerfile kopiert desktop-dist/ in die runner-Stufe; desktop-dist/
  per .gitkeep + .gitignore versioniert (leeres Verzeichnis, Pakete
  bleiben ungetrackt)
- HTTP-Durchstich-Spec (8 Tests) via NestFactory, kein fs-Mock; echtes
  Temp-Verzeichnis + unabhaengig berechneter SHA-256

Abweichung: DesktopController braucht @Inject(DesktopService) explizit
— Vitest transpiliert ueber esbuild, das emitDecoratorMetadata nicht
abbildet, sonst bleibt desktopService bei einem echten NestFactory-Bau
undefined (Rule 3, Blocker).

Lokal bewiesen: neu gebautes API-Abbild liefert /desktop/latest (200)
und /desktop/download/linux (200, attachment) aus, auch ueber
/api-proxy/ des Web-Containers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-16 16:05:55 +02:00
parent 0e4eb9bf9e
commit ae8fecb538
10 changed files with 539 additions and 0 deletions
@@ -0,0 +1,168 @@
import 'reflect-metadata';
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as os from 'os';
import * as path from '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 } 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<ReturnType<typeof NestFactory.create>>;
let baseUrl: string;
const PACKAGE_NAME = 'test-package.bin';
let packageSize: number;
let packageSha256: string;
function writeManifest(files: Record<string, { name: string; size: number; sha256: 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,
}),
);
}
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 8 (bewusst oeffentlich): getLatest und download tragen @Public()', () => {
expect(Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.getLatest)).toBe(true);
expect(Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.download)).toBe(true);
});
});