发布 0.2.1 并完善工具边界与安全性
This commit is contained in:
@@ -50,6 +50,9 @@ describe('UTF-8 and Base64', () => {
|
||||
it('rejects invalid Base64', () => {
|
||||
expect(() => decodeBase64('abcde')).toThrow('长度')
|
||||
expect(() => decodeBase64('ab*c')).toThrow('无效')
|
||||
expect(() => decodeBase64('TQ=')).toThrow('填充长度')
|
||||
expect(() => decodeBase64('TWE==')).toThrow('填充长度')
|
||||
expect(() => decodeBase64('T=Q=')).toThrow('填充位置')
|
||||
expect(() => decodeBase64('Zh==')).toThrow('无效')
|
||||
expect(() => decodeBase64('Zm9=')).toThrow('无效')
|
||||
})
|
||||
|
||||
@@ -25,4 +25,11 @@ describe('tool catalog consistency', () => {
|
||||
expect(navigationToolIds.sort()).toEqual(catalog.tools.map(({ id }) => id).sort())
|
||||
expect(new Set(navigationToolIds).size).toBe(navigationToolIds.length)
|
||||
})
|
||||
|
||||
it('places checksum tools before cryptographic tools by default', () => {
|
||||
const categoryIds = defaultNavigation.categories.map(({ id }) => id)
|
||||
expect(categoryIds.indexOf('checksum')).toBeGreaterThanOrEqual(0)
|
||||
expect(categoryIds.indexOf('crypto')).toBeGreaterThanOrEqual(0)
|
||||
expect(categoryIds.indexOf('checksum')).toBeLessThan(categoryIds.indexOf('crypto'))
|
||||
})
|
||||
})
|
||||
|
||||
+40
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { bytesToHex, parseHex } from '../src/lib/bytes'
|
||||
import { aes128Cmac } from '../src/lib/cmac'
|
||||
import { aes128Cmac, aesCmac } from '../src/lib/cmac'
|
||||
|
||||
describe('AES-128-CMAC RFC 4493 vectors', () => {
|
||||
const key = parseHex('2B7E151628AED2A6ABF7158809CF4F3C')
|
||||
@@ -15,3 +15,42 @@ describe('AES-128-CMAC RFC 4493 vectors', () => {
|
||||
expect(bytesToHex(aes128Cmac(key, parseHex(message)), '')).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AES-CMAC NIST SP 800-38B vectors', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'AES-192 empty message',
|
||||
key: '8E73B0F7DA0E6452C810F32B809079E562F8EAD2522C6B7B',
|
||||
message: '',
|
||||
expected: 'D17DDF46ADAACDE531CAC483DE7A9367',
|
||||
},
|
||||
{
|
||||
name: 'AES-192 one block',
|
||||
key: '8E73B0F7DA0E6452C810F32B809079E562F8EAD2522C6B7B',
|
||||
message: '6BC1BEE22E409F96E93D7E117393172A',
|
||||
expected: '9E99A7BF31E710900662F65E617C5184',
|
||||
},
|
||||
{
|
||||
name: 'AES-256 empty message',
|
||||
key: '603DEB1015CA71BE2B73AEF0857D77811F352C073B6108D72D9810A30914DFF4',
|
||||
message: '',
|
||||
expected: '028962F61B7BF89EFC6B551F4667D983',
|
||||
},
|
||||
{
|
||||
name: 'AES-256 one block',
|
||||
key: '603DEB1015CA71BE2B73AEF0857D77811F352C073B6108D72D9810A30914DFF4',
|
||||
message: '6BC1BEE22E409F96E93D7E117393172A',
|
||||
expected: '28A7023F452E8F82BD4BF28D8C37C35C',
|
||||
},
|
||||
])('matches $name', ({ key, message, expected }) => {
|
||||
expect(bytesToHex(aesCmac(parseHex(key), parseHex(message)), '')).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([0, 15, 17, 23, 25, 31, 33])('rejects a %s-byte key', (length) => {
|
||||
expect(() => aesCmac(new Uint8Array(length), new Uint8Array())).toThrow('16、24 或 32 字节')
|
||||
})
|
||||
|
||||
it('keeps the AES-128 compatibility wrapper strict', () => {
|
||||
expect(() => aes128Cmac(new Uint8Array(24), new Uint8Array())).toThrow('AES-128 密钥必须是 16 字节')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -48,6 +48,14 @@ describe('CRC', () => {
|
||||
expect(calculateCrcChunks(chunks, preset)).toBe(calculateCrc(textToBytes('123456789'), preset))
|
||||
})
|
||||
|
||||
it('rejects malformed runtime parameters instead of coercing them', () => {
|
||||
const preset = CRC_PRESETS[0]
|
||||
expect(() => calculateCrc(new Uint8Array(), { ...preset, inputReflected: undefined as never })).toThrow('布尔值')
|
||||
expect(() => calculateCrc(new Uint8Array(), { ...preset, polynomial: 7 as never })).toThrow('BigInt')
|
||||
expect(() => formatCrc(0x100n, 8)).toThrow('超出当前位宽')
|
||||
expect(() => formatCrc(0n, 7)).toThrow('位宽')
|
||||
})
|
||||
|
||||
it('reports both the combined CRC and each block CRC', () => {
|
||||
const parameters = CRC_PRESETS.find((preset) => preset.id === 'crc-32-iso-hdlc')!
|
||||
const blocks = [
|
||||
|
||||
@@ -95,4 +95,28 @@ describe('deployment configuration', () => {
|
||||
expect(agentGuide).toContain('git status --short --branch')
|
||||
expect(completeReadme).toContain('## AI 接手清单')
|
||||
})
|
||||
|
||||
it('keeps the management page under the reserved tools namespace', async () => {
|
||||
const router = await readProjectFile('src/router.ts')
|
||||
|
||||
expect(router).toContain("path: '/tools/setting'")
|
||||
expect(router).not.toContain("path: '/setting'")
|
||||
})
|
||||
|
||||
it('uses the UTF-8 / Hex tool ID as the canonical default route', async () => {
|
||||
const [router, catalogText, navigationText, registry] = await Promise.all([
|
||||
readProjectFile('src/router.ts'),
|
||||
readProjectFile('shared/tool-catalog.json'),
|
||||
readProjectFile('shared/default-navigation.json'),
|
||||
readProjectFile('src/tools/registry.ts'),
|
||||
])
|
||||
const catalog = JSON.parse(catalogText)
|
||||
const navigation = JSON.parse(navigationText)
|
||||
|
||||
expect(catalog.tools.find(({ title }) => title === 'UTF-8 / Hex')?.id).toBe('utf8-hex')
|
||||
expect(navigation.categories.flatMap(({ tools }) => tools)).toContain('utf8-hex')
|
||||
expect(router).toContain("redirect: '/tools/utf8-hex'")
|
||||
expect(router).not.toContain("'/tools/ascii-hex'")
|
||||
expect(registry).toContain("'utf8-hex': defineAsyncComponent(() => import('./Utf8HexTool.vue'))")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
async function findVueFiles(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true })
|
||||
const files = await Promise.all(entries.map((entry) => {
|
||||
const entryPath = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) return findVueFiles(entryPath)
|
||||
return entry.isFile() && entry.name.endsWith('.vue') ? [entryPath] : []
|
||||
}))
|
||||
return files.flat()
|
||||
}
|
||||
|
||||
describe('form field identity', () => {
|
||||
it('gives every native form field an id and name', async () => {
|
||||
const vueFiles = await findVueFiles(path.join(projectRoot, 'src'))
|
||||
const missing = []
|
||||
|
||||
for (const filePath of vueFiles) {
|
||||
const source = await readFile(filePath, 'utf8')
|
||||
for (const match of source.matchAll(/<(?:input|select|textarea)\b[^>]*>/gs)) {
|
||||
const tag = match[0]
|
||||
const line = source.slice(0, match.index).split('\n').length
|
||||
if (!/\s(?::)?id=/.test(tag) || !/\s(?::)?name=/.test(tag)) {
|
||||
missing.push(`${path.relative(projectRoot, filePath)}:${line} ${tag.replace(/\s+/g, ' ')}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_MAX_INTEL_HEX_BYTES,
|
||||
DEFAULT_MAX_INTEL_HEX_RECORDS,
|
||||
MAX_UINT32_ADDRESS,
|
||||
parseIntelHex,
|
||||
@@ -63,6 +64,16 @@ describe('Intel HEX structural limits', () => {
|
||||
expect(() => parseIntelHex(record(0, 0, [1]), { maxRecords: 1.5 })).toThrow('正整数')
|
||||
expect(() => parseIntelHex(record(0, 0, [1]), { maxRecords: Number.POSITIVE_INFINITY })).toThrow('正整数')
|
||||
expect(() => parseIntelHex(record(0, 0, [1]), { maxRecords: Number.MAX_SAFE_INTEGER + 1 })).toThrow('正整数')
|
||||
expect(() => parseIntelHex(record(0, 0, [1]), { maxBytes: 0 })).toThrow('正整数')
|
||||
expect(DEFAULT_MAX_INTEL_HEX_BYTES).toBe(64 * 1024 * 1024)
|
||||
})
|
||||
|
||||
it('rejects a merged data block before allocating beyond the byte limit', () => {
|
||||
expect(() => parseIntelHex([
|
||||
record(0, 0, [0xaa]),
|
||||
record(1, 0, [0xbb]),
|
||||
eof,
|
||||
].join('\n'), { maxBytes: 1 })).toThrow('有效数据不能超过 1 bytes')
|
||||
})
|
||||
|
||||
it('rejects records after EOF and malformed control records', () => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { 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 {
|
||||
normalizeNavigation,
|
||||
readOrCreateNavigation,
|
||||
validateNavigation,
|
||||
writeJsonAtomic,
|
||||
} from '../server/navigation.mjs'
|
||||
@@ -41,6 +42,34 @@ describe('navigation config', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('migrates the legacy ASCII/Hex ID without changing its saved position', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'web-tools-navigation-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const filePath = path.join(directory, 'navigation.json')
|
||||
const utf8Catalog = {
|
||||
categories: [{ id: 'encoding' }],
|
||||
tools: [
|
||||
{ id: 'utf8-hex', category: 'encoding' },
|
||||
{ id: 'base64', category: 'encoding' },
|
||||
],
|
||||
}
|
||||
const utf8Defaults = {
|
||||
version: 1,
|
||||
categories: [{ id: 'encoding', tools: ['utf8-hex', 'base64'] }],
|
||||
}
|
||||
await writeFile(filePath, JSON.stringify({
|
||||
version: 1,
|
||||
categories: [{ id: 'encoding', tools: ['base64', 'ascii-hex'] }],
|
||||
}))
|
||||
|
||||
const expected = {
|
||||
version: 1,
|
||||
categories: [{ id: 'encoding', tools: ['base64', 'utf8-hex'] }],
|
||||
}
|
||||
await expect(readOrCreateNavigation(filePath, utf8Catalog, utf8Defaults)).resolves.toEqual(expected)
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual(expected)
|
||||
})
|
||||
|
||||
it('rejects duplicate or incomplete configurations', () => {
|
||||
expect(() => validateNavigation({ version: 1, categories: [{ id: 'encoding', tools: ['hex', 'hex'] }] }, catalog)).toThrow('重复')
|
||||
expect(() => validateNavigation({ version: 1, categories: [{ id: 'encoding', tools: ['hex'] }, { id: 'crypto', tools: ['cmac'] }] }, catalog)).toThrow('全部')
|
||||
|
||||
+32
-2
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatBytes } from '../src/lib/bytes'
|
||||
import { secureRandomBytes, type RandomSource } from '../src/lib/random'
|
||||
import {
|
||||
DEFAULT_RANDOM_BYTES,
|
||||
MAX_RANDOM_BYTES,
|
||||
MIN_RANDOM_BYTES,
|
||||
secureRandomBytes,
|
||||
type RandomSource,
|
||||
} from '../src/lib/random'
|
||||
|
||||
describe('secure random bytes', () => {
|
||||
it('uses the supplied cryptographic source and preserves requested length', () => {
|
||||
@@ -12,8 +18,32 @@ describe('secure random bytes', () => {
|
||||
return array
|
||||
},
|
||||
}
|
||||
expect(DEFAULT_RANDOM_BYTES).toBe(16)
|
||||
expect(secureRandomBytes(4, source)).toEqual(Uint8Array.from([0, 1, 2, 3]))
|
||||
expect(() => secureRandomBytes(0, source)).toThrow('1 至 65536')
|
||||
})
|
||||
|
||||
it.each([0, -1, 1.5, Number.NaN, MAX_RANDOM_BYTES + 1])('rejects invalid length %s', (length) => {
|
||||
expect(() => secureRandomBytes(length, crypto)).toThrow(`${MIN_RANDOM_BYTES} 至 ${MAX_RANDOM_BYTES}`)
|
||||
})
|
||||
|
||||
it('accepts both length boundaries without exceeding the browser call limit', () => {
|
||||
const calls: number[] = []
|
||||
const source: RandomSource = {
|
||||
getRandomValues<T extends ArrayBufferView>(array: T): T {
|
||||
calls.push(array.byteLength)
|
||||
return array
|
||||
},
|
||||
}
|
||||
expect(secureRandomBytes(MIN_RANDOM_BYTES, source)).toHaveLength(MIN_RANDOM_BYTES)
|
||||
expect(secureRandomBytes(MAX_RANDOM_BYTES, source)).toHaveLength(MAX_RANDOM_BYTES)
|
||||
expect(calls).toEqual([MIN_RANDOM_BYTES, MAX_RANDOM_BYTES])
|
||||
})
|
||||
|
||||
it('fails closed when the cryptographic source is unavailable or throws', () => {
|
||||
expect(() => secureRandomBytes(DEFAULT_RANDOM_BYTES, null as unknown as RandomSource)).toThrow('不支持安全随机数')
|
||||
const failure = new Error('entropy source failed')
|
||||
const source: RandomSource = { getRandomValues: () => { throw failure } }
|
||||
expect(() => secureRandomBytes(DEFAULT_RANDOM_BYTES, source)).toThrow(failure)
|
||||
})
|
||||
|
||||
it('formats the same bytes for OpenSSL and source-code styles', () => {
|
||||
|
||||
+18
-1
@@ -1,8 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { formatBytes, parseHex } from '../src/lib/bytes'
|
||||
import { base64UrlToHex, formatPem, generateRsaKeyPair } from '../src/lib/rsa'
|
||||
|
||||
describe('RSA key generation', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('formats DER and JWK integer values', () => {
|
||||
expect(formatPem(Uint8Array.from([0, 1, 2, 3]), 'PUBLIC KEY')).toBe('-----BEGIN PUBLIC KEY-----\nAAECAw==\n-----END PUBLIC KEY-----\n')
|
||||
expect(base64UrlToHex('AQAB')).toBe('010001')
|
||||
@@ -17,4 +21,17 @@ describe('RSA key generation', () => {
|
||||
expect(formatBytes(parseHex(result.exponentHex), { format: 'byte-array' })).toBe('0x01, 0x00, 0x01')
|
||||
expect(['Web Crypto', 'node-forge']).toContain(result.source)
|
||||
}, 20_000)
|
||||
|
||||
it('uses the node-forge fallback with a cryptographic random source when SubtleCrypto is unavailable', async () => {
|
||||
const getRandomValues = globalThis.crypto.getRandomValues.bind(globalThis.crypto)
|
||||
vi.stubGlobal('crypto', { getRandomValues })
|
||||
|
||||
const result = await generateRsaKeyPair(2048)
|
||||
|
||||
expect(result.source).toBe('node-forge')
|
||||
expect(result.publicKeyPem).toMatch(/^-----BEGIN PUBLIC KEY-----/)
|
||||
expect(result.privateKeyPem).toMatch(/^-----BEGIN PRIVATE KEY-----/)
|
||||
expect(result.modulusHex).toHaveLength(512)
|
||||
expect(result.exponentHex).toBe('010001')
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
+66
-2
@@ -2,7 +2,7 @@ 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'
|
||||
import { assertProductionBuild, createWebToolsServer, CSP_NONCE_BYTES, MAX_JSON_BODY_SIZE } from '../server/index.mjs'
|
||||
|
||||
const temporaryDirectories = []
|
||||
const servers = []
|
||||
@@ -34,8 +34,11 @@ async function createFixtureServer() {
|
||||
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'), '<!doctype html><title>Web Tools</title>')
|
||||
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)
|
||||
@@ -68,6 +71,26 @@ describe('HTTP server', () => {
|
||||
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`)
|
||||
@@ -85,6 +108,18 @@ describe('HTTP server', () => {
|
||||
|
||||
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 () => {
|
||||
@@ -103,6 +138,21 @@ describe('HTTP server', () => {
|
||||
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'] }] }
|
||||
@@ -144,7 +194,7 @@ describe('HTTP server', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects invalid, oversized and cross-site navigation writes', async () => {
|
||||
it('rejects invalid, oversized and cross-origin browser navigation writes', async () => {
|
||||
const { baseUrl } = await createFixtureServer()
|
||||
const invalid = await fetch(`${baseUrl}/api/navigation`, {
|
||||
method: 'PUT',
|
||||
@@ -167,5 +217,19 @@ describe('HTTP server', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { validateXmlDocument, XML_PARSER_ERROR_NAMESPACES } from '../src/lib/xml'
|
||||
|
||||
function parserWithCandidates(candidates: Array<{ localName: string; namespaceURI: string | null; textContent: string | null }>) {
|
||||
return {
|
||||
parseFromString: () => ({
|
||||
getElementsByTagName: (localName: string) => {
|
||||
expect(localName).toBe('parsererror')
|
||||
return candidates
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe('XML validation', () => {
|
||||
it('accepts valid content even when the user root element is named parsererror', () => {
|
||||
expect(() => validateXmlDocument('<parsererror>valid</parsererror>', parserWithCandidates([
|
||||
{ localName: 'parsererror', namespaceURI: null, textContent: 'valid' },
|
||||
]))).not.toThrow()
|
||||
})
|
||||
|
||||
it('reports parser errors from the browser parser-error namespace', () => {
|
||||
for (const namespaceURI of XML_PARSER_ERROR_NAMESPACES) {
|
||||
expect(() => validateXmlDocument('<root>', parserWithCandidates([
|
||||
{ localName: 'parsererror', namespaceURI, textContent: 'XML parsing error\nDetails' },
|
||||
]))).toThrow('XML parsing error')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects empty input before invoking the parser', () => {
|
||||
expect(() => validateXmlDocument(' ', parserWithCandidates([]))).toThrow('请输入 XML')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user