Files
tessera-ctl/apps/api/src/desktop/desktop.service.spec.ts
T
schalli 636fe0df8f refactor(quick-260921-bi2): maschinelle Lint-Fixe und toten Code abbauen
- Aufgabe 2: vier sichere Biome-Regeln (useImportType pfadgebunden auf
  apps/web+packages, noUselessEscapeInRegex, useConst,
  useExponentiationOperator) sowie fuenf ungesicherte Regeln
  (useNodejsImportProtocol, useLiteralKeys, useOptionalChain, useTemplate,
  useParseIntRadix) angewendet und den gesamten Diff von Hand gelesen
  (ldap.service.ts zeichenweise gegen Gross-/Kleinschreibung der
  AD-Merkmale, auth.service.ts/jwt.strategy.ts gegen Durchwinken bei
  fehlender Sitzung geprueft)
- noUselessSwitchCase bleibt bewusst stehen (tender-normalizer.service.ts:60,
  die Fallmarke dokumentiert Absicht)
- Toter Code (D-03): fuenf folgenlose Auffangvariablen entfernt, eine
  nicht benutzte Funktion (forSystemQuery, Pruefskript) entfernt, ein
  positionsgebundener Dekoratorparameter umbenannt (current-user.decorator.ts),
  fuenf Symptomfunde entfernt und als Folgeaufgaben zu melden (siehe unten)
- Sechs weitere, im Plan nicht namentlich gelistete aber
  gleich-kategorische Dead-Code-Fundstellen in Testdateien zusaetzlich
  bereinigt (groups.service.spec.ts, cert-manager.test.tsx,
  ldap.service.spec.ts, prisma-tenant.extension.spec.ts x3) — noetig, um
  die vom Plan selbst verlangten Nullstaende bei noUnusedVariables/
  noUnusedImports/noUnusedFunctionParameters zu erreichen

Dekoratordaten aus apps/api unveraendert (593 Zeilen, sha256 6e1583f1...).
Endstand 620 Befunde (541 echt, 79 Test) statt der im Plan geschaetzten
621/542 — eine Differenz von 1, weil das Streichen des Namens aus
`catch (e: any)` in calendar.service.ts (Symptom-Fix) den dort ebenfalls
gemeldeten noExplicitAny-Befund miteliminiert; das ist eine erwuenschte
Nebenwirkung, keine Regression. Fehlerstufe 0, beide Testlaeufe
punktgleich gruen (69/1124, 66/459), pnpm type-check 4/4, pnpm lint
--force 5/5.

Folgeaufgaben aus D-03 (nicht in diesem Vorgang behoben):
- force-password-change.interceptor.ts: Freigabeliste prueft nur den Pfad,
  nicht die HTTP-Methode
- change-password/page.tsx: nach erzwungenem Wechsel bleibt die Person auf
  der Seite stehen (keine Weiterleitung, keine Aktualisierung der
  Benutzerablage)
- VehicleTable.tsx: Loeschschaltflaeche hat keinen Besetztzustand, laesst
  sich doppelt ausloesen
- SplitTab.tsx: downloadAllAsZip erhielt eine ungenutzte
  Uebersetzungsfunktion, Hinweis auf fest verdrahtete Texte im Zip-Pfad

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 08:58:32 +02:00

384 lines
15 KiB
TypeScript

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<ReturnType<typeof NestFactory.create>>;
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<string, { name: string; size: number; sha256: string; signature?: string }>,
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<string, string>) {
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<string, string>[] = [
{ 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);
});
});