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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user