clone

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

main
diagramzip / services / api / test / aliases.test.ts
aliases.test.ts 19.7K
1import { env, exports } from 'cloudflare:workers'2import { applyD1Migrations } from 'cloudflare:test'3import { beforeAll, describe, expect, it } from 'vitest'4import { canonicalizeSvg } from '../../../shared/svg/index.js'56const capability = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'7const otherCapability = 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA'8const rendererHeaders = {9 'X-Renderer-Unit': 'd2',10 'X-Renderer-Build': 'd2-compat-unit-1',11 'X-Renderer-Pipeline': 'd2',12}13const worker = (exports as unknown as {14 default: { fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> }15}).default1617function payload(source = 'a -> b') {18 return {19 mode: 'open',20 diagram: {21 type: 'd2',22 source,23 options: {},24 presentation: { background: '', padding: 0, frame: false, appearance: 'raw' },25 },26 metadata: { title: 'A to B', description: 'A small diagram.' },27 }28}2930function encryptedBlob(ciphertext = 'AAAAAAAAAAAAAAAAAAAAAA') {31 return {32 v: 1,33 alg: 'A256GCM',34 iv: 'AAAAAAAAAAAAAAAA',35 ciphertext,36 }37}3839function lockedPayload() {40 return {41 mode: 'locked',42 encryptedContent: encryptedBlob('BBBBBBBBBBBBBBBBBBBBBB'),43 encryptedMetadata: encryptedBlob('CCCCCCCCCCCCCCCCCCCCCC'),44 keyEnvelope: {45 v: 1,46 kdf: {47 name: 'PBKDF2',48 hash: 'SHA-256',49 iterations: 600_000,50 salt: 'AAAAAAAAAAAAAAAAAAAAAA',51 },52 wrap: encryptedBlob('DDDDDDDDDDDDDDDDDDDDDD'),53 },54 }55}5657async function create(body: unknown = payload()): Promise<Response> {58 return worker.fetch('https://diagram.zip/api/v1/aliases', {59 method: 'POST',60 headers: {61 Authorization: `Bearer ${capability}`,62 'Content-Type': 'application/json',63 },64 body: JSON.stringify(body),65 })66}6768describe('open aliases', () => {69 beforeAll(async () => {70 await applyD1Migrations(env.DB, env.TEST_MIGRATIONS)71 })7273 it('creates immutable content and a short alias without returning the capability', async () => {74 const response = await create()75 expect(response.status).toBe(201)76 expect(response.headers.get('ETag')).toBe('"1"')77 const result = await response.json<Record<string, unknown>>()78 expect(result.aliasId).toMatch(/^[A-Za-z0-9_-]{16}$/)79 expect(result.contentId).toMatch(/^[A-Za-z0-9_-]{43}$/)80 expect(JSON.stringify(result)).not.toContain(capability)8182 const object = await env.CONTENT.get(`contents/open/${result.contentId as string}.json`)83 expect(object).not.toBeNull()84 expect(await object?.json()).toEqual(payload().diagram)85 })8687 it('reads an alias and honors its revision ETag', async () => {88 const created = await create()89 const { aliasId } = await created.json<{ aliasId: string }>()9091 const response = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}`)92 expect(response.status).toBe(200)93 expect(response.headers.get('ETag')).toBe('"1"')94 expect((await response.json<{ diagram: { source: string } }>()).diagram.source).toBe('a -> b')9596 const unchanged = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}`, {97 headers: { 'If-None-Match': '"1"' },98 })99 expect(unchanged.status).toBe(304)100 })101102 it('updates with the capability and current revision', async () => {103 const created = await create()104 const { aliasId, contentId: originalContentId } = await created.json<{ aliasId: string; contentId: string }>()105 const response = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}`, {106 method: 'PUT',107 headers: {108 Authorization: `Bearer ${capability}`,109 'Content-Type': 'application/json',110 'If-Match': '"1"',111 },112 body: JSON.stringify(payload('a -> c')),113 })114 expect(response.status).toBe(200)115 expect(response.headers.get('ETag')).toBe('"2"')116 const result = await response.json<{ revision: number; contentId: string; diagram: { source: string } }>()117 expect(result.revision).toBe(2)118 expect(result.contentId).not.toBe(originalContentId)119 expect(result.diagram.source).toBe('a -> c')120 })121122 it('rejects an invalid capability without changing the alias', async () => {123 const created = await create()124 const { aliasId } = await created.json<{ aliasId: string }>()125 const response = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}`, {126 method: 'PUT',127 headers: {128 Authorization: `Bearer ${otherCapability}`,129 'Content-Type': 'application/json',130 'If-Match': '"1"',131 },132 body: JSON.stringify(payload('a -> attacker')),133 })134 expect(response.status).toBe(403)135 expect((await env.DB.prepare('SELECT revision FROM aliases WHERE alias_id = ?').bind(aliasId).first<{ revision: number }>())?.revision).toBe(1)136 })137138 it('returns a conflict for a stale update', async () => {139 const created = await create()140 const { aliasId } = await created.json<{ aliasId: string }>()141 const request = (source: string) => worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}`, {142 method: 'PUT',143 headers: {144 Authorization: `Bearer ${capability}`,145 'Content-Type': 'application/json',146 'If-Match': '"1"',147 },148 body: JSON.stringify(payload(source)),149 })150 expect((await request('a -> c')).status).toBe(200)151 const stale = await request('a -> d')152 expect(stale.status).toBe(412)153 expect((await stale.json<{ error: { code: string } }>()).error.code).toBe('revision_conflict')154 })155156 it('requires JSON and a write capability', async () => {157 const noCapability = await worker.fetch('https://diagram.zip/api/v1/aliases', {158 method: 'POST',159 headers: { 'Content-Type': 'application/json' },160 body: JSON.stringify(payload()),161 })162 expect(noCapability.status).toBe(401)163164 const wrongMediaType = await worker.fetch('https://diagram.zip/api/v1/aliases', {165 method: 'POST',166 headers: { Authorization: `Bearer ${capability}`, 'Content-Type': 'text/plain' },167 body: JSON.stringify(payload()),168 })169 expect(wrongMediaType.status).toBe(415)170 })171172 it('rejects a declared oversized body before parsing it', async () => {173 const response = await worker.fetch('https://diagram.zip/api/v1/aliases', {174 method: 'POST',175 headers: {176 Authorization: `Bearer ${capability}`,177 'Content-Length': String(1024 * 1024 + 1),178 'Content-Type': 'application/json',179 },180 body: '{}',181 })182 expect(response.status).toBe(413)183 })184185 it('stores and reads locked aliases without plaintext metadata', async () => {186 const created = await create(lockedPayload())187 expect(created.status).toBe(201)188 const result = await created.json<{ aliasId: string; mode: string; contentId: string }>()189 expect(result.mode).toBe('locked')190191 const row = await env.DB.prepare(`192 SELECT mode, title, description, encrypted_metadata, key_envelope193 FROM aliases WHERE alias_id = ?194 `).bind(result.aliasId).first<{195 mode: string196 title: string | null197 description: string | null198 encrypted_metadata: string | null199 key_envelope: string | null200 }>()201 expect(row?.mode).toBe('locked')202 expect(row?.title).toBeNull()203 expect(row?.description).toBeNull()204 expect(row?.encrypted_metadata).toContain('ciphertext')205 expect(row?.key_envelope).toContain('PBKDF2')206 expect(await env.CONTENT.get(`contents/locked/${result.contentId}.enc`)).not.toBeNull()207208 const read = await worker.fetch(`https://diagram.zip/api/v1/aliases/${result.aliasId}`)209 expect(read.status).toBe(200)210 expect(await read.json()).toMatchObject(lockedPayload())211 })212213 it('can lock and unlock an existing alias without changing its short link', async () => {214 const created = await create()215 const { aliasId } = await created.json<{ aliasId: string }>()216 const lock = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}`, {217 method: 'PUT',218 headers: {219 Authorization: `Bearer ${capability}`,220 'Content-Type': 'application/json',221 'If-Match': '"1"',222 },223 body: JSON.stringify(lockedPayload()),224 })225 expect(lock.status).toBe(200)226 expect((await lock.json<{ mode: string }>()).mode).toBe('locked')227228 const unlock = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}`, {229 method: 'PUT',230 headers: {231 Authorization: `Bearer ${capability}`,232 'Content-Type': 'application/json',233 'If-Match': '"2"',234 },235 body: JSON.stringify(payload()),236 })237 expect(unlock.status).toBe(200)238 const unlocked = await unlock.json<{ aliasId: string; mode: string; revision: number }>()239 expect(unlocked).toMatchObject({ aliasId, mode: 'open', revision: 3 })240 })241242 it('keeps the render identity when only the password envelope changes', async () => {243 const firstPayload = lockedPayload()244 const created = await create(firstPayload)245 const original = await created.json<{ aliasId: string; contentId: string; renderId: string }>()246 const changedEnvelope = {247 ...firstPayload,248 keyEnvelope: {249 ...firstPayload.keyEnvelope,250 kdf: { ...firstPayload.keyEnvelope.kdf, salt: 'BBBBBBBBBBBBBBBBBBBBBB' },251 wrap: encryptedBlob('FFFFFFFFFFFFFFFFFFFFFF'),252 },253 }254 const response = await worker.fetch(`https://diagram.zip/api/v1/aliases/${original.aliasId}`, {255 method: 'PUT',256 headers: {257 Authorization: `Bearer ${capability}`,258 'Content-Type': 'application/json',259 'If-Match': '"1"',260 },261 body: JSON.stringify(changedEnvelope),262 })263 expect(response.status).toBe(200)264 const updated = await response.json<{ contentId: string; renderId: string }>()265 expect(updated.contentId).toBe(original.contentId)266 expect(updated.renderId).toBe(original.renderId)267 })268269 it('changes the render identity but not content identity for metadata-only changes', async () => {270 const created = await create()271 const original = await created.json<{ aliasId: string; contentId: string; renderId: string }>()272 const changed = payload()273 changed.metadata.title = 'A renamed diagram'274 const response = await worker.fetch(`https://diagram.zip/api/v1/aliases/${original.aliasId}`, {275 method: 'PUT',276 headers: {277 Authorization: `Bearer ${capability}`,278 'Content-Type': 'application/json',279 'If-Match': '"1"',280 },281 body: JSON.stringify(changed),282 })283 expect(response.status).toBe(200)284 const updated = await response.json<{ contentId: string; renderId: string }>()285 expect(updated.contentId).toBe(original.contentId)286 expect(updated.renderId).not.toBe(original.renderId)287 })288289 it('stores and serves a content-addressed open SVG render', async () => {290 const created = await create()291 const { aliasId, renderId } = await created.json<{ aliasId: string; renderId: string }>()292 const svg = canonicalizeSvg(293 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>',294 { title: '', description: '' },295 'd2',296 'd2@0.7.1',297 )298 const stored = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}/renders/svg`, {299 method: 'PUT',300 headers: {301 Authorization: `Bearer ${capability}`,302 'Content-Type': 'image/svg+xml',303 'If-Match': '"1"',304 'X-Render-Id': renderId,305 ...rendererHeaders,306 },307 body: svg,308 })309 expect(stored.status).toBe(204)310311 const render = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}/renders/svg`)312 expect(render.status).toBe(200)313 expect(render.headers.get('Content-Type')).toBe('image/svg+xml')314 expect(render.headers.get('Cache-Control')).toContain('max-age=60')315 expect(render.headers.get('X-Renderer-Unit')).toBe('d2')316 expect(render.headers.get('X-Renderer-Build')).toBe('d2-compat-unit-1')317 expect(render.headers.get('X-Renderer-Pipeline')).toBe('d2')318 expect(render.headers.get('X-Diagram-Appearance')).toBe('raw')319 expect(render.headers.get('X-SVG-Schema')).toBe('1')320 expect(render.headers.get('Content-Security-Policy')).toBe("default-src 'none'; style-src 'unsafe-inline'; sandbox")321 expect(await render.text()).toBe(svg)322323 const head = await env.CONTENT.get(`render-heads/open/${renderId}.svg.json`)324 expect(await head?.json()).toEqual({325 unit: 'd2',326 build: 'd2-compat-unit-1',327 pipeline: ['d2'],328 objectKey: `renders/open/d2/d2-compat-unit-1/${renderId}.svg`,329 })330 })331332 it('refreshes a render head for a newer renderer build without advancing the alias revision', async () => {333 const created = await create()334 const { aliasId, renderId } = await created.json<{ aliasId: string; renderId: string }>()335 const first = canonicalizeSvg(336 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><text>old</text></svg>',337 { title: '', description: '' },338 'd2',339 'd2@0.7.1',340 )341 const refreshed = canonicalizeSvg(342 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><text>new</text></svg>',343 { title: '', description: '' },344 'd2',345 'd2@0.7.2',346 )347 const upload = (body: string, build: string) => worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}/renders/svg`, {348 method: 'PUT',349 headers: {350 Authorization: `Bearer ${capability}`,351 'Content-Type': 'image/svg+xml',352 'If-Match': '"1"',353 'X-Render-Id': renderId,354 'X-Renderer-Unit': 'd2',355 'X-Renderer-Build': build,356 'X-Renderer-Pipeline': 'd2',357 },358 body,359 })360361 expect((await upload(first, 'd2-compat-unit-1')).status).toBe(204)362 expect((await upload(refreshed, 'd2-compat-unit-2')).status).toBe(204)363364 const render = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}/renders/svg`)365 expect(render.headers.get('X-Renderer-Build')).toBe('d2-compat-unit-2')366 expect(await render.text()).toBe(refreshed)367 expect((await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}`)).headers.get('ETag')).toBe('"1"')368 expect(await env.CONTENT.get(`renders/open/d2/d2-compat-unit-1/${renderId}.svg`)).not.toBeNull()369 expect(await env.CONTENT.get(`renders/open/d2/d2-compat-unit-2/${renderId}.svg`)).not.toBeNull()370 })371372 it('materializes supported appearances from one canonical SVG object', async () => {373 const created = await create()374 const { aliasId, renderId } = await created.json<{ aliasId: string; renderId: string }>()375 const svg = canonicalizeSvg(376 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 20"><g class="graph"><polygon fill="white" stroke="none" points="0,0 40,0 40,20 0,20"></polygon><g class="node"><rect x="5" y="5" width="30" height="10" fill="white" stroke="black"></rect><text x="10" y="12" fill="black">Node</text></g></g></svg>',377 { title: '', description: '' },378 'graphviz',379 'graphviz@15.1.1',380 )381 const stored = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}/renders/svg`, {382 method: 'PUT',383 headers: {384 Authorization: `Bearer ${capability}`,385 'Content-Type': 'image/svg+xml',386 'If-Match': '"1"',387 'X-Render-Id': renderId,388 'X-Renderer-Unit': 'graphviz',389 'X-Renderer-Build': 'graphviz-wasm-unit-1',390 'X-Renderer-Pipeline': 'graphviz',391 },392 body: svg,393 })394 expect(stored.status).toBe(204)395396 const raw = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}/renders/svg`)397 const framed = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}/renders/svg?appearance=dark-framed`)398 expect(raw.status).toBe(200)399 expect(framed.status).toBe(200)400 expect(framed.headers.get('X-Diagram-Appearance')).toBe('dark-framed')401 expect(framed.headers.get('X-SVG-Materializer')).toBe('svg-materializer-4')402 expect(framed.headers.get('X-SVG-Palette')).toBe('diagramzip-palette-2')403 expect(framed.headers.get('ETag')).not.toBe(raw.headers.get('ETag'))404 const framedSvg = await framed.text()405 expect(framedSvg).toContain('data-dz-appearance="dark-framed"')406 expect(framedSvg).toContain('data-dz-owned="materializer"')407 expect(framedSvg).toContain('viewBox="-24 -24 88 68"')408409 const unchanged = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}/renders/svg?appearance=dark-framed`, {410 headers: { 'If-None-Match': framed.headers.get('ETag')! },411 })412 expect(unchanged.status).toBe(304)413 const objects = await env.CONTENT.list({ prefix: 'renders/open/graphviz/' })414 expect(objects.objects).toHaveLength(1)415 })416417 it('rejects non-canonical SVG uploads and unsupported appearances', async () => {418 const created = await create()419 const { aliasId, renderId } = await created.json<{ aliasId: string; renderId: string }>()420 const stored = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}/renders/svg`, {421 method: 'PUT',422 headers: {423 Authorization: `Bearer ${capability}`,424 'Content-Type': 'image/svg+xml',425 'If-Match': '"1"',426 'X-Render-Id': renderId,427 ...rendererHeaders,428 },429 body: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>',430 })431 expect(stored.status).toBe(422)432 expect((await stored.json<{ error: { code: string } }>()).error.code).toBe('not_canonical')433434 const invalid = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}/renders/svg?appearance=sepia`)435 expect(invalid.status).toBe(400)436 expect((await invalid.json<{ error: { code: string } }>()).error.code).toBe('invalid_appearance')437 })438439 it('stores locked renders opaquely and refuses an embed response', async () => {440 const created = await create(lockedPayload())441 const { aliasId, renderId } = await created.json<{ aliasId: string; renderId: string }>()442 const encryptedRender = encryptedBlob('EEEEEEEEEEEEEEEEEEEEEE')443 const stored = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}/renders/png`, {444 method: 'PUT',445 headers: {446 Authorization: `Bearer ${capability}`,447 'Content-Type': 'application/json',448 'If-Match': '"1"',449 'X-Render-Id': renderId,450 ...rendererHeaders,451 },452 body: JSON.stringify(encryptedRender),453 })454 expect(stored.status).toBe(204)455456 const embed = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}/renders/png`)457 expect(embed.status).toBe(403)458 expect((await embed.json<{ error: { code: string } }>()).error.code).toBe('encrypted_diagram_not_embeddable')459460 const encrypted = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}/renders/png?encrypted=1`)461 expect(encrypted.status).toBe(200)462 expect(await encrypted.json()).toEqual(encryptedRender)463464 const themed = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}/renders/png?encrypted=1&appearance=dark-framed`)465 expect(themed.status).toBe(422)466 expect((await themed.json<{ error: { code: string } }>()).error.code).toBe('encrypted_appearance_unavailable')467 })468469 it('rejects a stale render upload after an alias update', async () => {470 const created = await create()471 const { aliasId, renderId } = await created.json<{ aliasId: string; renderId: string }>()472 const updated = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}`, {473 method: 'PUT',474 headers: {475 Authorization: `Bearer ${capability}`,476 'Content-Type': 'application/json',477 'If-Match': '"1"',478 },479 body: JSON.stringify(payload('a -> updated')),480 })481 expect(updated.status).toBe(200)482483 const stale = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}/renders/svg`, {484 method: 'PUT',485 headers: {486 Authorization: `Bearer ${capability}`,487 'Content-Type': 'image/svg+xml',488 'If-Match': '"1"',489 'X-Render-Id': renderId,490 ...rendererHeaders,491 },492 body: '<svg xmlns="http://www.w3.org/2000/svg"></svg>',493 })494 expect(stale.status).toBe(412)495 })496})