import { request as httpRequest } from 'node:http'; import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from 'vitest'; import type { ContentHash } from '../src/asset/blob.js'; import { HttpAssetStore } from '../src/asset/http.js'; import { __setAssetIdFactoryForTesting, type AssetId } from '../src/asset/id.js'; import { PgAssetByteStore } from '../src/asset/pg-bytes.js'; import type { Queryable } from '../src/asset/pg.js'; import { createAssetHttpHandler } from '../src/server/asset.js'; import type { AssetPrincipal, AssetStore } from '../src/asset/types.js'; import { FOREIGN_IDS, commonDigestEncodings, expectNoDigestSubstring, runAssetStoreContract, } from './asset-contract.js'; import { startAssetConformanceServer, type AssetConformanceServer, type AssetConformanceServerOptions, } from './asset-conformance-server.js'; import { blobForObjectUrl } from './setup.js'; interface RawResponse { status: number; headers: Record; body: Buffer; } let server: AssetConformanceServer; let namespace = 0; const stores: HttpAssetStore[] = []; const blob = (value: string, type = 'text/plain'): Blob => new Blob([value], { type }); function makeStore(principal = 'principal-a', storeId = `asset-${namespace++}`): HttpAssetStore { const store = new HttpAssetStore({ baseUrl: server.baseUrl, fetch: server.fetch, headers: () => ({ 'x-asset-store-id': storeId, 'x-asset-principal': principal, }), }); stores.push(store); return store; } function rawRequest(options: { method: string; path: string; headers?: Record; body?: Buffer | string; }): Promise { const url = new URL(server.baseUrl); const body = typeof options.body === 'string' ? Buffer.from(options.body) : options.body; const headers = { ...(options.headers ?? {}), ...(body === undefined ? {} : { 'content-length': String(body.byteLength) }), }; return new Promise((resolve, reject) => { const req = httpRequest( { hostname: url.hostname, port: url.port, method: options.method, path: options.path, headers, }, (res) => { const chunks: Buffer[] = []; res.on('data', (chunk: Buffer) => chunks.push(chunk)); res.on('end', () => { resolve({ status: res.statusCode ?? 0, headers: res.headers, body: Buffer.concat(chunks), }); }); }, ); req.on('error', reject); if (body !== undefined) req.write(body); req.end(); }); } interface Part { name: string; value: string | Uint8Array; contentType?: string; filename?: string | null; extraHeaders?: Record; } function multipart(parts: readonly Part[], boundary = 'asset-test-boundary'): Buffer { const chunks: Buffer[] = []; for (const part of parts) { const filename = part.filename === undefined ? part.name === 'meta' ? 'metadata.json' : part.name === 'bytes' ? 'asset' : undefined : part.filename; chunks.push( Buffer.from( `--${boundary}\r\nContent-Disposition: form-data; name="${part.name}"${filename === null || filename === undefined ? '' : `; filename="${filename}"`}\r\n`, ), ); if (part.contentType !== undefined) { chunks.push(Buffer.from(`Content-Type: ${part.contentType}\r\n`)); } for (const [name, value] of Object.entries(part.extraHeaders ?? {})) { chunks.push(Buffer.from(`${name}: ${value}\r\n`)); } chunks.push(Buffer.from('\r\n'), Buffer.from(part.value), Buffer.from('\r\n')); } chunks.push(Buffer.from(`--${boundary}--\r\n`)); return Buffer.concat(chunks); } function multipartHeaders( storeId: string, principal = 'principal-a', boundary = 'asset-test-boundary', ): Record { return { 'content-type': `multipart/form-data; boundary=${boundary}`, 'x-asset-store-id': storeId, 'x-asset-principal': principal, }; } function comparable(response: RawResponse): unknown { return { status: response.status, headers: Object.fromEntries( Object.entries(response.headers).filter(([name]) => name !== 'connection'), ), body: response.body.toString('base64'), }; } const ASSET_ROUTES = [ ['POST', '/assets'], ['GET', '/assets/id/content'], ['HEAD', '/assets/id/content'], ['PUT', '/assets/id/content'], ['DELETE', '/assets/id'], ] as const; function unreachableRegistry(): { store: AssetStore; methods: Array> } { const fail = () => { throw new Error('asset registry was reached'); }; const store = { put: vi.fn(fail), identify: vi.fn(fail), resolve: vi.fn(fail), remove: vi.fn(fail), replace: vi.fn(fail), } satisfies AssetStore; return { store, methods: Object.values(store) }; } async function expectAuthRejection( options: Pick, expectedStatus: number, expectedCode: string, ): Promise { const registry = unreachableRegistry(); const isolated = await startAssetConformanceServer({ ...options, store: () => registry.store, }); try { for (const [method, path] of ASSET_ROUTES) { const response = await isolated.fetch(`${isolated.baseUrl}${path}`, { method }); expect(response.status, `${method} ${path}`).toBe(expectedStatus); if (method === 'HEAD') { expect(response.headers.get('x-error-code'), `${method} ${path}`).toBe(expectedCode); } else { await expect(response.json(), `${method} ${path}`).resolves.toMatchObject({ error: { code: expectedCode }, }); } for (const registryMethod of registry.methods) { expect(registryMethod, `${method} ${path}`).not.toHaveBeenCalled(); } } } finally { await isolated.close(); } } beforeAll(async () => { server = await startAssetConformanceServer(); }); afterEach(async () => { __setAssetIdFactoryForTesting(null); await Promise.all(stores.splice(0).map((store) => store.close())); }); afterAll(async () => { await server.close(); }); runAssetStoreContract( 'HttpAssetStore over PgAssetStore (PGlite)', { makeStore: () => makeStore(), withAllocator: async (allocator, run) => { __setAssetIdFactoryForTesting(allocator); try { return await run(); } finally { __setAssetIdFactoryForTesting(null); } }, }, async (url) => { const stored = blobForObjectUrl(url); if (!stored) throw new Error('object URL is not registered'); return new Uint8Array(await stored.arrayBuffer()); }, ); describe('asset HTTP handler contract', () => { test('exposes revisions on successful cross-origin writes', async () => { const storeId = `write-revision-${namespace++}`; const allocation = await rawRequest({ method: 'POST', path: '/assets', headers: multipartHeaders(storeId), body: multipart([ { name: 'meta', value: '{}', contentType: 'application/json' }, { name: 'bytes', value: 'original', contentType: 'text/plain' }, ]), }); expect(allocation.status).toBe(201); expect(allocation.headers['x-asset-revision']).toBe('1'); expect(allocation.headers['access-control-expose-headers']).toBe('X-Asset-Revision'); const id = (JSON.parse(allocation.body.toString()) as { id: string }).id; const replacement = await rawRequest({ method: 'PUT', path: `/assets/${encodeURIComponent(id)}/content`, headers: multipartHeaders(storeId), body: multipart([{ name: 'bytes', value: 'replacement', contentType: 'text/plain' }]), }); expect(replacement.status).toBe(204); expect(replacement.headers['x-asset-revision']).toBe('2'); expect(replacement.headers['access-control-expose-headers']).toBe('X-Asset-Revision'); }); test('rejects unauthenticated requests before reaching the registry', async () => { await expectAuthRejection({ authenticate: async () => undefined }, 401, 'UNAUTHENTICATED'); }); test('maps a malformed authenticated principal to an internal error before the registry', async () => { const errorLog = vi.spyOn(console, 'error').mockImplementation(() => undefined); try { await expectAuthRejection( { authenticate: async () => null as unknown as AssetPrincipal }, 500, 'INTERNAL_ERROR', ); } finally { errorLog.mockRestore(); } }); test('forbids a principal without an asset key on every route before the registry', async () => { await expectAuthRejection( { authenticate: async () => ({}) as AssetPrincipal }, 403, 'FORBIDDEN_ASSETS', ); }); test('applies authorizeAssets denial uniformly before the registry', async () => { await expectAuthRejection( { authenticate: async () => ({ key: 'principal' }), authorizeAssets: async () => false, }, 403, 'FORBIDDEN_ASSETS', ); }); test('HEAD and GET have identical outcomes and headers for owned and missing ids', async () => { const storeId = `head-parity-${namespace++}`; const owner = makeStore('owner', storeId); const other = makeStore('other', storeId); const ownId = await owner.put(blob('owned bytes', 'image/png')); const foreignId = await other.put(blob('foreign bytes', 'image/png')); const deletedId = await owner.put(blob('deleted bytes', 'image/png')); await owner.remove(deletedId); const cases = [ ['own', ownId], ['foreign', foreignId], ['never-allocated', 'ast_never_allocated'], ['already-deleted', deletedId], ] as const; const requestHeaders = { 'x-asset-store-id': storeId, 'x-asset-principal': 'owner', }; for (const [kind, id] of cases) { const path = `/assets/${encodeURIComponent(id)}/content`; const [get, head] = await Promise.all([ rawRequest({ method: 'GET', path, headers: requestHeaders }), rawRequest({ method: 'HEAD', path, headers: requestHeaders }), ]); const getHeaders = get.headers; const headHeaders = head.headers; expect(head.status, kind).toBe(get.status); expect(headHeaders, kind).toEqual(getHeaders); expect(head.body, kind).toHaveLength(0); if (kind === 'own') { expect(get.status).toBe(200); expect(get.body).toEqual(Buffer.from('owned bytes')); expect(getHeaders).toEqual({ 'access-control-expose-headers': 'X-Asset-Revision, X-Error-Code', 'cache-control': 'private, no-store', connection: 'keep-alive', 'content-length': '11', 'content-type': 'image/png', 'keep-alive': 'timeout=5', vary: 'Cookie, Authorization', 'x-asset-revision': '1', 'x-content-type-options': 'nosniff', }); } else { expect(get.status).toBe(404); expect(JSON.parse(get.body.toString())).toEqual({ error: { code: 'ASSET_NOT_FOUND', message: '@openmaic/storage: no asset is stored under that id', }, }); expect(getHeaders).toEqual({ 'access-control-expose-headers': 'X-Asset-Revision, X-Error-Code', connection: 'keep-alive', 'content-length': '100', 'content-type': 'application/json', 'keep-alive': 'timeout=5', 'x-error-code': 'ASSET_NOT_FOUND', }); } } }); test('HEAD reads registry identity without reaching the byte layer', async () => { class FailingReadByteStore extends PgAssetByteStore { override async read(_hash: ContentHash): Promise { throw new Error('HEAD reached the byte layer'); } override async readWith( _queryable: Queryable, _hash: ContentHash, ): Promise { throw new Error('HEAD reached the byte layer'); } } const isolated = await startAssetConformanceServer({ byteStore: (db) => new FailingReadByteStore(db), }); const store = new HttpAssetStore({ baseUrl: isolated.baseUrl, fetch: isolated.fetch, headers: () => ({ 'x-asset-principal': 'owner' }), }); try { const id = await store.put(blob('identity only', 'image/png')); const response = await isolated.fetch(`${isolated.baseUrl}/assets/${id}/content`, { method: 'HEAD', headers: { 'x-asset-principal': 'owner' }, }); expect(response.status).toBe(200); expect(response.headers.get('content-type')).toBe('image/png'); expect(response.headers.get('content-length')).toBe('13'); expect(response.headers.get('x-asset-revision')).toBe('1'); expect((await response.arrayBuffer()).byteLength).toBe(0); } finally { await store.close(); await isolated.close(); } }); test('foreign, absent, deleted, and malformed-shape ids are indistinguishable on every id route', async () => { const storeId = `matrix-${namespace++}`; const owner = makeStore('owner', storeId); const other = makeStore('other', storeId); const ownId = await owner.put(blob('own')); const foreignId = await other.put(blob('foreign')); const deletedId = await owner.put(blob('deleted')); await owner.remove(deletedId); const ids = { other: foreignId, 'never-allocated': 'ast_never_allocated', 'already-deleted': deletedId, malformed: 'not-an-asset-id', }; for (const method of ['GET', 'HEAD', 'PUT', 'DELETE'] as const) { const observations: unknown[] = []; for (const id of Object.values(ids)) { const path = method === 'DELETE' ? `/assets/${encodeURIComponent(id)}` : `/assets/${encodeURIComponent(id)}/content`; const write = multipart([ { name: 'bytes', value: 'replacement', contentType: 'text/plain' }, ]); const response = await rawRequest({ method, path, headers: method === 'PUT' ? multipartHeaders(storeId, 'owner') : { 'x-asset-store-id': storeId, 'x-asset-principal': 'owner' }, ...(method === 'PUT' ? { body: write } : {}), }); observations.push(comparable(response)); } for (const observation of observations.slice(1)) expect(observation).toEqual(observations[0]); const ownPath = method === 'DELETE' ? `/assets/${encodeURIComponent(ownId)}` : `/assets/${encodeURIComponent(ownId)}/content`; const own = await rawRequest({ method, path: ownPath, headers: method === 'PUT' ? multipartHeaders(storeId, 'owner') : { 'x-asset-store-id': storeId, 'x-asset-principal': 'owner' }, ...(method === 'PUT' ? { body: multipart([ { name: 'bytes', value: 'own replacement', contentType: 'text/plain' }, ]), } : {}), }); expect(own.status).toBe(method === 'GET' || method === 'HEAD' ? 200 : 204); } }); test('every FOREIGN_IDS value remains an ordinary HTTP miss', async () => { const store = makeStore(); for (const [, id] of FOREIGN_IDS) { await expect(store.resolve(id)).resolves.toBeNull(); await expect(store.remove(id)).resolves.toBeUndefined(); } }); test('query strings are rejected on all five routes', async () => { const storeId = `query-${namespace++}`; const store = makeStore('principal-a', storeId); const id = await store.put(blob('query target')); const cases = [ ['POST', '/assets?'], ['GET', `/assets/${id}/content?x=1`], ['HEAD', `/assets/${id}/content?`], ['PUT', `/assets/${id}/content?x=1`], ['DELETE', `/assets/${id}?x=1`], ] as const; for (const [method, path] of cases) { const response = await rawRequest({ method, path, headers: { 'x-asset-store-id': storeId, 'x-asset-principal': 'principal-a' }, }); expect(response.status).toBe(400); if (method !== 'HEAD') expect(response.body.toString()).toContain('VALIDATION_FAILED'); } }); test('GET, HEAD, and DELETE reject request bodies', async () => { const storeId = `bodyless-${namespace++}`; const store = makeStore('principal-a', storeId); const id = await store.put(blob('bodyless')); for (const [method, path] of [ ['GET', `/assets/${id}/content`], ['HEAD', `/assets/${id}/content`], ['DELETE', `/assets/${id}`], ] as const) { const response = await rawRequest({ method, path, headers: { 'x-asset-store-id': storeId, 'x-asset-principal': 'principal-a' }, body: 'smuggled', }); expect(response.status).toBe(400); } }); test('rejects hostile multipart shapes without writing', async () => { const storeId = `multipart-${namespace++}`; const headers = multipartHeaders(storeId); const cases: Array<[string, Buffer, number]> = [ [ 'duplicate meta parts', multipart([ { name: 'meta', value: '{}', contentType: 'application/json' }, { name: 'meta', value: '{}', contentType: 'application/json' }, { name: 'bytes', value: 'a' }, ]), 400, ], [ 'duplicate bytes parts', multipart([ { name: 'meta', value: '{}', contentType: 'application/json' }, { name: 'bytes', value: 'a' }, { name: 'bytes', value: 'b' }, ]), 400, ], [ 'extra part', multipart([ { name: 'meta', value: '{}', contentType: 'application/json' }, { name: 'extra', value: 'x' }, { name: 'bytes', value: 'a' }, ]), 400, ], [ 'missing bytes', multipart([{ name: 'meta', value: '{}', contentType: 'application/json' }]), 400, ], [ 'wrong order', multipart([ { name: 'bytes', value: 'a' }, { name: 'meta', value: '{}', contentType: 'application/json' }, ]), 400, ], ]; for (const [, body, status] of cases) { const response = await rawRequest({ method: 'POST', path: '/assets', headers, body }); expect(response.status).toBe(status); } const wrongType = await rawRequest({ method: 'POST', path: '/assets', headers: { 'content-type': 'application/json', 'x-asset-store-id': storeId }, body: '{}', }); expect(wrongType.status).toBe(415); }); test('enforces request, metadata, and asset byte limits independently', async () => { const limited = await startAssetConformanceServer({ maxRequestBytes: 700, maxAssetBytes: 20, maxMetaBytes: 20, }); try { const url = new URL(limited.baseUrl); const send = (body: Buffer): Promise => new Promise((resolve, reject) => { const req = httpRequest( { hostname: url.hostname, port: url.port, method: 'POST', path: '/assets', headers: { 'content-type': 'multipart/form-data; boundary=asset-test-boundary', 'content-length': String(body.byteLength), }, }, (res) => { const chunks: Buffer[] = []; res.on('data', (chunk: Buffer) => chunks.push(chunk)); res.on('end', () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body: Buffer.concat(chunks), }), ); }, ); req.on('error', reject); req.end(body); }); const oversizedMeta = multipart([ { name: 'meta', value: JSON.stringify({ x: 'm'.repeat(30) }), contentType: 'application/json', }, { name: 'bytes', value: 'ok' }, ]); const oversizedAsset = multipart([ { name: 'meta', value: '{}', contentType: 'application/json' }, { name: 'bytes', value: 'b'.repeat(21) }, ]); const oversizedRequest = Buffer.concat([ multipart([ { name: 'meta', value: '{}', contentType: 'application/json' }, { name: 'bytes', value: 'ok' }, ]), Buffer.alloc(800), ]); expect((await send(oversizedMeta)).status).toBe(413); expect((await send(oversizedAsset)).status).toBe(413); expect((await send(oversizedRequest)).status).toBe(413); } finally { await limited.close(); } }); test.each([ ['name smuggled in a quoted filename', 'form-data; filename="x; name=meta; y"'], ['unterminated quote', 'form-data; name="meta'], ['trailing text after a quote', 'form-data; name="meta"junk'], ['continuation parameter', 'form-data; name=meta; name*0=bytes'], ['non-ASCII separator', 'form-data;\u00a0name=meta'], ['bare LF in a quoted filename', 'form-data; name=meta; filename="a\nb"'], ] as const)('platform parser rejects %s', async (_label, metaDisposition) => { const boundary = 'asset-test-boundary'; const body = Buffer.concat([ Buffer.from( `--${boundary}\r\nContent-Disposition: ${metaDisposition}\r\n` + 'Content-Type: application/json\r\n\r\n{}\r\n', 'latin1', ), Buffer.from( `--${boundary}\r\nContent-Disposition: form-data; name="bytes"; filename="asset"\r\n\r\npayload\r\n`, 'latin1', ), Buffer.from(`--${boundary}--\r\n`), ]); const response = await rawRequest({ method: 'POST', path: '/assets', headers: multipartHeaders(`disposition-${namespace++}`, 'principal-a', boundary), body, }); expect(response.status).toBe(400); expect(JSON.parse(response.body.toString())).toEqual({ error: { code: 'VALIDATION_FAILED', message: '@openmaic/storage: malformed multipart body', }, }); }); test('rejects a meta part without filename as text-decoded data', async () => { const response = await rawRequest({ method: 'POST', path: '/assets', headers: multipartHeaders(`meta-string-${namespace++}`), body: multipart([ { name: 'meta', value: '{}', contentType: 'application/json', filename: null }, { name: 'bytes', value: 'payload' }, ]), }); expect(response.status).toBe(400); expect(response.body.toString()).toContain('meta part must be sent as a file'); }); test('rejects a meta file whose media type is not application/json', async () => { const response = await rawRequest({ method: 'POST', path: '/assets', headers: multipartHeaders(`meta-type-${namespace++}`), body: multipart([ { name: 'meta', value: '{}', contentType: 'text/plain' }, { name: 'bytes', value: 'payload' }, ]), }); expect(response.status).toBe(400); expect(response.body.toString()).toContain('meta part must be application/json'); }); test('rejects a meta file whose bytes are not valid UTF-8', async () => { const response = await rawRequest({ method: 'POST', path: '/assets', headers: multipartHeaders(`meta-utf8-${namespace++}`), body: multipart([ { name: 'meta', value: Uint8Array.of(0x7b, 0x22, 0x78, 0x22, 0x3a, 0x22, 0xff, 0x22, 0x7d), contentType: 'application/json', }, { name: 'bytes', value: 'payload' }, ]), }); expect(response.status).toBe(400); expect(response.body.toString()).toContain('meta part must contain valid UTF-8'); }); test('rejects a bytes part without filename as text-decoded data', async () => { const response = await rawRequest({ method: 'POST', path: '/assets', headers: multipartHeaders(`bytes-string-${namespace++}`), body: multipart([ { name: 'meta', value: '{}', contentType: 'application/json' }, { name: 'bytes', value: 'payload', filename: null }, ]), }); expect(response.status).toBe(400); expect(response.body.toString()).toContain('bytes part must be sent as a file'); }); test('the client round-trips non-UTF-8 bytes through a file part', async () => { const storeId = `binary-${namespace++}`; const store = makeStore('principal-a', storeId); const original = Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x00, 0xff, 0xfe, 0x80); const id = await store.put(new Blob([original], { type: 'image/png' })); const response = await rawRequest({ method: 'GET', path: `/assets/${id}/content`, headers: { 'x-asset-store-id': storeId, 'x-asset-principal': 'principal-a' }, }); expect(response.status).toBe(200); expect(response.body).toEqual(Buffer.from(original)); }); test('exceeding maxParts is a payload limit, not a validation failure', async () => { const limited = await startAssetConformanceServer({ maxParts: 2 }); try { const url = new URL(limited.baseUrl); const body = multipart([ { name: 'meta', value: '{}', contentType: 'application/json' }, { name: 'bytes', value: 'ok' }, { name: 'bytes', value: 'extra' }, ]); const response = await new Promise((resolve, reject) => { const req = httpRequest( { hostname: url.hostname, port: url.port, method: 'POST', path: '/assets', headers: { 'content-type': 'multipart/form-data; boundary=asset-test-boundary', 'content-length': String(body.byteLength), }, }, (res) => { const chunks: Buffer[] = []; res.on('data', (chunk: Buffer) => chunks.push(chunk)); res.on('end', () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body: Buffer.concat(chunks), }), ); }, ); req.on('error', reject); req.end(body); }); expect(response.status).toBe(413); } finally { await limited.close(); } }); test('wrong methods return 405 with Allow and /assetsfoo is ROUTE_NOT_FOUND', async () => { const storeId = `routes-${namespace++}`; for (const [path, allow] of [ ['/assets', 'POST'], ['/assets/id', 'DELETE'], ['/assets/id/content', 'GET, HEAD, PUT'], ] as const) { const response = await rawRequest({ method: 'PATCH', path, headers: { 'x-asset-store-id': storeId }, }); expect(response.status).toBe(405); expect(response.headers.allow).toBe(allow); } const outside = await rawRequest({ method: 'GET', path: '/assetsfoo' }); expect(outside.status).toBe(404); expect(outside.body.toString()).toContain('ROUTE_NOT_FOUND'); }); test('byte responses enforce safe labels and required headers', async () => { const storeId = `labels-${namespace++}`; const store = makeStore('principal-a', storeId); const id = await store.put(blob('', 'image/svg+xml')); const response = await server.fetch(`${server.baseUrl}/assets/${id}/content`, { headers: { 'x-asset-principal': 'principal-a', 'x-asset-store-id': storeId }, }); expect(response.status).toBe(200); expect(response.headers.get('content-type')).toBe('application/octet-stream'); expect(response.headers.get('content-disposition')).toBe('attachment'); expect(response.headers.get('x-content-type-options')).toBe('nosniff'); expect(response.headers.get('cache-control')).toBe('private, no-store'); expect(response.headers.get('vary')).toBe('Cookie, Authorization'); expect(response.headers.get('etag')).toBeNull(); expect(response.headers.get('last-modified')).toBeNull(); expect(response.headers.get('accept-ranges')).toBeNull(); const url = await store.resolve(id); const stored = blobForObjectUrl(url!); expect(stored?.type).toBe('application/octet-stream'); }); test('metadata-omitting PUT with an untyped blob uses the binary default media type', async () => { const storeId = `retained-type-${namespace++}`; const store = makeStore('principal-a', storeId); const id = await store.put(blob('before', 'image/png'), { contentType: 'image/png' }); await store.replace(id as AssetId, blob('after', '')); const response = await server.fetch(`${server.baseUrl}/assets/${id}/content`, { headers: { 'x-asset-principal': 'principal-a', 'x-asset-store-id': storeId }, }); expect(response.headers.get('content-type')).toBe('application/octet-stream'); }); test('no digest encoding appears in response headers, bodies, ids, or error messages', async () => { const storeId = `digest-${namespace++}`; const store = makeStore('principal-a', storeId); const data = blob('digest scan payload', 'image/png'); const id = await store.put(data); await expectNoDigestSubstring(id, data); const responses = await Promise.all([ rawRequest({ method: 'GET', path: `/assets/${id}/content`, headers: { 'x-asset-store-id': storeId, 'x-asset-principal': 'principal-a' }, }), rawRequest({ method: 'HEAD', path: `/assets/${id}/content`, headers: { 'x-asset-store-id': storeId, 'x-asset-principal': 'principal-a' }, }), rawRequest({ method: 'GET', path: '/assets/absent/content', headers: { 'x-asset-store-id': storeId, 'x-asset-principal': 'principal-a' }, }), ]); const observed = responses .map((response) => `${JSON.stringify(response.headers)}\n${response.body.toString()}`) .join('\n'); for (const encoding of await commonDigestEncodings(data)) { expect(observed.toLowerCase()).not.toContain(encoding.slice(0, 12).toLowerCase()); } }); }); describe('HttpAssetStore snapshot behavior', () => { test('coalesces concurrent cold resolves into one GET and one URL', async () => { const storeId = `coalesce-${namespace++}`; const writer = makeStore('principal-a', storeId); const id = await writer.put(blob('coalesce')); let gets = 0; const client = new HttpAssetStore({ baseUrl: server.baseUrl, fetch: async (input, init) => { if ((init?.method ?? 'GET') === 'GET') gets += 1; return server.fetch(input, init); }, headers: () => ({ 'x-asset-store-id': storeId, 'x-asset-principal': 'principal-a' }), }); stores.push(client); const [left, right] = await Promise.all([client.resolve(id), client.resolve(id)]); expect(left).toBe(right); expect(gets).toBe(1); }); test('identical bytes under two ids mint different per-id URLs', async () => { const store = makeStore(); const [leftId, rightId] = await Promise.all([store.put(blob('same')), store.put(blob('same'))]); const [left, right] = await Promise.all([store.resolve(leftId), store.resolve(rightId)]); expect(left).not.toBe(right); }); test('replace retires rather than revokes an issued snapshot; release revokes both', async () => { const store = makeStore(); const id = await store.put(blob('before')); const before = await store.resolve(id); expect(blobForObjectUrl(before!)).toBeDefined(); await store.replace(id as AssetId, blob('after')); const after = await store.resolve(id); expect(after).not.toBe(before); expect(blobForObjectUrl(before!)).toBeDefined(); expect(blobForObjectUrl(after!)).toBeDefined(); await store.release(id); expect(blobForObjectUrl(before!)).toBeUndefined(); expect(blobForObjectUrl(after!)).toBeUndefined(); }); test('warm resolves revalidate with HEAD', async () => { const storeId = `head-${namespace++}`; const writer = makeStore('principal-a', storeId); const id = await writer.put(blob('head')); const methods: string[] = []; const client = new HttpAssetStore({ baseUrl: server.baseUrl, fetch: async (input, init) => { methods.push(init?.method ?? 'GET'); return server.fetch(input, init); }, headers: () => ({ 'x-asset-store-id': storeId, 'x-asset-principal': 'principal-a' }), }); stores.push(client); const first = await client.resolve(id); const second = await client.resolve(id); expect(second).toBe(first); expect(methods).toEqual(['GET', 'HEAD']); }); test('records the GET revision rather than the preceding HEAD revision', async () => { let gets = 0; let heads = 0; const fetch = vi.fn(async (_input, init) => { const method = init?.method ?? 'GET'; if (method === 'HEAD') { heads += 1; const revision = heads === 1 ? '2' : '3'; return new Response(null, { status: 200, headers: { 'x-asset-revision': revision, 'content-type': 'image/png' }, }); } gets += 1; return new Response(gets === 1 ? 'one' : 'three', { status: 200, headers: { 'x-asset-revision': gets === 1 ? '1' : '3', 'content-type': 'image/png', }, }); }); const store = new HttpAssetStore({ baseUrl: 'https://assets.invalid', fetch }); stores.push(store); await store.resolve('asset'); await store.resolve('asset'); await store.resolve('asset'); expect(gets).toBe(2); expect(heads).toBe(2); }); test('an unclassifiable HEAD falls back to GET and is never treated as a miss', async () => { let requests = 0; const fetch = vi.fn(async (_input, init) => { requests += 1; if (requests === 1) { return new Response('bytes', { status: 200, headers: { 'x-asset-revision': '1', 'content-type': 'image/png' }, }); } if (init?.method === 'HEAD') return new Response(null, { status: 404 }); return new Response('bytes', { status: 200, headers: { 'x-asset-revision': '1', 'content-type': 'image/png' }, }); }); const store = new HttpAssetStore({ baseUrl: 'https://assets.invalid', fetch }); stores.push(store); const first = await store.resolve('asset'); await expect(store.resolve('asset')).resolves.toBe(first); expect(requests).toBe(3); }); test('only status 404 together with ASSET_NOT_FOUND is a miss', async () => { const store = new HttpAssetStore({ baseUrl: 'https://assets.invalid', fetch: async () => new Response(JSON.stringify({ error: { code: 'ROUTE_NOT_FOUND', message: 'route' } }), { status: 404, headers: { 'content-type': 'application/json' }, }), }); stores.push(store); await expect(store.resolve('asset')).rejects.toMatchObject({ status: 404, code: 'ROUTE_NOT_FOUND', }); }); test('unaddressable ids resolve and remove locally while replace synthesizes not found', async () => { const fetch = vi.fn(); const store = new HttpAssetStore({ baseUrl: 'https://assets.invalid', fetch }); stores.push(store); for (const id of ['', '.', '..', '\ud800']) { await expect(store.resolve(id)).resolves.toBeNull(); await expect(store.remove(id)).resolves.toBeUndefined(); await expect(store.replace(id as AssetId, blob('x'))).rejects.toMatchObject({ status: 404, code: 'ASSET_NOT_FOUND', }); } expect(fetch).not.toHaveBeenCalled(); }); test('a headers hook cannot overwrite multipart Content-Type', async () => { const store = new HttpAssetStore({ baseUrl: 'https://assets.invalid', fetch: vi.fn(), headers: () => ({ 'content-type': 'multipart/form-data' }), }); stores.push(store); await expect(store.put(blob('x'))).rejects.toMatchObject({ code: 'CONTENT_TYPE_CONFLICT' }); }); }); describe('asset handler construction', () => { const inertStore = {} as AssetStore; test('refuses executable renderable types', () => { expect(() => createAssetHttpHandler(inertStore, { authenticate: async () => ({ key: 'principal' }), renderableTypes: ['image/svg+xml'], }), ).toThrow(/excluded executable type/); }); test('requires outer request room beyond the decoded part limits', () => { expect(() => createAssetHttpHandler(inertStore, { authenticate: async () => ({ key: 'principal' }), maxRequestBytes: 20, maxAssetBytes: 10, maxMetaBytes: 10, }), ).toThrow(/multipart framing/); }); });