feat(api): GET /desktop/update — signierte Desktop-Pakete im Format des Tauri-Updaters ausliefern
- Neuer oeffentlicher Endpunkt (vor download/:platform): base-Origin wird
per safeOrigin validiert (nur http/https, kein Pfad/Query/Fragment/
Userinfo, sonst 400) und nur zum Bau der absoluten Download-URL genutzt,
nie serverseitig abgerufen
- 204 ohne Body bei fremder Plattform/Architektur, fehlendem Manifest,
fehlender Signatur oder fehlendem/ungueltigem updateVersion; sonst
{ version, pub_date (nur RFC 3339), url, signature, notes }
- Manifest-Felder signature (je Plattform) und updateVersion optional in
@tessera/shared, Eintragspruefung akzeptiert nur String-Signaturen
- 10 neue Spec-Tests, Test 11 erweitert (23 gesamt); latest/download
unveraendert
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@ 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';
|
||||
import { DesktopService, safeOrigin } from './desktop.service';
|
||||
|
||||
/**
|
||||
* DesktopService/DesktopController.spec — HTTP-Durchstich ueber
|
||||
@@ -34,7 +34,23 @@ const PACKAGE_NAME = 'test-package.bin';
|
||||
let packageSize: number;
|
||||
let packageSha256: string;
|
||||
|
||||
function writeManifest(files: Record<string, { name: string; size: number; sha256: 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({
|
||||
@@ -42,11 +58,29 @@ function writeManifest(files: Record<string, { name: string; size: number; sha25
|
||||
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);
|
||||
@@ -211,8 +245,139 @@ describe('DesktopService/DesktopController — HTTP-Durchstich (Phase 18)', () =
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('Test 11 (bewusst oeffentlich): getLatest und download tragen @Public()', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user