clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

main
diagramzip / services / shell / test / block-transform.test.ts
block-transform.test.ts 15.1K
1import { env, SELF } from 'cloudflare:test'2import { afterEach, describe, expect, it, vi } from 'vitest'3import { canonicalizeSvg } from '../../../shared/svg/index.js'4import { ENGINE_IDS } from '../../../renderers/shared/engines'5import worker from '../src/index'6import { engineForLang, renderUnitUrl, signBody } from '../src/block-transform'78const SECRET = 'tiny-test-secret'9const ORIGIN_PATTERN = 'https://{engine}.units.test'10const RAW_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 20"><rect x="2" y="2" width="36" height="16" fill="white" stroke="black"></rect><text x="8" y="14" fill="black">a</text></svg>'11const METADATA = { title: '', description: '' }12const MERMAID_CANONICAL = canonicalizeSvg(RAW_SVG, METADATA, 'mermaid', 'mermaid@11.17.0')13const DIAGRAMSNET_CANONICAL = canonicalizeSvg(RAW_SVG, METADATA, 'diagramsnet', 'diagrams.net@29.6.1')1415interface UnitCall { url: string; body: Record<string, unknown> }1617type UnitHandler = (call: UnitCall) => Response | Promise<Response>1819function unitFetch(handler: UnitHandler): { calls: UnitCall[] } {20 const calls: UnitCall[] = []21 vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {22 const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url23 const body = JSON.parse(String(init?.body)) as Record<string, unknown>24 const call = { url, body }25 calls.push(call)26 return handler(call)27 }))28 return { calls }29}3031function svgResponse(body: string): Response {32 return new Response(body, { headers: { 'content-type': 'image/svg+xml; charset=utf-8' } })33}3435function payload(blocks: Array<Record<string, unknown>>, extra: Record<string, unknown> = {}): string {36 return JSON.stringify({37 relay: 'https://relay.example',38 view: 'diagrams',39 source: { id: 'ab'.repeat(32), kind: 30818 },40 blocks,41 ...extra,42 })43}4445async function transform(body: string, options: { secret?: string | null; signature?: string; env?: Record<string, string> } = {}): Promise<Response> {46 const bytes = new TextEncoder().encode(body)47 const signature = options.signature ?? await signBody(SECRET, bytes)48 const request = new Request('https://diagram.zip/transform/blocks', {49 method: 'POST',50 headers: { 'content-type': 'application/json', 'x-transform-view': 'diagrams', 'x-transform-relay': 'https://relay.example', 'x-transform-signature': signature },51 body: bytes,52 })53 const secret = options.secret === undefined ? SECRET : options.secret54 const testEnv = { ...env, RENDER_UNIT_ORIGIN_PATTERN: ORIGIN_PATTERN, ...(secret === null ? {} : { BLOCK_TRANSFORM_SECRET: secret }), ...options.env } as Env55 return worker.fetch(request as Parameters<typeof worker.fetch>[0], testEnv)56}5758async function legacyTransform(body: string): Promise<Response> {59 const bytes = new TextEncoder().encode(body)60 const request = new Request('https://diagram.zip/transform/tiny', {61 method: 'POST',62 headers: { 'content-type': 'application/json', 'x-tiny-signature': await signBody(SECRET, bytes) },63 body: bytes,64 })65 return worker.fetch(request as Parameters<typeof worker.fetch>[0], { ...env, RENDER_UNIT_ORIGIN_PATTERN: ORIGIN_PATTERN, TINY_TRANSFORM_SECRET: SECRET } as Env)66}6768describe('block transform aliases', () => {69 it('maps every engine id to itself', () => {70 for (const engine of ENGINE_IDS) expect(engineForLang(engine)).toBe(engine)71 })7273 it('maps fence aliases to engine ids', () => {74 expect(engineForLang('mmd')).toBe('mermaid')75 expect(engineForLang('dot')).toBe('graphviz')76 expect(engineForLang('puml')).toBe('plantuml')77 expect(engineForLang('uml')).toBe('plantuml')78 expect(engineForLang('c4')).toBe('c4plantuml')79 expect(engineForLang('bob')).toBe('svgbob')80 expect(engineForLang('vega-lite')).toBe('vegalite')81 expect(engineForLang('drawio')).toBe('diagramsnet')82 expect(engineForLang(' Mermaid ')).toBe('mermaid')83 })8485 it('does not map unknown languages', () => {86 expect(engineForLang('python')).toBeUndefined()87 expect(engineForLang('')).toBeUndefined()88 expect(engineForLang('constructor')).toBeUndefined()89 })9091 it('builds render unit URLs from the origin pattern', () => {92 expect(renderUnitUrl('mermaid', undefined)).toBe('https://mermaid.render.diagram.zip/v1/svg')93 expect(renderUnitUrl('graphviz', 'https://{engine}.units.test/')).toBe('https://graphviz.units.test/v1/svg')94 })95})9697describe('POST /transform/blocks', () => {98 afterEach(() => { vi.unstubAllGlobals() })99100 it('keeps the legacy tiny route and headers working', async () => {101 unitFetch(() => svgResponse(MERMAID_CANONICAL))102 const response = await legacyTransform(payload([{ index: 0, lang: 'mermaid', source: 'graph TD; a-->b' }]))103 expect(response.status).toBe(200)104 expect((await response.json() as { artifacts: unknown[] }).artifacts).toHaveLength(1)105 })106107 it('does not accept a legacy signature on the canonical route', async () => {108 const body = payload([])109 const bytes = new TextEncoder().encode(body)110 const request = new Request('https://diagram.zip/transform/blocks', {111 method: 'POST',112 headers: { 'x-tiny-signature': await signBody(SECRET, bytes) },113 body: bytes,114 })115 const response = await worker.fetch(request as Parameters<typeof worker.fetch>[0], { ...env, BLOCK_TRANSFORM_SECRET: SECRET } as Env)116 expect(response.status).toBe(401)117 })118119 it('answers 503 when the transform secret is not configured', async () => {120 const response = await transform(payload([]), { secret: null })121122 expect(response.status).toBe(503)123 expect(response.headers.get('content-type')).toContain('application/json')124 expect(await response.json()).toEqual({ error: { code: 'transform_unavailable', message: 'BLOCK_TRANSFORM_SECRET is not configured.' } })125 })126127 it('is routed ahead of the GET and HEAD only check', async () => {128 const response = await SELF.fetch('https://diagram.zip/transform/blocks', { method: 'POST', body: payload([]) })129 const get = await SELF.fetch('https://diagram.zip/transform/blocks')130131 expect(response.status).toBe(503)132 expect(get.status).toBe(405)133 expect(get.headers.get('allow')).toBe('POST')134 })135136 it('rejects a missing or wrong signature', async () => {137 const body = payload([{ index: 0, lang: 'mermaid', source: 'graph TD; a-->b' }])138 const missing = await transform(body, { signature: '' })139 const wrong = await transform(body, { signature: `sha256=${'0'.repeat(64)}` })140 const otherSecret = await transform(body, { signature: await signBody('other', new TextEncoder().encode(body)) })141 const short = await transform(body, { signature: 'sha256=abcd' })142143 for (const response of [missing, wrong, otherSecret, short]) {144 expect(response.status).toBe(401)145 expect(await response.json()).toEqual({ error: { code: 'invalid_signature', message: 'X-Transform-Signature does not match the request body.' } })146 }147 })148149 it('renders signed blocks into materialized SVG artifacts', async () => {150 const unit = unitFetch(() => svgResponse(MERMAID_CANONICAL))151 const response = await transform(payload([{ index: 0, lang: 'mermaid', source: 'graph TD; a-->b' }]))152 const result = await response.json() as { artifacts: Array<Record<string, unknown>>; errors: unknown[] }153154 expect(response.status).toBe(200)155 expect(response.headers.get('cache-control')).toBe('no-store')156 expect(response.headers.get('x-content-type-options')).toBe('nosniff')157 expect(response.headers.get('access-control-allow-origin')).toBeNull()158 expect(result.errors).toEqual([])159 expect(result.artifacts).toHaveLength(1)160 expect(result.artifacts[0]).toMatchObject({ block: 0, engine: 'mermaid', type: 'image/svg+xml', appearance: 'auto-transparent' })161 expect(result.artifacts[0].body).toContain('data-dz-appearance="auto-transparent"')162 expect(result.artifacts[0].body).toContain('data-dz-materializer=')163 expect(unit.calls).toHaveLength(1)164 expect(unit.calls[0].url).toBe('https://mermaid.units.test/v1/svg')165 expect(unit.calls[0].body).toEqual({166 source: 'graph TD; a-->b',167 format: 'svg',168 options: {},169 metadata: { title: '', description: '' },170 presentation: { background: '', padding: 0, frame: false },171 })172 expect(unit.calls[0].body).not.toHaveProperty('engine')173 })174175 it('uses the default render unit origin when no pattern is configured', async () => {176 const unit = unitFetch(() => svgResponse(MERMAID_CANONICAL))177 const response = await transform(payload([{ index: 0, lang: 'mmd', source: 'graph TD; a-->b' }]), { env: { RENDER_UNIT_ORIGIN_PATTERN: '' } })178179 expect(response.status).toBe(200)180 expect(unit.calls[0].url).toBe('https://mermaid.render.diagram.zip/v1/svg')181 })182183 it('maps aliases per block and skips languages that are not engines', async () => {184 const unit = unitFetch(() => svgResponse(MERMAID_CANONICAL))185 const response = await transform(payload([186 { index: 0, lang: 'dot', source: 'digraph { a -> b }' },187 { index: 1, lang: 'python', source: 'print(1)' },188 { index: 2, lang: 'drawio', source: '<mxfile/>' },189 ]))190 const result = await response.json() as { artifacts: Array<{ block: number; engine: string }>; errors: unknown[] }191192 expect(response.status).toBe(200)193 expect(unit.calls.map(call => call.url)).toEqual(['https://graphviz.units.test/v1/svg', 'https://diagramsnet.units.test/v1/svg'])194 expect(result.artifacts.map(({ block, engine }) => ({ block, engine }))).toEqual([{ block: 0, engine: 'graphviz' }, { block: 2, engine: 'diagramsnet' }])195 expect(result.errors).toEqual([])196 })197198 it('isolates failures per block', async () => {199 unitFetch(call => {200 if (call.url.startsWith('https://d2.')) return Response.json({ error: { code: 'render_failed', message: 'unexpected token' } }, { status: 422 })201 if (call.url.startsWith('https://pikchr.')) throw new TypeError('connection refused')202 if (call.url.startsWith('https://goat.')) return svgResponse('<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>')203 if (call.url.startsWith('https://ditaa.')) return new Response('bad gateway', { status: 502 })204 return svgResponse(MERMAID_CANONICAL)205 })206 const response = await transform(payload([207 { index: 0, lang: 'mermaid', source: 'graph TD; a-->b' },208 { index: 1, lang: 'd2', source: 'a -> b' },209 { index: 2, lang: 'pikchr', source: 'box' },210 { index: 3, lang: 'goat', source: '+--+' },211 { index: 4, lang: 'ditaa', source: '+--+' },212 { index: 5, lang: 'graphviz', source: 'digraph {}' },213 ]))214 const result = await response.json() as { artifacts: Array<{ block: number }>; errors: Array<{ block: number; engine: string; error: string }> }215216 expect(response.status).toBe(200)217 expect(result.artifacts.map(({ block }) => block)).toEqual([0, 5])218 expect(result.errors).toEqual([219 { block: 1, engine: 'd2', error: 'renderer answered 422: unexpected token' },220 { block: 2, engine: 'pikchr', error: 'renderer unreachable: connection refused' },221 { block: 3, engine: 'goat', error: expect.stringContaining('renderer output rejected:') },222 { block: 4, engine: 'ditaa', error: 'renderer answered 502: bad gateway' },223 ])224 })225226 it('honours a requested appearance and falls back to raw when the profile lacks it', async () => {227 unitFetch(call => svgResponse(call.url.startsWith('https://diagramsnet.') ? DIAGRAMSNET_CANONICAL : MERMAID_CANONICAL))228 const response = await transform(payload([229 { index: 0, lang: 'mermaid', source: 'graph TD; a-->b' },230 { index: 1, lang: 'diagramsnet', source: '<mxfile/>' },231 ], { appearance: 'dark-transparent' }))232 const result = await response.json() as { artifacts: Array<{ block: number; appearance: string; body: string }> }233234 expect(response.status).toBe(200)235 expect(result.artifacts[0].appearance).toBe('dark-transparent')236 expect(result.artifacts[0].body).toContain('data-dz-appearance="dark-transparent"')237 expect(result.artifacts[1].appearance).toBe('raw')238 expect(result.artifacts[1].body).toContain('data-dz-appearance="raw"')239 })240241 it('rejects unknown appearances and malformed requests', async () => {242 const appearance = await transform(payload([], { appearance: 'sepia' }))243 const blocks = await transform(payload([{ index: 0, lang: 'mermaid' }]))244 const source = await transform(payload([], { source: 'not-an-object' }))245 const invalidJson = await transform('{')246247 expect(appearance.status).toBe(400)248 expect((await appearance.json() as { error: { code: string } }).error.code).toBe('invalid_appearance')249 expect(blocks.status).toBe(400)250 expect(source.status).toBe(400)251 expect(invalidJson.status).toBe(400)252 expect((await invalidJson.json() as { error: { code: string } }).error.code).toBe('invalid_json')253 })254255 it('rejects a body that carries the source event', async () => {256 const fetchSpy = vi.fn()257 vi.stubGlobal('fetch', fetchSpy)258 const response = await transform(payload([{ index: 0, lang: 'mermaid', source: 'graph TD; a-->b' }], { event: { id: 'ab'.repeat(32), kind: 30818, content: 'secret' } }))259260 expect(response.status).toBe(400)261 expect((await response.json() as { error: { code: string } }).error.code).toBe('unexpected_field')262 expect(fetchSpy).not.toHaveBeenCalled()263 })264265 it('accepts a body without source metadata', async () => {266 unitFetch(() => svgResponse(MERMAID_CANONICAL))267 const response = await transform(JSON.stringify({ blocks: [{ index: 0, lang: 'mermaid', source: 'graph TD; a-->b' }] }))268269 expect(response.status).toBe(200)270 expect((await response.json() as { artifacts: unknown[] }).artifacts).toHaveLength(1)271 })272273 it('enforces the body, block count and block source limits', async () => {274 const fetchSpy = vi.fn()275 vi.stubGlobal('fetch', fetchSpy)276 const oversizedBody = await transform(payload([{ index: 0, lang: 'mermaid', source: 'x'.repeat(1_048_576) }]))277 const tooManyBlocks = await transform(payload(Array.from({ length: 33 }, (_, index) => ({ index, lang: 'mermaid', source: 'graph TD; a-->b' }))))278 const oversizedBlock = await transform(payload([{ index: 0, lang: 'mermaid', source: 'x'.repeat(524_289) }]))279280 expect(oversizedBody.status).toBe(413)281 expect((await oversizedBody.json() as { error: { code: string } }).error.code).toBe('request_too_large')282 expect(tooManyBlocks.status).toBe(413)283 expect((await tooManyBlocks.json() as { error: { code: string } }).error.code).toBe('too_many_blocks')284 expect(oversizedBlock.status).toBe(413)285 expect((await oversizedBlock.json() as { error: { code: string } }).error.code).toBe('block_too_large')286 expect(fetchSpy).not.toHaveBeenCalled()287 })288289 it('renders at most six blocks at a time', async () => {290 let active = 0291 let peak = 0292 unitFetch(async () => {293 active += 1294 peak = Math.max(peak, active)295 await new Promise(resolve => setTimeout(resolve, 5))296 active -= 1297 return svgResponse(MERMAID_CANONICAL)298 })299 const response = await transform(payload(Array.from({ length: 16 }, (_, index) => ({ index, lang: 'mermaid', source: 'graph TD; a-->b' }))))300 const result = await response.json() as { artifacts: unknown[] }301302 expect(result.artifacts).toHaveLength(16)303 expect(peak).toBeLessThanOrEqual(6)304 expect(peak).toBeGreaterThan(1)305 })306})