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, 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 writeFile(path.join(staticRoot, 'index.html'), '
Web Tools')
await writeFile(path.join(staticRoot, 'app.js'), 'console.log("ok")')
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('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')
})
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('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-site 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)
})
})