mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
513 lines
22 KiB
TypeScript
513 lines
22 KiB
TypeScript
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)
|
||
})
|
||
})
|