|
| 1 | +/** |
| 2 | + * @vitest-environment node |
| 3 | + * |
| 4 | + * Exercises the real archive plumbing — archiver, the lazy entry generator, and the |
| 5 | + * Node-to-web bridge — against multi-chunk file streams, and validates the result with |
| 6 | + * the operating system's `unzip` rather than the same JS library that produced it. The |
| 7 | + * bug this route exists to fix was an archive a real zip reader could not open, so a |
| 8 | + * self-consistent JS round-trip is not the assertion that matters. |
| 9 | + */ |
| 10 | +import { execFile } from 'node:child_process' |
| 11 | +import { createReadStream } from 'node:fs' |
| 12 | +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' |
| 13 | +import { tmpdir } from 'node:os' |
| 14 | +import { join } from 'node:path' |
| 15 | +import { promisify } from 'node:util' |
| 16 | +import { createMockRequest } from '@sim/testing' |
| 17 | +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' |
| 18 | + |
| 19 | +const run = promisify(execFile) |
| 20 | + |
| 21 | +const { |
| 22 | + mockGetSession, |
| 23 | + mockVerifyWorkspaceMembership, |
| 24 | + mockListWorkspaceFiles, |
| 25 | + mockListFolders, |
| 26 | + mockDownloadFileStream, |
| 27 | +} = vi.hoisted(() => ({ |
| 28 | + mockGetSession: vi.fn(), |
| 29 | + mockVerifyWorkspaceMembership: vi.fn(), |
| 30 | + mockListWorkspaceFiles: vi.fn(), |
| 31 | + mockListFolders: vi.fn(), |
| 32 | + mockDownloadFileStream: vi.fn(), |
| 33 | +})) |
| 34 | + |
| 35 | +vi.mock('@/lib/auth', () => ({ |
| 36 | + auth: { api: { getSession: vi.fn() } }, |
| 37 | + getSession: mockGetSession, |
| 38 | +})) |
| 39 | +vi.mock('@/app/api/workflows/utils', () => ({ |
| 40 | + verifyWorkspaceMembership: mockVerifyWorkspaceMembership, |
| 41 | +})) |
| 42 | +vi.mock('@/lib/uploads/contexts/workspace', () => ({ |
| 43 | + listWorkspaceFiles: mockListWorkspaceFiles, |
| 44 | + listWorkspaceFileFolders: mockListFolders, |
| 45 | + buildWorkspaceFileFolderPathMap: (folders: Array<{ id: string; name: string }>) => |
| 46 | + new Map(folders.map((folder) => [folder.id, folder.name])), |
| 47 | + fetchServableWorkspaceFileBuffer: vi.fn(), |
| 48 | +})) |
| 49 | +vi.mock('@/lib/uploads/core/storage-service', () => ({ |
| 50 | + downloadFileStream: mockDownloadFileStream, |
| 51 | +})) |
| 52 | +vi.mock('@sim/audit', () => ({ |
| 53 | + recordAudit: vi.fn(), |
| 54 | + AuditAction: { FILE_DOWNLOADED: 'file.downloaded' }, |
| 55 | + AuditResourceType: { FILE: 'file' }, |
| 56 | +})) |
| 57 | +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) |
| 58 | + |
| 59 | +import { GET } from '@/app/api/workspaces/[id]/files/download/route' |
| 60 | + |
| 61 | +const WORKSPACE_ID = 'ws-1' |
| 62 | +const context = { params: Promise.resolve({ id: WORKSPACE_ID }) } |
| 63 | + |
| 64 | +let workDir: string |
| 65 | +let bigPath: string |
| 66 | +let bigBytes: Buffer |
| 67 | +/** The OS unzip is the point of this file; skip rather than fail where it is absent. */ |
| 68 | +let hasUnzip = true |
| 69 | + |
| 70 | +beforeAll(async () => { |
| 71 | + hasUnzip = await run('unzip', ['-v']).then( |
| 72 | + () => true, |
| 73 | + () => false |
| 74 | + ) |
| 75 | + workDir = await mkdtemp(join(tmpdir(), 'sim-archive-')) |
| 76 | + bigPath = join(workDir, 'big.bin') |
| 77 | + // Several MB of non-repeating bytes: forces many 64 KiB chunks through the generator, |
| 78 | + // the archiver queue and the web bridge, and would expose a truncation or ordering bug. |
| 79 | + bigBytes = Buffer.alloc(5 * 1024 * 1024) |
| 80 | + for (let i = 0; i < bigBytes.length; i++) bigBytes[i] = (i * 31 + (i >> 8)) & 0xff |
| 81 | + await writeFile(bigPath, bigBytes) |
| 82 | +}) |
| 83 | + |
| 84 | +afterAll(async () => { |
| 85 | + await rm(workDir, { recursive: true, force: true }) |
| 86 | +}) |
| 87 | + |
| 88 | +describe('workspace files download — real archive', () => { |
| 89 | + beforeEach(() => { |
| 90 | + vi.clearAllMocks() |
| 91 | + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) |
| 92 | + mockVerifyWorkspaceMembership.mockResolvedValue({ role: 'member' }) |
| 93 | + mockListFolders.mockResolvedValue([{ id: 'folder-1', name: 'Reports', parentId: null }]) |
| 94 | + // A real fs stream, not a single pre-made buffer. |
| 95 | + mockDownloadFileStream.mockImplementation(async () => createReadStream(bigPath)) |
| 96 | + }) |
| 97 | + |
| 98 | + it('produces an archive the OS unzip accepts, with byte-exact contents', async (ctx) => { |
| 99 | + if (!hasUnzip) ctx.skip() |
| 100 | + mockListWorkspaceFiles.mockResolvedValue([ |
| 101 | + { |
| 102 | + id: 'f1', |
| 103 | + name: 'big.bin', |
| 104 | + key: `workspace/${WORKSPACE_ID}/f1`, |
| 105 | + path: '/serve/f1', |
| 106 | + size: bigBytes.length, |
| 107 | + type: 'application/octet-stream', |
| 108 | + folderId: 'folder-1', |
| 109 | + }, |
| 110 | + ]) |
| 111 | + |
| 112 | + const response = await GET( |
| 113 | + createMockRequest( |
| 114 | + 'GET', |
| 115 | + undefined, |
| 116 | + {}, |
| 117 | + `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/download?fileIds=f1` |
| 118 | + ), |
| 119 | + context |
| 120 | + ) |
| 121 | + expect(response.status).toBe(200) |
| 122 | + |
| 123 | + const zipPath = join(workDir, 'out.zip') |
| 124 | + await writeFile(zipPath, Buffer.from(await response.arrayBuffer())) |
| 125 | + |
| 126 | + // Independent validation: the CRCs and central directory have to satisfy a real |
| 127 | + // zip reader, which is exactly what failed for the customer. |
| 128 | + await expect(run('unzip', ['-t', zipPath])).resolves.toBeTruthy() |
| 129 | + |
| 130 | + await run('unzip', ['-o', '-q', zipPath, '-d', join(workDir, 'out')]) |
| 131 | + const extracted = await readFile(join(workDir, 'out', 'Reports', 'big.bin')) |
| 132 | + expect(extracted.length).toBe(bigBytes.length) |
| 133 | + expect(extracted.equals(bigBytes)).toBe(true) |
| 134 | + }) |
| 135 | + |
| 136 | + it('keeps entries intact and correctly named across several streamed files', async (ctx) => { |
| 137 | + if (!hasUnzip) ctx.skip() |
| 138 | + mockListWorkspaceFiles.mockResolvedValue( |
| 139 | + ['a.bin', 'b.bin', 'c.bin'].map((name, index) => ({ |
| 140 | + id: `f${index}`, |
| 141 | + name, |
| 142 | + key: `workspace/${WORKSPACE_ID}/f${index}`, |
| 143 | + path: `/serve/f${index}`, |
| 144 | + size: bigBytes.length, |
| 145 | + type: 'application/octet-stream', |
| 146 | + folderId: 'folder-1', |
| 147 | + })) |
| 148 | + ) |
| 149 | + |
| 150 | + const response = await GET( |
| 151 | + createMockRequest( |
| 152 | + 'GET', |
| 153 | + undefined, |
| 154 | + {}, |
| 155 | + `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/download?fileIds=f0&fileIds=f1&fileIds=f2` |
| 156 | + ), |
| 157 | + context |
| 158 | + ) |
| 159 | + |
| 160 | + const zipPath = join(workDir, 'multi.zip') |
| 161 | + await writeFile(zipPath, Buffer.from(await response.arrayBuffer())) |
| 162 | + await expect(run('unzip', ['-t', zipPath])).resolves.toBeTruthy() |
| 163 | + |
| 164 | + const { stdout } = await run('unzip', ['-l', zipPath]) |
| 165 | + for (const name of ['Reports/a.bin', 'Reports/b.bin', 'Reports/c.bin']) { |
| 166 | + expect(stdout).toContain(name) |
| 167 | + } |
| 168 | + // Each entry carries the full payload — a shared or truncated stream would not. |
| 169 | + expect(stdout.match(/5242880/g)).toHaveLength(3) |
| 170 | + }) |
| 171 | +}) |
0 commit comments