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:
@@ -7,6 +7,7 @@ import { BugReportsModule } from './bug-reports/bug-reports.module';
|
||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from './auth/guards/roles.guard';
|
||||
import { ForcePasswordChangeInterceptor } from './auth/interceptors/force-password-change.interceptor';
|
||||
import { DesktopModule } from './desktop/desktop.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { LdapModule } from './ldap/ldap.module';
|
||||
import { MailModule } from './mail/mail.module';
|
||||
@@ -36,6 +37,7 @@ import { UserModule } from './user/user.module';
|
||||
UserModule,
|
||||
TenantModule,
|
||||
HealthModule,
|
||||
DesktopModule,
|
||||
MailModule,
|
||||
LdapModule,
|
||||
ModuleRegistryModule,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Controller, Get, Inject, Param, StreamableFile } from '@nestjs/common';
|
||||
import type { DesktopLatestResponse } from '@tessera/shared';
|
||||
import { Public } from '../auth/decorators/public.decorator';
|
||||
import { DesktopService } from './desktop.service';
|
||||
|
||||
@Controller('desktop')
|
||||
export class DesktopController {
|
||||
// `@Inject()` explizit (nicht nur der Konstruktor-Typ): Vitest transpiliert
|
||||
// ueber esbuild, das `emitDecoratorMetadata` nicht respektiert -- ohne den
|
||||
// expliziten Token findet Nests DI in diesem einen HTTP-Durchstich-Test
|
||||
// (desktop.service.spec.ts) keinen Provider und `desktopService` bleibt
|
||||
// `undefined`. Im echten Build (tsc via `nest build`) waere das auch ohne
|
||||
// `@Inject()` korrekt aufgeloest worden.
|
||||
constructor(@Inject(DesktopService) private readonly desktopService: DesktopService) {}
|
||||
|
||||
// Bewusst oeffentlich (D-10, gleicher Grund wie HealthController.getVersion,
|
||||
// T-KU1-03): die Anmeldeseite zeigt den Download-Link, bevor eine Anmeldung
|
||||
// existiert.
|
||||
@Public()
|
||||
@Get('latest')
|
||||
getLatest(): DesktopLatestResponse {
|
||||
return this.desktopService.getLatest();
|
||||
}
|
||||
|
||||
// Bewusst oeffentlich (D-10): der Download selbst braucht keine Anmeldung,
|
||||
// gleicher Grund wie getLatest oben.
|
||||
@Public()
|
||||
@Get('download/:platform')
|
||||
download(@Param('platform') platform: string): StreamableFile {
|
||||
const { stream, entry } = this.desktopService.getPackage(platform);
|
||||
return new StreamableFile(stream, {
|
||||
type: 'application/octet-stream',
|
||||
disposition: `attachment; filename="${entry.name}"`,
|
||||
length: entry.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DesktopController } from './desktop.controller';
|
||||
import { DesktopService } from './desktop.service';
|
||||
|
||||
@Module({
|
||||
controllers: [DesktopController],
|
||||
providers: [DesktopService],
|
||||
})
|
||||
export class DesktopModule {}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import type {
|
||||
DesktopLatestResponse,
|
||||
DesktopManifest,
|
||||
DesktopManifestFile,
|
||||
DesktopPlatform,
|
||||
} from '@tessera/shared';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Wertevorrat der Plattformen (Phase 18, D-10). Geschlossen -- eine dritte
|
||||
* Plattform waere eine bewusste Erweiterung hier UND am Typ `DesktopPlatform`
|
||||
* in packages/shared/src/index.ts.
|
||||
*/
|
||||
const PLATFORMS = ['windows', 'linux'] as const;
|
||||
|
||||
@Injectable()
|
||||
export class DesktopService {
|
||||
private readonly logger = new Logger(DesktopService.name);
|
||||
|
||||
/** Resolved path to desktop-dist/ (monorepo root, or /app/desktop-dist im Abbild). */
|
||||
private readonly desktopDistDir: string;
|
||||
|
||||
constructor() {
|
||||
const envDir = process.env.DESKTOP_DIST_DIR?.trim();
|
||||
this.desktopDistDir =
|
||||
envDir && envDir.length > 0
|
||||
? envDir
|
||||
: // __dirname at runtime = apps/api/dist/desktop/ -- go up 4 levels to monorepo root
|
||||
path.resolve(__dirname, '..', '..', '..', '..', 'desktop-dist');
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest manifest.json. Gibt `null` zurueck (nie werfen) wenn die Datei
|
||||
* fehlt, nicht parsebar ist, oder die Grundform nicht stimmt (version kein
|
||||
* String, files kein Objekt) -- D-10: "fehlt das Verzeichnis/Manifest: 404
|
||||
* mit klarer Meldung".
|
||||
*/
|
||||
getManifest(): DesktopManifest | null {
|
||||
const manifestPath = path.join(this.desktopDistDir, 'manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = fs.readFileSync(manifestPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as DesktopManifest;
|
||||
if (typeof parsed.version !== 'string' || typeof parsed.files !== 'object' || parsed.files === null) {
|
||||
this.logger.warn(`manifest.json unter ${manifestPath} hat unerwartete Form`);
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
this.logger.warn(`manifest.json unter ${manifestPath} konnte nicht gelesen werden: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /desktop/latest` (D-10): Kopf-Felder aus dem Manifest, je
|
||||
* vorhandener Plattform eine relative Download-URL ergaenzt (Client stellt
|
||||
* die API-Basis davor, siehe Objective-Abschnitt "Vom Client aus ...").
|
||||
*/
|
||||
getLatest(): DesktopLatestResponse {
|
||||
const manifest = this.getManifest();
|
||||
if (!manifest) {
|
||||
throw new NotFoundException('Desktop packages are not available on this server');
|
||||
}
|
||||
const files: DesktopLatestResponse['files'] = {};
|
||||
for (const platform of PLATFORMS) {
|
||||
const entry = manifest.files[platform];
|
||||
if (entry) {
|
||||
files[platform] = { ...entry, url: `/desktop/download/${platform}` };
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: manifest.version,
|
||||
channel: manifest.channel,
|
||||
commit: manifest.commit,
|
||||
buildTime: manifest.buildTime,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /desktop/download/:platform` (D-10, T-18-01): Reihenfolge ist die
|
||||
* Sicherheitseigenschaft -- Whitelist VOR jedem Dateisystemzugriff, der
|
||||
* Dateiname kommt ausschliesslich aus manifest.json, nie aus der Anfrage.
|
||||
*/
|
||||
getPackage(platform: string): { stream: fs.ReadStream; entry: DesktopManifestFile } {
|
||||
// (1) Whitelist -- vor jedem Dateisystemzugriff.
|
||||
if (!PLATFORMS.includes(platform as DesktopPlatform)) {
|
||||
throw new BadRequestException('Unknown platform');
|
||||
}
|
||||
const knownPlatform = platform as DesktopPlatform;
|
||||
|
||||
// (2) Manifest holen.
|
||||
const manifest = this.getManifest();
|
||||
if (!manifest) {
|
||||
throw new NotFoundException('Desktop packages are not available on this server');
|
||||
}
|
||||
|
||||
// (3) Eintrag fuer diese Plattform muss existieren.
|
||||
const entry = manifest.files[knownPlatform];
|
||||
if (!entry) {
|
||||
throw new NotFoundException(`No package for platform: ${knownPlatform}`);
|
||||
}
|
||||
|
||||
// (4) Verteidigung in der Tiefe (T-18-02): auch ein manipuliertes
|
||||
// Manifest darf nicht aus dem Ordner hinausfuehren.
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(entry.name)) {
|
||||
throw new NotFoundException(`No package for platform: ${knownPlatform}`);
|
||||
}
|
||||
|
||||
// (5) Datei muss existieren.
|
||||
const filePath = path.join(this.desktopDistDir, entry.name);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new NotFoundException(`Package file missing: ${entry.name}`);
|
||||
}
|
||||
|
||||
// (6) Stream zurueckgeben -- kein Puffern der ganzen Datei (Installer
|
||||
// sind deutlich groesser als DKV-Exporte).
|
||||
return { stream: fs.createReadStream(filePath), entry };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user