clone

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

test: cover the tiny transform endpoint

Dami committed b170323
commit
b17032316c724aa39687f17674f44639df93e475
parent
ed32e765fac7231eb7062717a3736290c98a235f
author
Dami <dami.etoile@gmail.com>
1 file changed, +257 -0
Aservices/shell/test/tiny-transform.test.ts257++++++++++++++++++++++++++++++
A services/shell/test/tiny-transform.test.ts +257 -0
@@ -0,0 +1,257 @@
1+import { env, SELF } from 'cloudflare:test'2+import { afterEach, describe, expect, it, vi } from 'vitest'3+import { canonicalizeSvg } from '../../../shared/svg/index.js'4+import { ENGINE_IDS } from '../../../renderers/shared/engines'5+import worker from '../src/index'6+import { engineForLang, renderUnitUrl, signBody } from '../src/tiny-transform'7+8+const SECRET = 'tiny-test-secret'9+const ORIGIN_PATTERN = 'https://{engine}.units.test'10+const 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>'11+const METADATA = { title: '', description: '' }12+const MERMAID_CANONICAL = canonicalizeSvg(RAW_SVG, METADATA, 'mermaid', 'mermaid@11.17.0')13+const DIAGRAMSNET_CANONICAL = canonicalizeSvg(RAW_SVG, METADATA, 'diagramsnet', 'diagrams.net@29.6.1')14+15+interface UnitCall { url: string; body: Record<string, unknown> }16+17+type UnitHandler = (call: UnitCall) => Response | Promise<Response>18+19+function 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+}30+31+function svgResponse(body: string): Response {32+ return new Response(body, { headers: { 'content-type': 'image/svg+xml; charset=utf-8' } })33+}34+35+function payload(blocks: Array<Record<string, unknown>>, extra: Record<string, unknown> = {}): string {36+ return JSON.stringify({37+ relay: 'https://relay.example',38+ view: 'diagrams',39+ event: { id: 'ab'.repeat(32), kind: 30818, pubkey: 'cd'.repeat(32), created_at: 0, content: '', tags: [['title', 'Delivery']] },40+ blocks,41+ ...extra,42+ })43+}44+45+async 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/tiny', {49+ method: 'POST',50+ headers: { 'content-type': 'application/json', 'x-tiny-view': 'diagrams', 'x-tiny-relay': 'https://relay.example', 'x-tiny-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 ? {} : { TINY_TRANSFORM_SECRET: secret }), ...options.env } as Env55+ return worker.fetch(request as Parameters<typeof worker.fetch>[0], testEnv)56+}57+58+describe('tiny transform aliases', () => {59+ it('maps every engine id to itself', () => {60+ for (const engine of ENGINE_IDS) expect(engineForLang(engine)).toBe(engine)61+ })62+63+ it('maps fence aliases to engine ids', () => {64+ expect(engineForLang('mmd')).toBe('mermaid')65+ expect(engineForLang('dot')).toBe('graphviz')66+ expect(engineForLang('puml')).toBe('plantuml')67+ expect(engineForLang('uml')).toBe('plantuml')68+ expect(engineForLang('c4')).toBe('c4plantuml')69+ expect(engineForLang('bob')).toBe('svgbob')70+ expect(engineForLang('vega-lite')).toBe('vegalite')71+ expect(engineForLang('drawio')).toBe('diagramsnet')72+ expect(engineForLang(' Mermaid ')).toBe('mermaid')73+ })74+75+ it('does not map unknown languages', () => {76+ expect(engineForLang('python')).toBeUndefined()77+ expect(engineForLang('')).toBeUndefined()78+ expect(engineForLang('constructor')).toBeUndefined()79+ })80+81+ it('builds render unit URLs from the origin pattern', () => {82+ expect(renderUnitUrl('mermaid', undefined)).toBe('https://mermaid.render.diagram.zip/v1/svg')83+ expect(renderUnitUrl('graphviz', 'https://{engine}.units.test/')).toBe('https://graphviz.units.test/v1/svg')84+ })85+})86+87+describe('POST /transform/tiny', () => {88+ afterEach(() => { vi.unstubAllGlobals() })89+90+ it('answers 503 when the transform secret is not configured', async () => {91+ const response = await transform(payload([]), { secret: null })92+93+ expect(response.status).toBe(503)94+ expect(response.headers.get('content-type')).toContain('application/json')95+ expect(await response.json()).toEqual({ error: { code: 'transform_unavailable', message: 'TINY_TRANSFORM_SECRET is not configured.' } })96+ })97+98+ it('is routed ahead of the GET and HEAD only check', async () => {99+ const response = await SELF.fetch('https://diagram.zip/transform/tiny', { method: 'POST', body: payload([]) })100+ const get = await SELF.fetch('https://diagram.zip/transform/tiny')101+102+ expect(response.status).toBe(503)103+ expect(get.status).toBe(405)104+ expect(get.headers.get('allow')).toBe('POST')105+ })106+107+ it('rejects a missing or wrong signature', async () => {108+ const body = payload([{ index: 0, lang: 'mermaid', source: 'graph TD; a-->b' }])109+ const missing = await transform(body, { signature: '' })110+ const wrong = await transform(body, { signature: `sha256=${'0'.repeat(64)}` })111+ const otherSecret = await transform(body, { signature: await signBody('other', new TextEncoder().encode(body)) })112+ const short = await transform(body, { signature: 'sha256=abcd' })113+114+ for (const response of [missing, wrong, otherSecret, short]) {115+ expect(response.status).toBe(401)116+ expect(await response.json()).toEqual({ error: { code: 'invalid_signature', message: 'X-Tiny-Signature does not match the request body.' } })117+ }118+ })119+120+ it('renders signed blocks into materialized SVG artifacts', async () => {121+ const unit = unitFetch(() => svgResponse(MERMAID_CANONICAL))122+ const response = await transform(payload([{ index: 0, lang: 'mermaid', source: 'graph TD; a-->b' }]))123+ const result = await response.json() as { artifacts: Array<Record<string, unknown>>; errors: unknown[] }124+125+ expect(response.status).toBe(200)126+ expect(response.headers.get('cache-control')).toBe('no-store')127+ expect(response.headers.get('x-content-type-options')).toBe('nosniff')128+ expect(response.headers.get('access-control-allow-origin')).toBeNull()129+ expect(result.errors).toEqual([])130+ expect(result.artifacts).toHaveLength(1)131+ expect(result.artifacts[0]).toMatchObject({ block: 0, engine: 'mermaid', type: 'image/svg+xml', appearance: 'auto-transparent' })132+ expect(result.artifacts[0].body).toContain('data-dz-appearance="auto-transparent"')133+ expect(result.artifacts[0].body).toContain('data-dz-materializer=')134+ expect(unit.calls).toHaveLength(1)135+ expect(unit.calls[0].url).toBe('https://mermaid.units.test/v1/svg')136+ expect(unit.calls[0].body).toEqual({137+ source: 'graph TD; a-->b',138+ format: 'svg',139+ options: {},140+ metadata: { title: 'Delivery', description: '' },141+ presentation: { background: '', padding: 0, frame: false },142+ })143+ expect(unit.calls[0].body).not.toHaveProperty('engine')144+ })145+146+ it('uses the default render unit origin when no pattern is configured', async () => {147+ const unit = unitFetch(() => svgResponse(MERMAID_CANONICAL))148+ const response = await transform(payload([{ index: 0, lang: 'mmd', source: 'graph TD; a-->b' }]), { env: { RENDER_UNIT_ORIGIN_PATTERN: '' } })149+150+ expect(response.status).toBe(200)151+ expect(unit.calls[0].url).toBe('https://mermaid.render.diagram.zip/v1/svg')152+ })153+154+ it('maps aliases per block and skips languages that are not engines', async () => {155+ const unit = unitFetch(() => svgResponse(MERMAID_CANONICAL))156+ const response = await transform(payload([157+ { index: 0, lang: 'dot', source: 'digraph { a -> b }' },158+ { index: 1, lang: 'python', source: 'print(1)' },159+ { index: 2, lang: 'drawio', source: '<mxfile/>' },160+ ]))161+ const result = await response.json() as { artifacts: Array<{ block: number; engine: string }>; errors: unknown[] }162+163+ expect(response.status).toBe(200)164+ expect(unit.calls.map(call => call.url)).toEqual(['https://graphviz.units.test/v1/svg', 'https://diagramsnet.units.test/v1/svg'])165+ expect(result.artifacts.map(({ block, engine }) => ({ block, engine }))).toEqual([{ block: 0, engine: 'graphviz' }, { block: 2, engine: 'diagramsnet' }])166+ expect(result.errors).toEqual([])167+ })168+169+ it('isolates failures per block', async () => {170+ unitFetch(call => {171+ if (call.url.startsWith('https://d2.')) return Response.json({ error: { code: 'render_failed', message: 'unexpected token' } }, { status: 422 })172+ if (call.url.startsWith('https://pikchr.')) throw new TypeError('connection refused')173+ if (call.url.startsWith('https://goat.')) return svgResponse('<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>')174+ if (call.url.startsWith('https://ditaa.')) return new Response('bad gateway', { status: 502 })175+ return svgResponse(MERMAID_CANONICAL)176+ })177+ const response = await transform(payload([178+ { index: 0, lang: 'mermaid', source: 'graph TD; a-->b' },179+ { index: 1, lang: 'd2', source: 'a -> b' },180+ { index: 2, lang: 'pikchr', source: 'box' },181+ { index: 3, lang: 'goat', source: '+--+' },182+ { index: 4, lang: 'ditaa', source: '+--+' },183+ { index: 5, lang: 'graphviz', source: 'digraph {}' },184+ ]))185+ const result = await response.json() as { artifacts: Array<{ block: number }>; errors: Array<{ block: number; engine: string; error: string }> }186+187+ expect(response.status).toBe(200)188+ expect(result.artifacts.map(({ block }) => block)).toEqual([0, 5])189+ expect(result.errors).toEqual([190+ { block: 1, engine: 'd2', error: 'renderer answered 422: unexpected token' },191+ { block: 2, engine: 'pikchr', error: 'renderer unreachable: connection refused' },192+ { block: 3, engine: 'goat', error: expect.stringContaining('renderer output rejected:') },193+ { block: 4, engine: 'ditaa', error: 'renderer answered 502: bad gateway' },194+ ])195+ })196+197+ it('honours a requested appearance and falls back to raw when the profile lacks it', async () => {198+ unitFetch(call => svgResponse(call.url.startsWith('https://diagramsnet.') ? DIAGRAMSNET_CANONICAL : MERMAID_CANONICAL))199+ const response = await transform(payload([200+ { index: 0, lang: 'mermaid', source: 'graph TD; a-->b' },201+ { index: 1, lang: 'diagramsnet', source: '<mxfile/>' },202+ ], { appearance: 'dark-transparent' }))203+ const result = await response.json() as { artifacts: Array<{ block: number; appearance: string; body: string }> }204+205+ expect(response.status).toBe(200)206+ expect(result.artifacts[0].appearance).toBe('dark-transparent')207+ expect(result.artifacts[0].body).toContain('data-dz-appearance="dark-transparent"')208+ expect(result.artifacts[1].appearance).toBe('raw')209+ expect(result.artifacts[1].body).toContain('data-dz-appearance="raw"')210+ })211+212+ it('rejects unknown appearances and malformed requests', async () => {213+ const appearance = await transform(payload([], { appearance: 'sepia' }))214+ const blocks = await transform(payload([{ index: 0, lang: 'mermaid' }]))215+ const invalidJson = await transform('{')216+217+ expect(appearance.status).toBe(400)218+ expect((await appearance.json() as { error: { code: string } }).error.code).toBe('invalid_appearance')219+ expect(blocks.status).toBe(400)220+ expect(invalidJson.status).toBe(400)221+ expect((await invalidJson.json() as { error: { code: string } }).error.code).toBe('invalid_json')222+ })223+224+ it('enforces the body, block count and block source limits', async () => {225+ const fetchSpy = vi.fn()226+ vi.stubGlobal('fetch', fetchSpy)227+ const oversizedBody = await transform(payload([{ index: 0, lang: 'mermaid', source: 'x'.repeat(1_048_576) }]))228+ const tooManyBlocks = await transform(payload(Array.from({ length: 33 }, (_, index) => ({ index, lang: 'mermaid', source: 'graph TD; a-->b' }))))229+ const oversizedBlock = await transform(payload([{ index: 0, lang: 'mermaid', source: 'x'.repeat(524_289) }]))230+231+ expect(oversizedBody.status).toBe(413)232+ expect((await oversizedBody.json() as { error: { code: string } }).error.code).toBe('request_too_large')233+ expect(tooManyBlocks.status).toBe(413)234+ expect((await tooManyBlocks.json() as { error: { code: string } }).error.code).toBe('too_many_blocks')235+ expect(oversizedBlock.status).toBe(413)236+ expect((await oversizedBlock.json() as { error: { code: string } }).error.code).toBe('block_too_large')237+ expect(fetchSpy).not.toHaveBeenCalled()238+ })239+240+ it('renders at most six blocks at a time', async () => {241+ let active = 0242+ let peak = 0243+ unitFetch(async () => {244+ active += 1245+ peak = Math.max(peak, active)246+ await new Promise(resolve => setTimeout(resolve, 5))247+ active -= 1248+ return svgResponse(MERMAID_CANONICAL)249+ })250+ const response = await transform(payload(Array.from({ length: 16 }, (_, index) => ({ index, lang: 'mermaid', source: 'graph TD; a-->b' }))))251+ const result = await response.json() as { artifacts: unknown[] }252+253+ expect(result.artifacts).toHaveLength(16)254+ expect(peak).toBeLessThanOrEqual(6)255+ expect(peak).toBeGreaterThan(1)256+ })257+})