import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { assertProductionBuild, createWebToolsServer, CSP_NONCE_BYTES, MAX_JSON_BODY_SIZE } from '../server/index.mjs' const temporaryDirectories = [] const servers = [] afterEach(async () => { await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(resolve)))) await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))) }) const catalog = { categories: [{ id: 'encoding' }, { id: 'crypto' }], tools: [ { id: 'hex', category: 'encoding' }, { id: 'cmac', category: 'crypto' }, ], } const defaults = { version: 1, categories: [ { id: 'encoding', tools: ['hex'] }, { id: 'crypto', tools: ['cmac'] }, ], } async function createFixtureServer() { const directory = await mkdtemp(path.join(tmpdir(), 'web-tools-server-')) temporaryDirectories.push(directory) const staticRoot = path.join(directory, 'dist') const dataDir = path.join(directory, 'data') await writeFile(path.join(directory, 'placeholder'), '') await mkdir(staticRoot, { recursive: true }) await mkdir(path.join(staticRoot, 'assets'), { recursive: true }) await writeFile(path.join(staticRoot, 'index.html'), 'Web Tools') await writeFile(path.join(staticRoot, 'app.js'), 'console.log("ok")') await writeFile(path.join(staticRoot, 'assets', 'app-ABCD1234.js'), 'console.log("hashed")') await writeFile(path.join(staticRoot, 'assets', 'unversioned.js'), 'console.log("stable")') await writeFile(path.join(staticRoot, 'site.webmanifest'), '{}') const server = await createWebToolsServer({ staticRoot, dataDir, catalog, defaults, logger: { error: () => undefined } }) servers.push(server) await new Promise((resolve, reject) => { server.once('error', reject) server.listen(0, '127.0.0.1', resolve) }) const address = server.address() return { baseUrl: `http://127.0.0.1:${address.port}`, dataDir } } describe('HTTP server', () => { it('fails before listening when the production build is missing', async () => { const directory = await mkdtemp(path.join(tmpdir(), 'web-tools-missing-build-')) temporaryDirectories.push(directory) await expect(assertProductionBuild(path.join(directory, 'dist'))).rejects.toThrow('npm.cmd run build') }) it('serves direct SPA navigation but returns 404 for missing assets', async () => { const { baseUrl } = await createFixtureServer() const page = await fetch(`${baseUrl}/tools/base64`, { headers: { Accept: 'text/html' } }) expect(page.status).toBe(200) expect(await page.text()).toContain('Web Tools') const asset = await fetch(`${baseUrl}/assets/definitely-missing.js`) expect(asset.status).toBe(404) expect(asset.headers.get('content-type')).toContain('text/plain') const extensionlessAsset = await fetch(`${baseUrl}/assets/chunk`, { headers: { Accept: 'application/javascript' } }) expect(extensionlessAsset.status).toBe(404) }) it('uses a unique cryptographic CSP nonce without allowing arbitrary inline scripts', async () => { const { baseUrl } = await createFixtureServer() const first = await fetch(`${baseUrl}/`) const second = await fetch(`${baseUrl}/tools/base64`, { headers: { Accept: 'text/html' } }) const firstPolicy = first.headers.get('content-security-policy') ?? '' const secondPolicy = second.headers.get('content-security-policy') ?? '' const noncePattern = /script-src 'self' 'nonce-([A-Za-z0-9+/]+={0,2})'/ const firstNonce = firstPolicy.match(noncePattern)?.[1] const secondNonce = secondPolicy.match(noncePattern)?.[1] expect(firstNonce).toBeTruthy() expect(secondNonce).toBeTruthy() expect(Buffer.from(firstNonce, 'base64')).toHaveLength(CSP_NONCE_BYTES) expect(Buffer.from(secondNonce, 'base64')).toHaveLength(CSP_NONCE_BYTES) expect(firstNonce).not.toBe(secondNonce) expect(firstPolicy).not.toContain("'unsafe-inline'") expect(firstPolicy).toContain("object-src 'none'") expect(firstPolicy).toContain("frame-ancestors 'none'") }) it('returns no-store JSON health and API responses', async () => { const { baseUrl } = await createFixtureServer() const health = await fetch(`${baseUrl}/healthz`) expect(await health.json()).toEqual({ status: 'ok' }) expect(health.headers.get('cache-control')).toBe('no-store') const missingApi = await fetch(`${baseUrl}/api/missing`) expect(missingApi.status).toBe(404) expect(missingApi.headers.get('cache-control')).toBe('no-store') }) it('serves the Web App Manifest with its registered media type', async () => { const { baseUrl } = await createFixtureServer() const response = await fetch(`${baseUrl}/site.webmanifest`) expect(response.status).toBe(200) expect(response.headers.get('content-type')).toBe('application/manifest+json; charset=utf-8') expect(response.headers.get('cache-control')).toBe('no-cache') }) it('only gives immutable caching to fingerprinted build assets', async () => { const { baseUrl } = await createFixtureServer() const stableAsset = await fetch(`${baseUrl}/app.js`) const nestedStableAsset = await fetch(`${baseUrl}/assets/unversioned.js`) const fingerprintedAsset = await fetch(`${baseUrl}/assets/app-ABCD1234.js`) expect(stableAsset.headers.get('cache-control')).toBe('no-cache') expect(nestedStableAsset.headers.get('cache-control')).toBe('no-cache') expect(fingerprintedAsset.headers.get('cache-control')).toBe('public, max-age=31536000, immutable') }) it('creates, reads and updates navigation through the API', async () => { const { baseUrl, dataDir } = await createFixtureServer() const initial = await fetch(`${baseUrl}/api/navigation`) expect(await initial.json()).toEqual(defaults) const updated = { version: 1, categories: [...defaults.categories].reverse() } const saved = await fetch(`${baseUrl}/api/navigation`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updated), }) expect(saved.status).toBe(200) expect(await saved.json()).toEqual(updated) expect(JSON.parse(await readFile(path.join(dataDir, 'navigation.json'), 'utf8'))).toEqual(updated) }) it('keeps concurrent navigation writes valid and complete', async () => { const { baseUrl, dataDir } = await createFixtureServer() const first = { version: 1, categories: defaults.categories } const second = { version: 1, categories: [...defaults.categories].reverse() } const responses = await Promise.all([first, second].map((navigation) => fetch(`${baseUrl}/api/navigation`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(navigation), }))) expect(responses.map((response) => response.status)).toEqual([200, 200]) const stored = JSON.parse(await readFile(path.join(dataDir, 'navigation.json'), 'utf8')) expect([first, second]).toContainEqual(stored) }) it('normalizes an existing navigation file when a later release adds a tool', async () => { const { baseUrl, dataDir } = await createFixtureServer() const legacy = { version: 1, categories: [{ id: 'encoding', tools: ['hex'] }, { id: 'crypto', tools: ['cmac'] }] } await mkdir(dataDir, { recursive: true }) await writeFile(path.join(dataDir, 'navigation.json'), JSON.stringify(legacy)) const response = await fetch(`${baseUrl}/api/navigation`) expect(await response.json()).toEqual(legacy) const expandedCatalog = { ...catalog, tools: [...catalog.tools, { id: 'base64', category: 'encoding' }] } const expandedDefaults = { ...defaults, categories: [{ id: 'encoding', tools: ['hex', 'base64'] }, ...defaults.categories.slice(1)] } const secondServer = await createWebToolsServer({ staticRoot: path.join(dataDir, 'missing-dist'), dataDir, catalog: expandedCatalog, defaults: expandedDefaults, logger: { error: () => undefined }, }) servers.push(secondServer) await new Promise((resolve, reject) => { secondServer.once('error', reject) secondServer.listen(0, '127.0.0.1', resolve) }) const address = secondServer.address() const upgraded = await fetch(`http://127.0.0.1:${address.port}/api/navigation`) expect(await upgraded.json()).toEqual({ version: 1, categories: [ { id: 'encoding', tools: ['hex', 'base64'] }, { id: 'crypto', tools: ['cmac'] }, ], }) expect(JSON.parse(await readFile(path.join(dataDir, 'navigation.json'), 'utf8'))).toEqual({ version: 1, categories: [ { id: 'encoding', tools: ['hex', 'base64'] }, { id: 'crypto', tools: ['cmac'] }, ], }) }) it('rejects invalid, oversized and cross-origin browser navigation writes', async () => { const { baseUrl } = await createFixtureServer() const invalid = await fetch(`${baseUrl}/api/navigation`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: '{invalid', }) expect(invalid.status).toBe(400) const oversized = await fetch(`${baseUrl}/api/navigation`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: ' '.repeat(MAX_JSON_BODY_SIZE + 1), }) expect(oversized.status).toBe(400) expect((await oversized.json()).error).toContain('过大') const crossSite = await fetch(`${baseUrl}/api/navigation`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'cross-site' }, body: JSON.stringify(defaults), }) expect(crossSite.status).toBe(403) const sameSite = await fetch(`${baseUrl}/api/navigation`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-site' }, body: JSON.stringify(defaults), }) expect(sameSite.status).toBe(403) const sameOrigin = await fetch(`${baseUrl}/api/navigation`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' }, body: JSON.stringify(defaults), }) expect(sameOrigin.status).toBe(200) }) })