Files
toolbox-portal/scripts/deployment.test.ts
T

333 lines
12 KiB
TypeScript

import { readFileSync } from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
const projectRoot = process.cwd();
const compose = readFileSync(path.join(projectRoot, 'compose.yaml'), 'utf8');
const composeExample = readFileSync(
path.join(projectRoot, 'compose.example.yaml'),
'utf8'
);
const containerfile = readFileSync(
path.join(projectRoot, 'Containerfile.release'),
'utf8'
);
const environmentExample = readFileSync(
path.join(projectRoot, '.env.example'),
'utf8'
);
const nginxConfig = readFileSync(
path.join(projectRoot, 'deploy', 'nginx.conf'),
'utf8'
);
const releaseLock = JSON.parse(
readFileSync(path.join(projectRoot, 'release', 'toolbox.lock.json'), 'utf8')
) as { releaseVersion: string; apps: Array<{ target: string }> };
const developmentCatalogue = JSON.parse(
readFileSync(path.join(projectRoot, 'public', 'toolbox.catalog.json'), 'utf8')
) as { apps: Array<{ manifest: string; enabled: boolean }> };
const expectedAppTargets = [
'pdf',
'xslt',
'onenote',
'av',
'regex',
'svg',
'auth',
'sudoku',
'colour',
'office',
'image',
'file',
'epub',
'archive',
'privacy',
'crypto',
'barcode',
'network',
'geo',
'helper',
'rand',
'data',
'diff',
'time',
'text',
'unicode',
] as const;
const immutableCacheControl = 'public, max-age=31536000, immutable';
const immutableUriPatterns = Array.from(
nginxConfig.matchAll(
new RegExp(
`^\\s*"~([^"]+)"\\s+"${immutableCacheControl.replaceAll(
', ',
',\\s+'
)}";$`,
'gmu'
)
),
(match) => new RegExp(match[1], 'u')
);
const isImmutable = (uri: string): boolean =>
immutableUriPatterns.some((pattern) => pattern.test(uri));
describe('production deployment', () => {
it('keeps the release version and checksum pins synchronized', () => {
const containerVersion = containerfile.match(
/^ARG TOOLBOX_RELEASE_VERSION=(\S+)$/mu
)?.[1];
const composeVersion = compose.match(
/\$\{TOOLBOX_RELEASE_VERSION:-([^}]+)\}/u
)?.[1];
const containerChecksum = containerfile.match(
/^ARG TOOLBOX_RELEASE_SHA256=([a-f0-9]{64})$/mu
)?.[1];
const composeChecksum = compose.match(
/\$\{TOOLBOX_RELEASE_SHA256:-([a-f0-9]{64})\}/u
)?.[1];
const environmentVersion = environmentExample.match(
/^TOOLBOX_RELEASE_VERSION=(\S+)$/mu
)?.[1];
const environmentChecksum = environmentExample.match(
/^TOOLBOX_RELEASE_SHA256=([a-f0-9]{64})$/mu
)?.[1];
const localImageVersion = composeExample.match(
/^\s+image: git\.add-ideas\.de\/lotobo\/toolbox:(\S+)$/mu
)?.[1];
expect(containerVersion).toBe(releaseLock.releaseVersion);
expect(composeVersion).toBe(containerVersion);
expect(containerChecksum).toMatch(/^[a-f0-9]{64}$/u);
expect(composeChecksum).toMatch(/^[a-f0-9]{64}$/u);
expect(environmentChecksum).toMatch(/^[a-f0-9]{64}$/u);
expect(composeChecksum).toBe(containerChecksum);
expect(environmentVersion).toBe(containerVersion);
expect(environmentChecksum).toBe(containerChecksum);
expect(localImageVersion).toBe(containerVersion);
});
it('verifies the release before extraction and retains a rootless final image', () => {
expect(containerfile).toContain("--proto-redir '=https' --tlsv1.2");
expect(containerfile.indexOf('sha256sum -c -')).toBeGreaterThan(0);
expect(containerfile.indexOf('unzip -q')).toBeGreaterThan(
containerfile.indexOf('sha256sum -c -')
);
expect(containerfile).toContain(
'COPY --from=release --chown=101:101 /tmp/toolbox/'
);
for (const app of expectedAppTargets) {
expect(containerfile).toContain(
`test -f /tmp/toolbox/apps/${app}/toolbox-app.json`
);
}
expect(containerfile).toMatch(/\nUSER 101:101\nEXPOSE 8080\n/u);
});
it('keeps the development catalogue aligned with all release targets', () => {
expect(releaseLock.apps.map(({ target }) => target)).toEqual(
expectedAppTargets
);
expect(developmentCatalogue.apps).toEqual(
expectedAppTargets.map((target) => ({
manifest: `./apps/${target}/toolbox-app.json`,
enabled: true,
}))
);
});
it('uses only the requested external Traefik network and redirects HTTP to HTTPS', () => {
expect(compose).toContain('dockerfile: Containerfile.release');
expect(compose).toContain('traefik.enable: "true"');
expect(compose).toContain(
'traefik.docker.network: "${TRAEFIK_NETWORK:-internal}"'
);
expect(compose).toContain(
'traefik.http.routers.addideas-toolbox.rule: "Host(`${TOOLBOX_HOST:-toolbox.add-ideas.de}`)"'
);
expect(compose).toContain(
'traefik.http.routers.addideas-toolbox.entrypoints: websecure'
);
expect(compose).toContain(
'traefik.http.routers.addideas-toolbox.tls.certresolver: "${TRAEFIK_CERT_RESOLVER:-netcup}"'
);
expect(compose).toContain(
'traefik.http.routers.addideas-toolbox-http.rule: "Host(`${TOOLBOX_HOST:-toolbox.add-ideas.de}`)"'
);
expect(compose).toContain(
'traefik.http.routers.addideas-toolbox-http.entrypoints: web'
);
expect(compose).toContain(
'traefik.http.routers.addideas-toolbox-http.priority: 900'
);
expect(compose).toContain(
'traefik.http.routers.addideas-toolbox-http.middlewares: addideas-toolbox-https-redirect'
);
expect(compose).toContain(
'traefik.http.routers.addideas-toolbox-http.service: addideas-toolbox'
);
expect(compose).toContain(
'traefik.http.middlewares.addideas-toolbox-https-redirect.redirectscheme.scheme: https'
);
expect(compose).toContain(
'traefik.http.middlewares.addideas-toolbox-https-redirect.redirectscheme.permanent: "true"'
);
expect(compose).toContain('external: true');
expect(compose).not.toMatch(/^\s+ports:/mu);
});
it('isolates live WebAuthn behind a dedicated hostname and path prefix', () => {
expect(environmentExample).toContain(
'AUTH_TOOLS_HOST=auth.toolbox.add-ideas.de'
);
expect(compose).toContain(
'traefik.http.routers.addideas-auth.rule: "Host(`${AUTH_TOOLS_HOST:-auth.toolbox.add-ideas.de}`)"'
);
expect(compose).toContain(
'traefik.http.routers.addideas-auth.entrypoints: websecure'
);
expect(compose).toContain(
'traefik.http.routers.addideas-auth.tls.certresolver: "${TRAEFIK_CERT_RESOLVER:-netcup}"'
);
expect(compose).toContain(
'traefik.http.routers.addideas-auth.middlewares: addideas-auth-prefix'
);
expect(compose).toContain(
'traefik.http.middlewares.addideas-auth-prefix.addprefix.prefix: /apps/auth'
);
expect(compose).toContain(
'traefik.http.routers.addideas-auth-http.middlewares: addideas-toolbox-https-redirect'
);
expect(compose).not.toContain('addideas-auth-stripprefix');
});
it('grants camera only to the isolated authentication host and barcode path', () => {
expect(nginxConfig).toMatch(
/map "\$host:\$uri" \$toolbox_permissions_policy\s*\{\s*default "camera=\(\), microphone=\(\), geolocation=\(\), payment=\(\), usb=\(\)";\s*"~\^auth\\\.toolbox\\\.add-ideas\\\.de:" "camera=\(self\), microphone=\(\), geolocation=\(\), payment=\(\), usb=\(\)";\s*"~\^\[\^:\]\+:\/apps\/barcode\(\?:\/\|\$\)" "camera=\(self\), microphone=\(\), geolocation=\(\), payment=\(\), usb=\(\)";\s*\}/mu
);
expect(nginxConfig).toContain(
'add_header Permissions-Policy $toolbox_permissions_policy always;'
);
expect(
nginxConfig.match(/^\s*add_header Permissions-Policy /gmu)
).toHaveLength(1);
});
it('allows RANDOM.ORG only for the explicitly opt-in Random Tools path', () => {
expect(nginxConfig).toMatch(
/map \$uri \$toolbox_connect_sources\s*\{\s*default "'self'";\s*"~\^\/apps\/rand\(\?:\/\|\$\)" "'self' https:\/\/www\.random\.org";\s*\}/mu
);
expect(nginxConfig).toMatch(
/^\s*add_header Content-Security-Policy "[^"]*connect-src \$toolbox_connect_sources;[^"]*" always;$/mu
);
expect(nginxConfig.match(/https:\/\/www\.random\.org/gu)).toHaveLength(1);
});
it('quotes nginx regular expressions that contain brace quantifiers', () => {
expect(nginxConfig).toContain(
'"~^/(?:.*/)?assets/.*-[A-Za-z0-9_-]{8,}\\.[^/]+$"'
);
expect(nginxConfig).not.toMatch(/^\s*~[^\n]*\{\d/mu);
});
it('caches only hashed assets and the versioned FFmpeg runtime immutably', () => {
expect(immutableUriPatterns).toHaveLength(2);
for (const uri of [
'/assets/index-Bp9nK_3a.js',
'/apps/av/vendor/ffmpeg/0.12.10/version.json',
'/apps/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.js',
'/apps/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm',
'/apps/av/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js',
'/apps/av/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm',
'/apps/av/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js',
'/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/version.json',
'/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js',
'/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm',
'/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js',
'/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm',
'/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js',
]) {
expect(isImmutable(uri), uri).toBe(true);
}
for (const uri of [
'/',
'/index.html',
'/SOURCE.md',
'/LICENSE',
'/toolbox.catalog.json',
'/toolbox.release.json',
'/apps/av/toolbox-app.json',
'/apps/av/THIRD_PARTY_NOTICES.md',
'/apps/av/vendor/ffmpeg/version.json',
'/apps/av/vendor/ffmpeg/latest/st/ffmpeg-core.wasm',
'/apps/av/vendor/ffmpeg/0.12/st/ffmpeg-core.wasm',
'/apps/av/vendor/ffmpeg/01.2.3/st/ffmpeg-core.wasm',
'/apps/av/vendor/ffmpeg/0.12.10/st/other.wasm',
'/apps/av/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.wasm',
'/apps/av/vendor/ffmpeg/0.12.10/ffmpeg-core.wasm',
'/apps/av/vendor/ffmpeg/0.12.10-/st/ffmpeg-core.wasm',
'/apps/av/vendor/ffmpeg/0.12.10-reviewed_/st/ffmpeg-core.wasm',
'/apps/av/vendor/ffmpeg/0.12.10-latest/st/ffmpeg-core.wasm',
'/apps/av/vendor/ffmpeg/0.12.10-SNAPSHOT/st/ffmpeg-core.wasm',
'/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.2/st/ffmpeg-core.wasm',
]) {
expect(isImmutable(uri), uri).toBe(false);
}
});
it('serves WebAssembly through an explicit MIME-mapped location', () => {
expect(nginxConfig).toMatch(
/location ~\* \\\.wasm\$\s*\{\s*types\s*\{\s*application\/wasm wasm;\s*\}\s*try_files \$uri =404;\s*\}/mu
);
expect(nginxConfig).not.toMatch(
/location ~\* [^\n]*\(\?:[^\n|)]*\bwasm\b[^\n)]*\)/u
);
});
it('serves ES modules as JavaScript before the broad static location', () => {
const moduleLocation =
/location ~\* \\\.mjs\$\s*\{\s*types\s*\{\s*application\/javascript mjs;\s*\}\s*try_files \$uri =404;\s*\}/mu;
const moduleLocationIndex = nginxConfig.search(moduleLocation);
const staticLocationIndex = nginxConfig.search(
/location ~\* \\\.\(\?:css\|js\|mjs\|/u
);
expect(moduleLocationIndex).toBeGreaterThanOrEqual(0);
expect(staticLocationIndex).toBeGreaterThan(moduleLocationIndex);
});
it('allows same-origin WebAssembly workers and local blob media previews', () => {
expect(nginxConfig).toContain("script-src 'self' 'wasm-unsafe-eval'");
expect(nginxConfig).toContain("worker-src 'self' blob:");
expect(nginxConfig).toContain("media-src 'self' blob:");
expect(nginxConfig).not.toContain("'unsafe-eval'");
});
it('grants only the sandboxed SVG canvas controller cross-origin loading headers', () => {
expect(nginxConfig).toMatch(
/map \$uri \$toolbox_resource_policy\s*\{\s*default "same-origin";\s*"\/apps\/svg\/canvas-frame-controller\.js" "cross-origin";\s*\}/mu
);
expect(nginxConfig).toMatch(
/map \$uri \$toolbox_access_control_allow_origin\s*\{\s*default "";\s*"\/apps\/svg\/canvas-frame-controller\.js" "\*";\s*\}/mu
);
expect(nginxConfig).toContain(
'add_header Cross-Origin-Resource-Policy $toolbox_resource_policy always;'
);
expect(nginxConfig).toContain(
'add_header Access-Control-Allow-Origin $toolbox_access_control_allow_origin always;'
);
expect(nginxConfig).not.toMatch(
/location[^\n{]*canvas-frame-controller[^\n{]*\{/u
);
expect(
nginxConfig.match(/^\s*add_header X-Content-Type-Options /gmu)
).toHaveLength(1);
expect(
nginxConfig.match(/^\s*add_header Content-Security-Policy /gmu)
).toHaveLength(1);
});
});