95 lines
7.4 KiB
TypeScript
95 lines
7.4 KiB
TypeScript
/** @jest-environment node */
|
|
import { createHash } from 'node:crypto'
|
|
import { access, mkdtemp, readdir, rm } from 'node:fs/promises'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import { prisma } from '@/lib/prisma'
|
|
import { getNativeFile, nativeRange, putNativeFile } from '@/lib/desktop-client/native-storage'
|
|
|
|
jest.mock('@/lib/prisma', () => ({ prisma: { $queryRawUnsafe: jest.fn(), $executeRawUnsafe: jest.fn(), $transaction: jest.fn() } }))
|
|
let directory = ''
|
|
jest.mock('@/lib/detail-generator/storage', () => ({ getDetailStorageRoot: () => directory }))
|
|
type Asset = { id: string; owner: string; project: string; source: string; sha256: string; key: string; mime: string; bytes: number }
|
|
const assets: Asset[] = []
|
|
let alive = true
|
|
const authorize = jest.fn(async () => undefined)
|
|
function upload(bytes: Buffer, hash?: string) {
|
|
const sha = hash || createHash('sha256').update(bytes).digest('hex')
|
|
const stream = new ReadableStream({ start(controller) { for (let offset = 0; offset < bytes.length; offset += 16384) controller.enqueue(bytes.subarray(offset, offset + 16384)); controller.close() } })
|
|
return { source: 'native:' + sha, request: new Request('https://sc.jiansun.vip/native', { method: 'PUT', headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': String(bytes.length), 'X-Content-SHA256': sha }, body: stream, duplex: 'half' } as RequestInit) }
|
|
}
|
|
beforeEach(async () => {
|
|
directory = await mkdtemp(path.join(os.tmpdir(), 'desktop-native-')); assets.length = 0; alive = true; jest.clearAllMocks()
|
|
;(prisma.$transaction as jest.Mock).mockImplementation(async action => action(prisma))
|
|
;(prisma.$queryRawUnsafe as jest.Mock).mockImplementation(async (sql: string, ...args: any[]) => {
|
|
if (sql.includes('pg_advisory')) return []
|
|
if (sql.includes('FROM desktop_workspace_projects')) return alive ? [{ id: 'project' }] : []
|
|
if (sql.includes('SUM(byte_size)')) return [{ bytes: assets.reduce((sum, asset) => sum + asset.bytes, 0), count: assets.length }]
|
|
if (sql.includes('FROM desktop_workspace_assets')) return assets.filter(asset => asset.owner === args[0] && asset.project === args[1] && asset.source === args[2])
|
|
throw new Error(sql)
|
|
})
|
|
;(prisma.$executeRawUnsafe as jest.Mock).mockImplementation(async (_sql: string, id: string, project: string, owner: string, source: string, sha256: string, key: string, mime: string, bytes: number) => { assets.push({ id, project, owner, source, sha256, key, mime, bytes }); return 1 })
|
|
})
|
|
afterEach(async () => { await rm(directory, { recursive: true, force: true }) })
|
|
|
|
test('streams exact native bytes, deduplicates immutable hashes, and supports bounded range downloads', async () => {
|
|
const bytes = Buffer.alloc(2 * 1024 * 1024 + 17, 0x61), first = upload(bytes)
|
|
expect(await putNativeFile('owner', 'project', first.source, first.request, authorize)).toMatchObject({ bytes: bytes.length, existing: false })
|
|
expect(authorize).toHaveBeenCalledTimes(2)
|
|
const second = upload(bytes)
|
|
expect(await putNativeFile('owner', 'project', second.source, second.request, authorize)).toMatchObject({ existing: true })
|
|
expect(assets).toHaveLength(1)
|
|
const response = await getNativeFile('owner', 'project', first.source, new Request('https://sc.jiansun.vip/native', { headers: { range: 'bytes=1048576-1048607' } }), authorize)
|
|
expect(response.status).toBe(206); expect(response.headers.get('content-length')).toBe('32')
|
|
expect(Buffer.from(await response.arrayBuffer())).toEqual(bytes.subarray(1048576, 1048608))
|
|
await expect(getNativeFile('other', 'project', first.source, new Request('https://sc.jiansun.vip/native'), authorize)).rejects.toMatchObject({ status: 404 })
|
|
})
|
|
|
|
test('checksum failure and revoked authorization at completion leave no native bytes or lock', async () => {
|
|
const bad = upload(Buffer.from('wrong'), '0'.repeat(64))
|
|
await expect(putNativeFile('owner', 'project', bad.source, bad.request, authorize)).rejects.toMatchObject({ code: 'checksum_mismatch' })
|
|
expect(assets).toHaveLength(0)
|
|
const good = upload(Buffer.from('native'))
|
|
authorize.mockResolvedValueOnce(undefined).mockRejectedValueOnce(Object.assign(new Error('revoked'), { status: 401 }))
|
|
await expect(putNativeFile('owner', 'project', good.source, good.request, authorize)).rejects.toMatchObject({ status: 401 })
|
|
expect(assets).toHaveLength(0)
|
|
const ownerDir = path.join(directory, 'desktop-native', createHash('sha256').update('owner').digest('hex'), 'project')
|
|
expect(await readdir(ownerDir)).toEqual([])
|
|
})
|
|
|
|
test('deletion during upload prevents registration and clears the partial file', async () => {
|
|
const good = upload(Buffer.from('native'))
|
|
authorize.mockImplementationOnce(async () => undefined).mockImplementationOnce(async () => { alive = false })
|
|
await expect(putNativeFile('owner', 'project', good.source, good.request, authorize)).rejects.toMatchObject({ status: 404 })
|
|
expect(assets).toHaveLength(0)
|
|
})
|
|
|
|
test('oversized, quota-full, and invalid multi-range requests are rejected without reading the archive', async () => {
|
|
const good = upload(Buffer.from('native'))
|
|
good.request.headers.set('content-length', String(512 * 1024 * 1024 + 1))
|
|
await expect(putNativeFile('owner', 'project', good.source, good.request, authorize)).rejects.toMatchObject({ status: 413 })
|
|
assets.push({ id: 'old', owner: 'owner', project: 'project', source: 'native:' + 'f'.repeat(64), sha256: 'f'.repeat(64), key: '', mime: '', bytes: 1024 * 1024 * 1024 })
|
|
const full = upload(Buffer.from('native'))
|
|
await expect(putNativeFile('owner', 'project', full.source, full.request, authorize)).rejects.toMatchObject({ code: 'project_quota' })
|
|
expect(nativeRange('bytes=-4', 10)).toEqual({ start: 6, end: 9, partial: true })
|
|
expect(nativeRange('bytes=7-999', 10)).toEqual({ start: 7, end: 9, partial: true })
|
|
for (const value of ['bytes=1-2,4-5','bytes=10-','bytes=-0','bytes=-','items=1-2']) expect(() => nativeRange(value, 10)).toThrow()
|
|
})
|
|
|
|
test('concurrent native uploads cannot multiply a project transfer and quota is rechecked at commit', async () => {
|
|
const bytes = Buffer.from('streamed'), hash = createHash('sha256').update(bytes).digest('hex')
|
|
let controller!: ReadableStreamDefaultController<Uint8Array>
|
|
const request = new Request('https://sc.jiansun.vip/native', { method: 'PUT', headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': String(bytes.length), 'X-Content-SHA256': hash }, body: new ReadableStream({ start(value) { controller = value } }), duplex: 'half' } as RequestInit)
|
|
const running = putNativeFile('owner','project','native:' + hash,request,authorize)
|
|
const lockFile = path.join(directory,'desktop-native',createHash('sha256').update('owner').digest('hex'),'project','.upload.lock')
|
|
for (let index = 0; index < 100; index++) { if (await access(lockFile).then(() => true, () => false)) break; await new Promise(resolve => setTimeout(resolve, 1)) }
|
|
const concurrent = upload(Buffer.from('second'))
|
|
await expect(putNativeFile('owner','project',concurrent.source,concurrent.request,authorize)).rejects.toMatchObject({ code: 'upload_busy' })
|
|
// Simulates a preview committed by another request while the stream was arriving.
|
|
assets.push({ id: 'large', owner: 'owner', project: 'project', source: 'local:large', sha256: '', key: '', mime: 'image/png', bytes: 1024 * 1024 * 1024 })
|
|
controller.enqueue(bytes); controller.close()
|
|
await expect(running).rejects.toMatchObject({ code: 'project_quota' })
|
|
expect(assets).toHaveLength(1)
|
|
expect(await readdir(path.dirname(lockFile))).toEqual([])
|
|
})
|