Files
recflare/apps/img/src/test/integration/api.test.ts
T
2026-10-21 22:14:43 -04:00

513 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { PhotonImage } from '@cf-wasm/photon'
import { createExecutionContext, env, SELF, waitOnExecutionContext } from 'cloudflare:test'
import { beforeAll, describe, expect, it } from 'vitest'
import app from '../../img.app'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const ORIGIN = 'https://example.com'
/** Decode a JPEG and read back its pixel dimensions. */
function jpegSize(bytes: Uint8Array): { width: number; height: number } {
const img = PhotonImage.new_from_byteslice(bytes)
try {
return { width: img.get_width(), height: img.get_height() }
} finally {
img.free()
}
}
// A tiny valid JPEG magic-number blob — enough to assert round-tripping.
const IMAGE_BYTES = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46])
// Public half (SPKI DER, base64) of the dev IMG_SIGNING_KEY in wrangler.jsonc —
// used to verify the Content-Signature header.
const PUBLIC_SPKI_B64 =
'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1EIcBzPCvOFRy3WYuG8ICaRyr/OpotABJBpiMq2zcZHsSPXQw7NC+N082JDqYLy627oB9qJ+wC3idtbzFTANLkIYIEWMWJC9hjWl56vBVXOIroji2+lOpR4hV9JRdgmJfBYXmJPtHRP4GAl8np9xcnZpbMJdauR+HIJiQT3QHc2RomLXWCUfOb564cW8Ks7CLlmXPWf4M77DufHhY+788uWq6bI0+QSJ1qrUi3gaou0HPj7YPTl7pUTwX4VOmHKN5Nw+/jB9f2JNpRKp9niylCVUgdHnmHz5iqMW86HRf7EJcalSyYn7cC6b1ng9GPYryybipZ7QuTgl52qu2GQDaQIDAQAB'
// An R2-only key that has no matching file in `static/`, so it exercises the
// bucket path rather than a static asset.
const R2_KEY = 'user-photo.jpg'
// An extensionless name, as returned by the `storage` worker for a FileType 3
// upload — served from `recflare-cdn` under `image/`, not `recflare-img`.
const CDN_NAME = '2028-06-01/12345-67890-12345'
/**
* Fetch with real signing turned OFF — the deployed default. `vitest.config.ts`
* binds `IMG_SIGNING_ENABLED` on so the RSA path stays covered, so the placeholder
* path has to drive the app directly with an overridden env.
*/
async function unsignedFetch(url: string): Promise<Response> {
const ctx = createExecutionContext()
const res = await app.fetch(new Request(url), { ...env, IMG_SIGNING_ENABLED: false }, ctx)
await waitOnExecutionContext(ctx)
return res
}
beforeAll(async () => {
await env.IMAGES.put(R2_KEY, IMAGE_BYTES, {
httpMetadata: { contentType: 'image/jpeg' },
})
await env.CDN_ASSETS.put(`image/${CDN_NAME}`, IMAGE_BYTES, {
httpMetadata: { contentType: 'image/jpeg' },
})
// Seed R2 with a key that ALSO exists in `static/` to prove static wins.
await env.IMAGES.put('3DCharades.jpg', IMAGE_BYTES, {
httpMetadata: { contentType: 'image/jpeg' },
})
})
describe('img endpoints', () => {
it('GET / reports service status', async () => {
const res = await SELF.fetch(`${ORIGIN}/`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ service: 'img', status: 'ok' })
})
// An upload keeps whatever content-type the uploader claimed, so a stored HTML
// document would otherwise render — and run — on this origin. Every response says
// `nosniff` and carries a sandboxing CSP so the body is inert whatever it is.
it('serves every response with headers that stop a stored HTML upload executing', async () => {
const HTML_KEY = 'not-an-image.jpg'
await env.IMAGES.put(HTML_KEY, '<script>alert(1)</script>', {
httpMetadata: { contentType: 'text/html' },
})
const html = await SELF.fetch(`${ORIGIN}/${HTML_KEY}`)
expect(html.status).toBe(200)
expect(html.headers.get('x-content-type-options')).toBe('nosniff')
expect(html.headers.get('content-security-policy')).toBe("default-src 'none'; sandbox")
// The stored object, the static asset, the fallback, a resize, and a 304.
const paths = [
`/${R2_KEY}`,
`/${CDN_NAME}`,
'/does-not-exist-anywhere.jpg',
'/3DCharades.jpg?width=128',
'/',
]
for (const path of paths) {
const res = await SELF.fetch(`${ORIGIN}${path}`)
expect(res.status, path).toBe(200)
expect(res.headers.get('x-content-type-options'), path).toBe('nosniff')
expect(res.headers.get('content-security-policy'), path).toBe("default-src 'none'; sandbox")
}
const etag = (await SELF.fetch(`${ORIGIN}/${R2_KEY}`)).headers.get('etag')
const notModified = await SELF.fetch(`${ORIGIN}/${R2_KEY}`, {
headers: { 'if-none-match': etag! },
})
expect(notModified.status).toBe(304)
expect(notModified.headers.get('x-content-type-options')).toBe('nosniff')
})
it('streams an image stored in R2 with its content type', async () => {
const res = await SELF.fetch(`${ORIGIN}/${R2_KEY}`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('image/jpeg')
expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES)
})
// Images are served with the same 30-day window `cdn` uses for its blobs, plus
// `immutable` — an image never changes under its key, a new one takes a new key.
it('every served image carries the 30-day Cache-Control', async () => {
const CACHE_CONTROL = `public, max-age=${86400 * 30}, immutable`
// The stored object, a static asset, and the fallback for a key in neither.
for (const path of [`/${R2_KEY}`, `/${CDN_NAME}`, '/does-not-exist-anywhere.jpg']) {
const res = await SELF.fetch(`${ORIGIN}${path}`)
expect(res.status, path).toBe(200)
expect(res.headers.get('cache-control'), path).toBe(CACHE_CONTROL)
}
// And the transformed variant, which is rebuilt rather than streamed. Uses a real
// (decodable) JPEG, since a resize has to actually run for this path to be reached.
const resized = await SELF.fetch(`${ORIGIN}/3DCharades.jpg?width=128`)
expect(resized.status).toBe(200)
expect(resized.headers.get('cache-control')).toBe(CACHE_CONTROL)
})
it('serves an extensionless key from the cdn bucket under image/', async () => {
const res = await SELF.fetch(`${ORIGIN}/${CDN_NAME}`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('image/jpeg')
expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES)
})
it('does not look for an extensionless key in the image bucket', async () => {
// Same bare name seeded into `recflare-img` instead: extensionless keys only
// ever resolve against `recflare-cdn`, so this falls through to the default.
await env.IMAGES.put('2028-06-02/only-in-img', IMAGE_BYTES)
const res = await SELF.fetch(`${ORIGIN}/2028-06-02/only-in-img`)
expect(res.status).toBe(200)
const body = new Uint8Array(await res.arrayBuffer())
expect(body.length).toBeGreaterThan(IMAGE_BYTES.length)
})
it('resizes an extensionless cdn image', async () => {
// Exercises the transform path against the cdn bucket, not just the stream-through.
// Needs a decodable JPEG, so reuse a bundled static asset's bytes.
const real = await (await SELF.fetch(`${ORIGIN}/3DCharades.jpg`)).arrayBuffer()
await env.CDN_ASSETS.put('image/2028-06-03/real-photo', real, {
httpMetadata: { contentType: 'image/jpeg' },
})
const res = await SELF.fetch(`${ORIGIN}/2028-06-03/real-photo?width=128`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('image/jpeg')
expect(res.headers.get('etag')).toBeNull()
expect(jpegSize(new Uint8Array(await res.arrayBuffer())).width).toBe(128)
})
it('honours the 64 width some newer endpoints request', async () => {
const res = await SELF.fetch(`${ORIGIN}/3DCharades.jpg?width=64`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('image/jpeg')
expect(jpegSize(new Uint8Array(await res.arrayBuffer())).width).toBe(64)
})
it('serves a static asset in preference to an R2 object of the same key', async () => {
const res = await SELF.fetch(`${ORIGIN}/3DCharades.jpg`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toMatch(/^image\/jpeg/)
// The bundled static JPEG, not the tiny IMAGE_BYTES stub seeded into R2.
const body = new Uint8Array(await res.arrayBuffer())
expect(body.length).toBeGreaterThan(IMAGE_BYTES.length)
expect(body[0]).toBe(0xff)
expect(body[1]).toBe(0xd8)
})
it('serves a nested static asset', async () => {
const res = await SELF.fetch(`${ORIGIN}/Base/Clearcut.jpg`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toMatch(/^image\/jpeg/)
const body = new Uint8Array(await res.arrayBuffer())
expect(body.length).toBeGreaterThan(0)
expect(body[0]).toBe(0xff)
expect(body[1]).toBe(0xd8)
})
it('returns 304 when If-None-Match matches the etag', async () => {
const first = await SELF.fetch(`${ORIGIN}/${R2_KEY}`)
const etag = first.headers.get('etag')
expect(etag).toBeTruthy()
const res = await SELF.fetch(`${ORIGIN}/${R2_KEY}`, {
headers: { 'If-None-Match': etag! },
})
expect(res.status).toBe(304)
})
it('honors a Range request on the stored image with a 206', async () => {
const res = await SELF.fetch(`${ORIGIN}/${R2_KEY}`, { headers: { Range: 'bytes=2-4' } })
expect(res.status).toBe(206)
expect(res.headers.get('content-range')).toBe('bytes 2-4/8')
expect(res.headers.get('accept-ranges')).toBe('bytes')
expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES.slice(2, 5))
})
// R2 resolves a range it cannot parse or satisfy to the WHOLE object rather than
// failing. Handing that back as a bare 200 is the shape that corrupts a chunked
// download — the client wrote a whole file where it expected a slice — so every one
// of these still states what the body holds.
it('never answers a bytes range with a whole-object 200', async () => {
for (const range of ['bytes=100-200', 'bytes=abc', 'bytes=0-1,3-4', 'bytes=0-7']) {
const res = await SELF.fetch(`${ORIGIN}/${R2_KEY}`, { headers: { Range: range } })
expect(res.status, range).toBe(206)
expect(res.headers.get('content-range'), range).toBe('bytes 0-7/8')
}
// A unit other than bytes must be ignored outright (RFC 9110), not answered with
// a byte-denominated Content-Range.
const other = await SELF.fetch(`${ORIGIN}/${R2_KEY}`, { headers: { Range: 'items=0-1' } })
expect(other.status).toBe(200)
expect(other.headers.get('content-range')).toBeNull()
})
// A resize decodes the whole image, so there is no meaningful slice of the source to
// read — the range is ignored and the whole transformed result served, which is the
// legal answer. What it must NOT do is claim a 206 over bytes it rebuilt. Runs against
// the R2 path (a decodable JPEG borrowed from `static/`), since that is the one that
// has a range to suppress; the static-asset path is never handed one at all.
it('ignores a Range when a transform rebuilds the body', async () => {
const real = await (await SELF.fetch(`${ORIGIN}/3DCharades.jpg`)).arrayBuffer()
await env.IMAGES.put('ranged-transform.jpg', real, {
httpMetadata: { contentType: 'image/jpeg' },
})
const res = await SELF.fetch(`${ORIGIN}/ranged-transform.jpg?width=128`, {
headers: { Range: 'bytes=0-9' },
})
expect(res.status).toBe(200)
expect(res.headers.get('content-range')).toBeNull()
expect(res.headers.get('accept-ranges')).toBeNull()
expect(jpegSize(new Uint8Array(await res.arrayBuffer())).width).toBe(128)
// Same for a real RSA signature, which covers the whole body (the test env binds
// IMG_SIGNING_ENABLED on, so `?sig=p1` takes the signing path rather than the stub).
const signed = await SELF.fetch(`${ORIGIN}/ranged-transform.jpg?sig=p1`, {
headers: { Range: 'bytes=0-9' },
})
expect(signed.status).toBe(200)
expect(signed.headers.get('content-range')).toBeNull()
expect(signed.headers.get('content-signature')).toContain('key-id=KEY:RSA:p1.rec.net')
expect(new Uint8Array(await signed.arrayBuffer()).byteLength).toBe(real.byteLength)
})
it('serves the DefaultProfileImage.jpg fallback for a missing image', async () => {
const res = await SELF.fetch(`${ORIGIN}/missing.png`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toMatch(/^image\/jpeg/)
const body = new Uint8Array(await res.arrayBuffer())
// Real JPEG static asset: SOI marker + non-empty body.
expect(body.length).toBeGreaterThan(0)
expect(body[0]).toBe(0xff)
expect(body[1]).toBe(0xd8)
})
it('signs the DefaultProfileImage.jpg fallback with ?sig=p1', async () => {
const res = await SELF.fetch(`${ORIGIN}/missing.png?sig=p1`)
expect(res.status).toBe(200)
const header = res.headers.get('content-signature')
expect(header).toMatch(/^key-id=KEY:RSA:p1\.rec\.net; data=/)
const signatureB64 = header!.split('data=')[1]
const signature = Uint8Array.from(atob(signatureB64), (ch) => ch.charCodeAt(0))
const body = new Uint8Array(await res.arrayBuffer())
const publicKey = await crypto.subtle.importKey(
'spki',
Uint8Array.from(atob(PUBLIC_SPKI_B64), (ch) => ch.charCodeAt(0)),
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-1' },
false,
['verify']
)
const ok = await crypto.subtle.verify('RSASSA-PKCS1-v1_5', publicKey, signature, body)
expect(ok).toBe(true)
})
it('signs the response with ?sig=p1 and the signature verifies', async () => {
const res = await SELF.fetch(`${ORIGIN}/${R2_KEY}?sig=p1`)
expect(res.status).toBe(200)
const header = res.headers.get('content-signature')
expect(header).toMatch(/^key-id=KEY:RSA:p1\.rec\.net; data=/)
const signatureB64 = header!.split('data=')[1]
const signature = Uint8Array.from(atob(signatureB64), (ch) => ch.charCodeAt(0))
const body = new Uint8Array(await res.arrayBuffer())
const publicKey = await crypto.subtle.importKey(
'spki',
Uint8Array.from(atob(PUBLIC_SPKI_B64), (ch) => ch.charCodeAt(0)),
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-1' },
false,
['verify']
)
const ok = await crypto.subtle.verify('RSASSA-PKCS1-v1_5', publicKey, signature, body)
expect(ok).toBe(true)
})
it('does not sign without ?sig=p1', async () => {
const res = await SELF.fetch(`${ORIGIN}/${R2_KEY}`)
expect(res.headers.get('content-signature')).toBeNull()
})
it('returns a placeholder signature when IMG_SIGNING_ENABLED is off', async () => {
// The deployed default (see wrangler.jsonc). The header must still be there —
// the client requires it — but the value is derived from the key, so the body
// is neither buffered nor hashed and streams straight out of R2.
const res = await unsignedFetch(`${ORIGIN}/${R2_KEY}?sig=p1`)
expect(res.status).toBe(200)
const header = res.headers.get('content-signature')
expect(header).toMatch(/^key-id=KEY:RSA:p1\.rec\.net; data=/)
// Same shape as a real RSA-2048 signature, so the client's parser sees no
// difference between the two modes.
const signature = Uint8Array.from(atob(header!.split('data=')[1]), (ch) => ch.charCodeAt(0))
expect(signature.length).toBe(256)
expect(signature.some((b) => b !== 0)).toBe(true)
// Still on the streaming path: the source etag survives and the bytes are the
// stored object, untouched.
expect(res.headers.get('etag')).toBeTruthy()
expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES)
})
it('derives the placeholder signature from the key, stably', async () => {
const sigFor = async (path: string) =>
(await unsignedFetch(`${ORIGIN}/${path}?sig=p1`)).headers.get('content-signature')
// Stable for a key, so a cached response and a fresh one agree...
expect(await sigFor(R2_KEY)).toBe(await sigFor(R2_KEY))
// ...and distinct across keys, so it isn't a single hardcoded constant.
expect(await sigFor(R2_KEY)).not.toBe(await sigFor(CDN_NAME))
})
it('signs the fallback and resized bodies with a placeholder too', async () => {
// The fallback (missing key) and the transform path both go through
// serveStaticAsset/finalizeImage — the header must survive both.
const fallback = await unsignedFetch(`${ORIGIN}/missing.png?sig=p1`)
expect(fallback.headers.get('content-signature')).toMatch(/^key-id=KEY:RSA:p1\.rec\.net; /)
const resized = await unsignedFetch(`${ORIGIN}/RecCenter.jpg?width=512&sig=p1`)
expect(resized.headers.get('content-signature')).toMatch(/^key-id=KEY:RSA:p1\.rec\.net; /)
expect(jpegSize(new Uint8Array(await resized.arrayBuffer())).width).toBe(512)
})
it('resizes a static asset to ?width, preserving aspect ratio', async () => {
const full = new Uint8Array(await (await SELF.fetch(`${ORIGIN}/RecCenter.jpg`)).arrayBuffer())
const original = jpegSize(full)
const res = await SELF.fetch(`${ORIGIN}/RecCenter.jpg?width=512`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('image/jpeg')
// Resized responses carry no etag (the source etag no longer describes them).
expect(res.headers.get('etag')).toBeNull()
const body = new Uint8Array(await res.arrayBuffer())
const resized = jpegSize(body)
expect(resized.width).toBe(512)
// Height scales with the source aspect ratio (allow 1px rounding).
expect(resized.height).toBeCloseTo(Math.round((original.height / original.width) * 512), -0.5)
})
it('center-crops to a square and resizes with ?cropSquare=1&width', async () => {
const res = await SELF.fetch(`${ORIGIN}/RecCenter.jpg?width=256&cropSquare=1`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('image/jpeg')
const size = jpegSize(new Uint8Array(await res.arrayBuffer()))
expect(size.width).toBe(256)
expect(size.height).toBe(256)
})
it('keeps a PNG source as PNG when resizing, alpha intact', async () => {
// A 64×64 RGBA PNG: left half opaque red, right half fully transparent. Built
// through Photon so the bytes are a real PNG with an alpha channel.
const side = 64
const rgba = new Uint8Array(side * side * 4)
for (let y = 0; y < side; y++) {
for (let x = 0; x < side; x++) {
const i = (y * side + x) * 4
rgba[i] = 255
rgba[i + 3] = x < side / 2 ? 255 : 0
}
}
const src = new PhotonImage(rgba, side, side)
const pngBytes = src.get_bytes()
src.free()
// Stored under a `.bin` name with a claimed PNG type, like a custom avatar item's
// design; the codec must go by the bytes, not the name.
await env.IMAGES.put('avatar-item/2026-08-26/abc-design.bin', pngBytes, {
httpMetadata: { contentType: 'image/png' },
})
const res = await SELF.fetch(`${ORIGIN}/avatar-item/2026-08-26/abc-design.bin?width=128`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('image/png')
expect(res.headers.get('etag')).toBeNull()
const out = PhotonImage.new_from_byteslice(new Uint8Array(await res.arrayBuffer()))
try {
expect(out.get_width()).toBe(128)
const px = out.get_raw_pixels()
const alphaAt = (x: number, y: number) => px[(y * 128 + x) * 4 + 3]
// Deep inside each half, away from the Lanczos edge: opaque left, transparent right.
expect(alphaAt(16, 64)).toBe(255)
expect(alphaAt(112, 64)).toBe(0)
} finally {
out.free()
}
})
it('crops to a square at native size with ?cropSquare=1 alone', async () => {
const full = jpegSize(
new Uint8Array(await (await SELF.fetch(`${ORIGIN}/RecCenter.jpg`)).arrayBuffer())
)
const res = await SELF.fetch(`${ORIGIN}/RecCenter.jpg?cropSquare=1`)
expect(res.status).toBe(200)
const size = jpegSize(new Uint8Array(await res.arrayBuffer()))
expect(size.width).toBe(size.height)
// The square's side is the shorter source dimension.
expect(size.width).toBe(Math.min(full.width, full.height))
})
it('ignores an invalid ?width and serves the original', async () => {
const full = new Uint8Array(await (await SELF.fetch(`${ORIGIN}/RecCenter.jpg`)).arrayBuffer())
const res = await SELF.fetch(`${ORIGIN}/RecCenter.jpg?width=0`)
expect(res.status).toBe(200)
expect(new Uint8Array(await res.arrayBuffer())).toEqual(full)
})
it('ignores a ?width outside the allowed sizes and serves the original', async () => {
const full = new Uint8Array(await (await SELF.fetch(`${ORIGIN}/RecCenter.jpg`)).arrayBuffer())
// 300 isn't one of 64/128/256/512/1024, so it's rejected and the source served.
const res = await SELF.fetch(`${ORIGIN}/RecCenter.jpg?width=300`)
expect(res.status).toBe(200)
expect(new Uint8Array(await res.arrayBuffer())).toEqual(full)
})
it('signs the resized body with ?width and ?sig=p1', async () => {
const res = await SELF.fetch(`${ORIGIN}/RecCenter.jpg?width=512&sig=p1`)
expect(res.status).toBe(200)
expect(jpegSize(new Uint8Array(await res.clone().arrayBuffer())).width).toBe(512)
const header = res.headers.get('content-signature')
expect(header).toMatch(/^key-id=KEY:RSA:p1\.rec\.net; data=/)
const signature = Uint8Array.from(atob(header!.split('data=')[1]), (ch) => ch.charCodeAt(0))
const body = new Uint8Array(await res.arrayBuffer())
const publicKey = await crypto.subtle.importKey(
'spki',
Uint8Array.from(atob(PUBLIC_SPKI_B64), (ch) => ch.charCodeAt(0)),
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-1' },
false,
['verify']
)
// The signature must verify over the RESIZED bytes the client receives.
const ok = await crypto.subtle.verify('RSASSA-PKCS1-v1_5', publicKey, signature, body)
expect(ok).toBe(true)
})
it('GET /openapi.json documents every route', async () => {
const res = await SELF.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
const spec = (await res.json()) as {
openapi: string
paths: Record<string, Record<string, { summary?: string }>>
}
expect(spec.openapi).toMatch(/^3\.1/)
// The spec route hides itself.
expect(spec.paths['/openapi.json']).toBeUndefined()
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently shipping
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual(['GET /', 'GET /{key}'])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
for (const ops of Object.values(spec.paths)) {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
// Schemas must inline: a `$ref` here is a dangling reference (see openapi.ts).
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
})
})