636fe0df8f
- 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
384 lines
15 KiB
TypeScript
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);
|
|
});
|
|
});
|