diff --git a/lib/storage.ts b/lib/storage.ts index f4053af..fa2eb73 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -72,6 +72,35 @@ export class ObjectNotFoundError extends Error { } } +/** Adapters throw it without `size`; `Storage.download` fills it in from the location. */ +export class RangeNotSatisfiableError extends Error { + constructor( + objectName: string, + public readonly size?: number, + ) { + super(`Range not satisfiable for ${objectName}${size === undefined ? '' : ` (size ${size})`}`) + this.name = 'RangeNotSatisfiableError' + } +} + +/** An omitted `end` means open-ended; the backend resolves it against the object length. */ +export interface RangeRequest { + start: number + end?: number +} + +export interface ByteRange { + start: number + end: number +} + +/** `range` is set only when a range was requested and the adapter honoured it. */ +export interface DownloadStream { + stream: Readable + size?: number + range?: ByteRange +} + export class AlreadyExistsError extends Error { constructor(objectName: string) { super(`Already exists: ${objectName}`) @@ -148,11 +177,17 @@ export class Storage { if (actualPartCount < location.partCount) throw new ObjectNotFoundError(partsFolder) } - private async downloadFromCacheEntryLocation(location: StorageLocation) { - if (location.mergedAt) return this.adapter.createDownloadStream(`${location.folderName}/merged`) + private async downloadFromCacheEntryLocation( + location: StorageLocation, + range?: RangeRequest, + ): Promise { + if (location.mergedAt) + return this.adapter.createDownloadStream(`${location.folderName}/merged`, range) await this.ensurePartsExist(location) - return Readable.from(this.streamParts(location)) + // No `range`: an unmerged entry is concatenated from its parts as it is read, so + // there is nothing to seek into. Served whole under a 200, which Range allows. + return { stream: Readable.from(this.streamParts(location)) } } private async pumpPartsToStreams( @@ -180,7 +215,7 @@ export class Storage { if (location.partsDeletedAt) throw new Error('No parts to feed for location with deleted parts') for (let i = 0; i < location.partCount; i++) { - const partStream = await this.adapter.createDownloadStream( + const { stream: partStream } = await this.adapter.createDownloadStream( `${location.folderName}/parts/${i}`, ) @@ -536,12 +571,13 @@ export class Storage { } } - async download(cacheEntryId: string): Promise { + /** Keyed by location, not entry: a re-save repoints the entry, but a location's bytes never change. */ + async download(locationId: string, range?: RangeRequest): Promise { const protectedLocation = await this.db.transaction().execute(async (tx) => { let query = tx .selectFrom('storage_locations') .innerJoin('cache_entries', 'cache_entries.locationId', 'storage_locations.id') - .where('cache_entries.id', '=', cacheEntryId) + .where('storage_locations.id', '=', locationId) .selectAll('storage_locations') if (env.DB_DRIVER !== 'sqlite') query = query.forUpdate() const storageLocation = await query.executeTakeFirst() @@ -563,8 +599,8 @@ export class Storage { try { if (storageLocation.mergedAt) { - const stream = await this.downloadFromCacheEntryLocation(storageLocation) - return this.protectDownloadStream(stream, readerLeaseId) + const download = await this.downloadFromCacheEntryLocation(storageLocation, range) + return { ...download, stream: this.protectDownloadStream(download.stream, readerLeaseId) } } await this.ensurePartsExist(storageLocation) @@ -580,24 +616,26 @@ export class Storage { }), ) if (!merge) { - const stream = await this.downloadFromCacheEntryLocation(storageLocation) - return this.protectDownloadStream(stream, readerLeaseId) + const download = await this.downloadFromCacheEntryLocation(storageLocation, range) + return { ...download, stream: this.protectDownloadStream(download.stream, readerLeaseId) } } this.pumpPartsToStreams(storageLocation, responseStream, mergerStream).catch((err) => { responseStream.destroy(err) mergerStream.destroy(err) if (err instanceof ObjectNotFoundError) - logger.warn(`Stale cache entry ${cacheEntryId}: ${err.message}`) + logger.warn(`Stale storage location ${locationId}: ${err.message}`) }) - return this.protectDownloadStream(responseStream, readerLeaseId) + return { stream: this.protectDownloadStream(responseStream, readerLeaseId) } } catch (err) { await releaseReaderLease(this.db, readerLeaseId) if (err instanceof ObjectNotFoundError) { - logger.warn(`Stale cache entry ${cacheEntryId}: ${err.message}`) + logger.warn(`Stale storage location ${locationId}: ${err.message}`) return } + if (err instanceof RangeNotSatisfiableError) + throw new RangeNotSatisfiableError(locationId, storageLocation.sizeBytes ?? undefined) throw err } } @@ -751,7 +789,7 @@ export class Storage { continue } - const defaultUrl = `${env.API_BASE_URL}/download/${cacheEntry.match.id}` + const defaultUrl = `${env.API_BASE_URL}/download/${cacheEntry.match.locationId}` if (!env.ENABLE_DIRECT_DOWNLOADS || !this.adapter.createDownloadUrl || !location.mergedAt) return { @@ -801,7 +839,7 @@ export class Storage { export const getStorage = createSingletonPromise(async () => Storage.fromEnv()) export interface StorageAdapter { - createDownloadStream(objectName: string): Promise + createDownloadStream(objectName: string, range?: RangeRequest): Promise /** * Uploads must be atomically visible: an object never exists partially, and * overwriting an object never disturbs active readers of the previous @@ -1007,19 +1045,29 @@ class S3Adapter implements StorageAdapter { return deleted } - async createDownloadStream(objectName: string) { + async createDownloadStream(objectName: string, range?: RangeRequest): Promise { try { const response = await this.s3.send( new GetObjectCommand({ Bucket: this.bucket, Key: `${this.keyPrefix}/${objectName}`, + Range: range ? `bytes=${range.start}-${range.end ?? ''}` : undefined, }), ) if (!response.Body) throw new Error('No body in S3 get object response') - return response.Body as Readable + const stream = response.Body as Readable + if (response.$metadata.httpStatusCode !== 206) return { stream, size: response.ContentLength } + + const served = parseContentRange(response.ContentRange) + if (!served) { + stream.destroy() + throw new Error(`S3 answered 206 with unparseable Content-Range: ${response.ContentRange}`) + } + return { stream, size: served.size, range: { start: served.start, end: served.end } } } catch (err: any) { if (err.name === 'NoSuchKey') throw new ObjectNotFoundError(objectName) + if (err.name === 'InvalidRange') throw new RangeNotSatisfiableError(objectName) throw err } } @@ -1172,14 +1220,20 @@ class FileSystemAdapter implements StorageAdapter { return folder } - async createDownloadStream(objectName: string) { + async createDownloadStream(objectName: string, range?: RangeRequest): Promise { const filePath = this.safePath(objectName) + let size: number try { - await fs.access(filePath) + const stat = await fs.stat(filePath) + size = stat.size } catch { throw new ObjectNotFoundError(objectName) } - return createReadStream(filePath) + if (!range) return { stream: createReadStream(filePath), size } + + const served = clampRange(range, size) + if (!served) throw new RangeNotSatisfiableError(objectName) + return { stream: createReadStream(filePath, served), size, range: served } } async objectExists(objectName: string) { @@ -1329,11 +1383,22 @@ class GcsAdapter implements StorageAdapter { this.bucket = gcs.bucket(bucket) } - async createDownloadStream(objectName: string) { + async createDownloadStream(objectName: string, range?: RangeRequest): Promise { const file = this.bucket.file(`${this.keyPrefix}/${objectName}`) - const [exists] = await file.exists() - if (!exists) throw new ObjectNotFoundError(objectName) - return file.createReadStream() + // `getMetadata` proves existence and carries the size, so no second round-trip. + let size: number + try { + const [metadata] = await file.getMetadata() + size = Number(metadata.size) + } catch (err: any) { + if (err.code === 404) throw new ObjectNotFoundError(objectName) + throw err + } + if (!range) return { stream: file.createReadStream(), size } + + const served = clampRange(range, size) + if (!served) throw new RangeNotSatisfiableError(objectName) + return { stream: file.createReadStream(served), size, range: served } } async objectExists(objectName: string) { @@ -1534,10 +1599,35 @@ class AzBlobAdapter implements StorageAdapter { return deleted } - async createDownloadStream(objectName: string) { - const response = await this.blob(objectName).download() + async createDownloadStream(objectName: string, range?: RangeRequest): Promise { + let response + try { + response = await this.blob(objectName).download( + range?.start, + range?.end === undefined ? undefined : range.end - range.start + 1, + ) + } catch (err: any) { + if (err.statusCode === 404) throw new ObjectNotFoundError(objectName) + if (err.statusCode === 416) throw new RangeNotSatisfiableError(objectName) + throw err + } if (!response.readableStreamBody) throw new Error(`No stream for blob "${objectName}"`) - return Readable.from(response.readableStreamBody) + const stream = Readable.from(response.readableStreamBody) + + const served = parseContentRange(response.contentRange) + if (served) { + // Azurite answers a range past the end with an empty 206 where Azure answers 416. + if (served.start >= served.size) { + stream.destroy() + throw new RangeNotSatisfiableError(objectName) + } + return { stream, size: served.size, range: { start: served.start, end: served.end } } + } + // No Content-Range means the whole blob. The SDK drops the range header for `bytes=0-`, + // so that one request still got exactly what it asked for. + const size = response.contentLength + if (range?.start !== 0 || range.end !== undefined || !size) return { stream, size } + return { stream, size, range: { start: 0, end: size - 1 } } } async uploadStream(objectName: string, stream: Readable) { @@ -1597,3 +1687,17 @@ class AzBlobAdapter implements StorageAdapter { return sign(objectName) } } + +function clampRange(range: RangeRequest, size: number): ByteRange | undefined { + if (range.start >= size) return + return { start: range.start, end: Math.min(range.end ?? size - 1, size - 1) } +} + +const CONTENT_RANGE_RE = /^bytes (\d+)-(\d+)\/(\d+)$/ + +function parseContentRange(header: string | undefined) { + if (!header) return + const m = CONTENT_RANGE_RE.exec(header) + if (!m) return + return { start: Number(m[1]), end: Number(m[2]), size: Number(m[3]) } +} diff --git a/routes/download/[cacheEntryId].ts b/routes/download/[cacheEntryId].ts deleted file mode 100644 index b2849e6..0000000 --- a/routes/download/[cacheEntryId].ts +++ /dev/null @@ -1,46 +0,0 @@ -import { pipeline } from 'node:stream/promises' -import { z } from 'zod' -import { logger } from '~/lib/logger' -import { getStorage } from '~/lib/storage' - -const pathParamsSchema = z.object({ - cacheEntryId: z.string(), -}) - -export default defineEventHandler(async (event) => { - const parsedPathParams = pathParamsSchema.safeParse(event.context.params) - if (!parsedPathParams.success) - throw createError({ - statusCode: 400, - statusMessage: `Invalid path parameters: ${parsedPathParams.error.message}`, - }) - - const { cacheEntryId } = parsedPathParams.data - - const storage = await getStorage() - const stream = await storage.download(cacheEntryId) - if (!stream) - throw createError({ - statusCode: 404, - message: 'Cache file not found', - }) - - // `sendStream` neither waits for `drain` nor notices client aborts; `pipeline` does both - // and destroys the source, which releases its reader lease. `_handled` is an h3 v1 internal. - event._handled = true - try { - await pipeline(stream, event.node.res) - } catch (err) { - // Client went away mid-body. Expected on cancelled jobs and parallel runners. - if ((err as NodeJS.ErrnoException).code === 'ERR_STREAM_PREMATURE_CLOSE') { - logger.debug(`Client aborted /download/${cacheEntryId}: ${(err as Error).message}`) - return - } - // Headers are out, so Nitro's error handler would crash with ERR_HTTP_HEADERS_SENT. - if (event.node.res.headersSent) { - logger.error(`Download stream failed for ${cacheEntryId}`, { error: err }) - return - } - throw err - } -}) diff --git a/routes/download/[locationId].ts b/routes/download/[locationId].ts new file mode 100644 index 0000000..ddc4909 --- /dev/null +++ b/routes/download/[locationId].ts @@ -0,0 +1,87 @@ +import type { RangeRequest } from '~/lib/storage' +import { pipeline } from 'node:stream/promises' +import { z } from 'zod' +import { logger } from '~/lib/logger' +import { getStorage, RangeNotSatisfiableError } from '~/lib/storage' + +const pathParamsSchema = z.object({ + locationId: z.string(), +}) + +// Single ranges only; multi-range and suffix (`bytes=-500`) fall through to a 200. +const RANGE_RE = /^bytes=(\d+)-(\d*)$/i + +function parseRange(header: string | undefined): RangeRequest | undefined { + if (!header) return + const m = RANGE_RE.exec(header.trim()) + if (!m) return + const start = Number(m[1]) + if (!Number.isSafeInteger(start)) return + if (m[2] === '') return { start } + const end = Number(m[2]) + if (!Number.isSafeInteger(end) || end < start) return + return { start, end } +} + +export default defineEventHandler(async (event) => { + const parsedPathParams = pathParamsSchema.safeParse(event.context.params) + if (!parsedPathParams.success) + throw createError({ + statusCode: 400, + statusMessage: `Invalid path parameters: ${parsedPathParams.error.message}`, + }) + + const { locationId } = parsedPathParams.data + const storage = await getStorage() + + const range = parseRange(getHeader(event, 'range')) + + // Unmerged entries ignore Range, so clients must key off the status, not this header. + setHeader(event, 'accept-ranges', 'bytes') + + let download + try { + download = await storage.download(locationId, range) + } catch (err) { + if (!(err instanceof RangeNotSatisfiableError)) throw err + setResponseStatus(event, 416, 'Range Not Satisfiable') + if (err.size !== undefined) setHeader(event, 'content-range', `bytes */${err.size}`) + return send(event) + } + if (!download) + throw createError({ + statusCode: 404, + message: 'Cache file not found', + }) + + if (download.range && download.size !== undefined) { + setResponseStatus(event, 206) + setHeader( + event, + 'content-range', + `bytes ${download.range.start}-${download.range.end}/${download.size}`, + ) + setHeader(event, 'content-length', download.range.end - download.range.start + 1) + } else if (download.size !== undefined) { + setHeader(event, 'content-length', download.size) + } + + // `sendStream` neither waits for `drain` nor notices client aborts; `pipeline` does both + // and destroys the source, which releases its reader lease. `_handled` is an h3 v1 internal. + event._handled = true + try { + await pipeline(download.stream, event.node.res) + } catch (err) { + // Client went away mid-body. Expected on cancelled jobs and parallel runners. + if ((err as NodeJS.ErrnoException).code === 'ERR_STREAM_PREMATURE_CLOSE') { + logger.debug(`Client aborted /download/${locationId}: ${(err as Error).message}`) + return + } + // Headers are out, so Nitro's error handler would crash with ERR_HTTP_HEADERS_SENT. + if (event.node.res.headersSent) { + logger.error(`Download stream failed for ${locationId}`, { error: err }) + return + } + throw err + } +}) diff --git a/tests/cleanup-lifecycle.test.ts b/tests/cleanup-lifecycle.test.ts index 0824338..49ddeca 100644 --- a/tests/cleanup-lifecycle.test.ts +++ b/tests/cleanup-lifecycle.test.ts @@ -240,11 +240,11 @@ describe('cleanup lifecycle', () => { .execute() try { - const mergingDownload = await storage.download(entryId) - const activePartsDownload = await storage.download(entryId) + const mergingDownload = await storage.download(locationId) + const activePartsDownload = await storage.download(locationId) expect(mergingDownload).toBeDefined() expect(activePartsDownload).toBeDefined() - for await (const _chunk of mergingDownload!) void _chunk + for await (const _chunk of mergingDownload!.stream) void _chunk await storage.waitForOngoingMerges() const taskModule = await import('~/tasks/cleanup/parts') @@ -252,7 +252,7 @@ describe('cleanup lifecycle', () => { await task.run({} as never) expect(await storage.adapter.countFilesInFolder(`${folderName}/parts`)).toBe(1) - for await (const _chunk of activePartsDownload!) void _chunk + for await (const _chunk of activePartsDownload!.stream) void _chunk await vi.waitFor( async () => { @@ -262,10 +262,10 @@ describe('cleanup lifecycle', () => { { timeout: 5000, interval: 100 }, ) - const mergedDownload = await storage.download(entryId) + const mergedDownload = await storage.download(locationId) expect(mergedDownload).toBeDefined() let restored = '' - for await (const chunk of mergedDownload!) restored += chunk.toString() + for await (const chunk of mergedDownload!.stream) restored += chunk.toString() expect(restored).toBe('cache-data') } finally { await db.deleteFrom('storage_locations').where('id', '=', locationId).execute() @@ -307,7 +307,7 @@ describe('cleanup lifecycle', () => { }) .execute() - const download = await storage.download(entryId) + const download = await storage.download(locationId) expect(download).toBeDefined() await db.deleteFrom('cache_entries').where('id', '=', entryId).execute() const taskModule = await import('~/tasks/cleanup/storage-locations') @@ -315,7 +315,7 @@ describe('cleanup lifecycle', () => { await task.run({} as never) expect(await storage.adapter.countFilesInFolder(folderName)).toBe(1) - for await (const _chunk of download!) void _chunk + for await (const _chunk of download!.stream) void _chunk await vi.waitFor( async () => { @@ -439,9 +439,9 @@ describe('cleanup lifecycle', () => { vi.useFakeTimers() try { - const download = await storage.download(entryId) + const download = await storage.download(locationId) expect(download).toBeDefined() - download!.on('error', () => undefined) + download!.stream.on('error', () => undefined) await db .deleteFrom('storage_reader_leases') .where('storageLocationId', '=', locationId) @@ -451,9 +451,10 @@ describe('cleanup lifecycle', () => { // The renewal fires on the fake timer, but the lease-lost DB query resolves on a // real round-trip — wait for the resulting destroy instead of asserting synchronously. // Plain 'close' wait (not events.once, which rejects on the error-destroy). - if (!download!.destroyed) await new Promise((resolve) => download!.once('close', resolve)) + if (!download!.stream.destroyed) + await new Promise((resolve) => download!.stream.once('close', resolve)) - expect(download!.destroyed).toBe(true) + expect(download!.stream.destroyed).toBe(true) } finally { vi.useRealTimers() await db.deleteFrom('storage_locations').where('id', '=', locationId).execute() diff --git a/tests/download-merge-backpressure.test.ts b/tests/download-merge-backpressure.test.ts index d15b410..84c3ab9 100644 --- a/tests/download-merge-backpressure.test.ts +++ b/tests/download-merge-backpressure.test.ts @@ -30,7 +30,7 @@ describe('download merge backpressure', () => { function* chunked() { for (let o = 0; o < buf.length; o += 64 * 1024) yield buf.subarray(o, o + 64 * 1024) } - return Promise.resolve(Readable.from(chunked())) + return Promise.resolve({ stream: Readable.from(chunked()) }) }, } as unknown as StorageAdapter diff --git a/tests/eager-merge.test.ts b/tests/eager-merge.test.ts index ed5eb92..ad8c4a4 100644 --- a/tests/eager-merge.test.ts +++ b/tests/eager-merge.test.ts @@ -24,7 +24,7 @@ async function uploadParts(storage: Storage, parts: Buffer[]) { } async function mergedBytes(storage: Storage, folderName: string) { - const stream = await storage.adapter.createDownloadStream(`${folderName}/merged`) + const { stream } = await storage.adapter.createDownloadStream(`${folderName}/merged`) return Buffer.concat(await stream.toArray()) } diff --git a/tests/proxy-backpressure.test.ts b/tests/proxy-backpressure.test.ts index 7d09801..2fc5af7 100644 --- a/tests/proxy-backpressure.test.ts +++ b/tests/proxy-backpressure.test.ts @@ -30,8 +30,8 @@ beforeAll(async () => { // …) at evaluation time, so they can only load after the stubs above. const { default: upload } = await import('~/routes/devstoreaccount1/upload/[uploadId].put') router.put('/upload/:uploadId', upload) - const { default: download } = await import('~/routes/download/[cacheEntryId]') - router.get('/download/:cacheEntryId', download) + const { default: download } = await import('~/routes/download/[locationId]') + router.get('/download/:locationId', download) server.listen(0) await once(server, 'listening') }) @@ -123,7 +123,7 @@ describe('proxy routes apply backpressure', () => { } const source = Readable.from(entry(), { objectMode: false, highWaterMark: CHUNK_BYTES }) const storage = await getStorage() - vi.spyOn(storage, 'download').mockResolvedValue(source) + vi.spyOn(storage, 'download').mockResolvedValue({ stream: source }) const { port } = server.address() as AddressInfo const serverSide = once(server, 'request') as Promise<[IncomingMessage, ServerResponse]> diff --git a/tests/range-download.test.ts b/tests/range-download.test.ts new file mode 100644 index 0000000..d971a10 --- /dev/null +++ b/tests/range-download.test.ts @@ -0,0 +1,138 @@ +import { Buffer } from 'node:buffer' +import crypto from 'node:crypto' +import { Readable } from 'node:stream' + +import { describe, expect, test } from 'vitest' +import { getStorage } from '~/lib/storage' + +const SCOPE = 'refs/heads/main' +const REPO_ID = '123' +const VERSION = 'range-test-version' +const DOWNLOAD_URL = 'http://localhost:3000/download' + +// Odd size on purpose: not a multiple of any chunk size a client would pick. +const PART_SIZE = 512 * 1024 +const TOTAL = 2 * PART_SIZE + 12_345 + +async function createEntry(key = `range-${crypto.randomUUID()}`) { + const storage = await getStorage() + const part0 = crypto.randomBytes(PART_SIZE) + const part1 = crypto.randomBytes(PART_SIZE) + const part2 = crypto.randomBytes(TOTAL - 2 * PART_SIZE) + const expected = Buffer.concat([part0, part1, part2]) + + const upload = await storage.createUpload({ + key, + version: VERSION, + scope: SCOPE, + repoId: REPO_ID, + }) + if (!upload) throw new Error('createUpload returned nothing') + await storage.uploadPart(upload.id, 0, Readable.from(part0)) + await storage.uploadPart(upload.id, 1, Readable.from(part1)) + await storage.uploadPart(upload.id, 2, Readable.from(part2)) + await storage.completeUpload({ key, version: VERSION, scope: SCOPE, repoId: REPO_ID }) + + const matched = await storage.matchCacheEntry({ + keys: [key], + version: VERSION, + scopes: [SCOPE], + repoId: REPO_ID, + }) + const id = matched?.match.locationId + if (!id) throw new Error('cache entry not found after completeUpload') + return { id, key, expected, storage } +} + +async function createMergedEntry() { + const entry = await createEntry() + // First download of an unmerged entry starts the merge in the background. + const first = await entry.storage.download(entry.id) + for await (const _ of first!.stream) { + /* drain */ + } + await entry.storage.waitForOngoingMerges() + return entry +} + +async function get(id: string, range?: string) { + const res = await fetch(`${DOWNLOAD_URL}/${id}`, { + headers: range ? { range } : {}, + }) + const body = Buffer.from(await res.arrayBuffer()) + return { res, body } +} + +describe('proxy download route serves HTTP Range on merged entries', () => { + test('no Range: 200, full body, content-length, accept-ranges', async () => { + const { id, expected } = await createMergedEntry() + const { res, body } = await get(id) + expect(res.status).toBe(200) + expect(res.headers.get('accept-ranges')).toBe('bytes') + expect(res.headers.get('content-length')).toBe(String(TOTAL)) + expect(body.compare(expected)).toBe(0) + }) + + test('closed range: 206 with exact content-range and content-length', async () => { + const { id, expected } = await createMergedEntry() + const { res, body } = await get(id, 'bytes=4096-8191') + expect(res.status).toBe(206) + expect(res.headers.get('content-range')).toBe(`bytes 4096-8191/${TOTAL}`) + expect(res.headers.get('content-length')).toBe('4096') + expect(body.compare(expected.subarray(4096, 8192))).toBe(0) + }) + + test('open-ended range is clamped to the object', async () => { + const { id, expected } = await createMergedEntry() + const start = TOTAL - 100 + const { res, body } = await get(id, `bytes=${start}-`) + expect(res.status).toBe(206) + expect(res.headers.get('content-range')).toBe(`bytes ${start}-${TOTAL - 1}/${TOTAL}`) + expect(body.compare(expected.subarray(start))).toBe(0) + }) + + test('closed range past the end is clamped, not rejected', async () => { + const { id, expected } = await createMergedEntry() + const start = TOTAL - 10 + const { res, body } = await get(id, `bytes=${start}-${TOTAL + 5000}`) + expect(res.status).toBe(206) + expect(res.headers.get('content-range')).toBe(`bytes ${start}-${TOTAL - 1}/${TOTAL}`) + expect(body.compare(expected.subarray(start))).toBe(0) + }) + + test('range starting at the end: 416 with content-range bytes */size', async () => { + const { id } = await createMergedEntry() + const { res } = await get(id, `bytes=${TOTAL}-`) + expect(res.status).toBe(416) + expect(res.headers.get('content-range')).toBe(`bytes */${TOTAL}`) + }) + + test('unparseable Range falls through to a plain 200', async () => { + const { id, expected } = await createMergedEntry() + const { res, body } = await get(id, 'bytes=abc') + expect(res.status).toBe(200) + expect(body.compare(expected)).toBe(0) + }) +}) + +describe('proxy download route on unmerged entries', () => { + test('range is ignored: 200 with the full body streamed from parts', async () => { + const { id, expected } = await createEntry() + const { res, body } = await get(id, 'bytes=0-10') + expect(res.status).toBe(200) + expect(res.headers.get('content-range')).toBeNull() + expect(body.compare(expected)).toBe(0) + }) +}) + +test('a re-save does not change the bytes behind a handed-out URL', async () => { + const { id, key } = await createEntry() + const resaved = await createEntry(key) + expect(resaved.id).not.toBe(id) + + const stale = await get(id, 'bytes=0-10') + expect(stale.res.status).toBe(404) + const { res, body } = await get(resaved.id, 'bytes=0-10') + expect(res.status).toBe(200) + expect(body.compare(resaved.expected)).toBe(0) +}) diff --git a/tests/storage-lifecycle.test.ts b/tests/storage-lifecycle.test.ts index 7201cc0..7e98903 100644 --- a/tests/storage-lifecycle.test.ts +++ b/tests/storage-lifecycle.test.ts @@ -37,7 +37,7 @@ describe('storage lifecycle reconciliation', () => { let deleteCalls = 0 const adapter = { async createDownloadStream() { - return Readable.from('') + return { stream: Readable.from('') } }, async uploadStream() {}, async objectExists() { @@ -69,7 +69,7 @@ describe('storage lifecycle reconciliation', () => { const db = await getDatabase() const adapter = { async createDownloadStream() { - return Readable.from('') + return { stream: Readable.from('') } }, async uploadStream() {}, async objectExists() {