gongxiangp2/lib/__tests__/desktop-native-storage.test.ts

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([])
})