import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import path from 'node:path'; import fs from 'node:fs/promises'; import { Readable } from 'node:stream'; import type { IncomingMessage } from 'node:http'; import { createAnalyzeUploadHandler } from '../../src/server/analyze-upload.js'; import { PUBLIC_ORIGIN_ENV, createWriteOriginGuard } from '../../src/server/middleware.js'; // The guard admits GITNEXUS_PUBLIC_ORIGIN as well as loopback, and the // rejection cases below assume none is configured. Clear the developer's // ambient value for the file rather than inheriting it. const ambientPublicOrigin = process.env[PUBLIC_ORIGIN_ENV]; beforeAll(() => { delete process.env[PUBLIC_ORIGIN_ENV]; }); afterAll(() => { if (ambientPublicOrigin !== undefined) process.env[PUBLIC_ORIGIN_ENV] = ambientPublicOrigin; }); const BOUNDARY = '----gitnexusuploadtest'; function multipart( parts: Array<{ name: string; value?: string; filename?: string; data?: Buffer }>, ): { body: Buffer; headers: Record } { const chunks: Buffer[] = []; for (const p of parts) { chunks.push(Buffer.from(`--${BOUNDARY}\r\n`)); if (p.filename !== undefined) { chunks.push( Buffer.from( `Content-Disposition: form-data; name="${p.name}"; filename="${p.filename}"\r\n` + `Content-Type: application/octet-stream\r\n\r\n`, ), ); chunks.push(p.data ?? Buffer.alloc(0)); chunks.push(Buffer.from('\r\n')); } else { chunks.push(Buffer.from(`Content-Disposition: form-data; name="${p.name}"\r\n\r\n`)); chunks.push(Buffer.from(p.value ?? '')); chunks.push(Buffer.from('\r\n')); } } chunks.push(Buffer.from(`--${BOUNDARY}--\r\n`)); return { body: Buffer.concat(chunks), headers: { 'content-type': `multipart/form-data; boundary=${BOUNDARY}` }, }; } function mockReq(parts: Parameters[0]): IncomingMessage { const { body, headers } = multipart(parts); const r = Readable.from([body]) as unknown as IncomingMessage & { headers: typeof headers }; r.headers = headers; return r; } interface MockRes { statusCode: number; body: unknown; status: (c: number) => MockRes; json: (b: unknown) => MockRes; } function mockRes(): MockRes { const res = { statusCode: 0, body: undefined as unknown } as MockRes; res.status = (c: number) => { res.statusCode = c; return res; }; res.json = (b: unknown) => { res.body = b; return res; }; return res; } // Track promoted upload dirs created by the real ingest+promote so we clean up. const promoted: string[] = []; afterEach(async () => { while (promoted.length) { await fs.rm(promoted.pop()!, { recursive: true, force: true }).catch(() => {}); } }); function uniqueTop(): string { return `uptest_${Math.floor(Math.random() * 1e9).toString(36)}`; } describe('createAnalyzeUploadHandler', () => { it('ingests, promotes the inner folder, and launches analysis (202)', async () => { const top = uniqueTop(); const createJob = vi.fn(() => ({ id: 'job-1', status: 'queued' })); const launch = vi.fn((_j, dir: string) => promoted.push(dir)); const failJob = vi.fn(); const handler = createAnalyzeUploadHandler({ createJob, launch, failJob }); const res = mockRes(); await handler( mockReq([ { name: 'manifest', value: JSON.stringify([`${top}/a.js`, `${top}/sub/b.js`]) }, { name: 'files', filename: 'blob', data: Buffer.from('alpha') }, { name: 'files', filename: 'blob', data: Buffer.from('beta') }, ]) as never, res as never, ); expect(res.statusCode).toBe(202); expect((res.body as { jobId: string }).jobId).toBe('job-1'); expect(createJob).toHaveBeenCalledOnce(); expect(launch).toHaveBeenCalledOnce(); const dir = launch.mock.calls[0][1] as string; const opts = launch.mock.calls[0][2] as { registryName: string }; // Inner folder promoted: contents live directly under the upload dir. expect(await fs.readFile(path.join(dir, 'a.js'), 'utf8')).toBe('alpha'); expect(await fs.readFile(path.join(dir, 'sub', 'b.js'), 'utf8')).toBe('beta'); expect(opts.registryName).toBe(top); expect(createJob.mock.calls[0][0].repoPath).toBe(dir); }); it('maps a busy job (createJob throws "already in progress") to 409 and promotes nothing', async () => { const top = uniqueTop(); const createJob = vi.fn(() => { throw new Error('Analysis already in progress for another repository'); }); const launch = vi.fn((_j, dir: string) => promoted.push(dir)); const failJob = vi.fn(); const handler = createAnalyzeUploadHandler({ createJob, launch, failJob }); const res = mockRes(); await handler( mockReq([ { name: 'manifest', value: JSON.stringify([`${top}/a.js`]) }, { name: 'files', filename: 'blob', data: Buffer.from('x') }, ]) as never, res as never, ); expect(res.statusCode).toBe(409); expect(launch).not.toHaveBeenCalled(); // Nothing promoted onto disk. const { UPLOAD_ROOT } = await import('../../src/server/upload-paths.js'); await expect(fs.access(path.join(UPLOAD_ROOT, top))).rejects.toBeTruthy(); }); it('rejects a traversal path in the manifest (400) without launching', async () => { const createJob = vi.fn(() => ({ id: 'j', status: 'queued' })); const launch = vi.fn(); const failJob = vi.fn(); const handler = createAnalyzeUploadHandler({ createJob, launch, failJob }); const res = mockRes(); await handler( mockReq([ { name: 'manifest', value: JSON.stringify(['../escape.js']) }, { name: 'files', filename: 'blob', data: Buffer.from('x') }, ]) as never, res as never, ); expect(res.statusCode).toBe(400); expect(createJob).not.toHaveBeenCalled(); expect(launch).not.toHaveBeenCalled(); }); it('rejects an un-nameable top folder (Windows-reserved → 400)', async () => { const createJob = vi.fn(() => ({ id: 'j', status: 'queued' })); const launch = vi.fn(); const failJob = vi.fn(); const handler = createAnalyzeUploadHandler({ createJob, launch, failJob }); const res = mockRes(); await handler( mockReq([ { name: 'manifest', value: JSON.stringify(['CON/a.js']) }, { name: 'files', filename: 'blob', data: Buffer.from('x') }, ]) as never, res as never, ); expect(res.statusCode).toBe(400); expect(launch).not.toHaveBeenCalled(); }); it('strips a crafted .gitnexus index from the promoted upload', async () => { const top = uniqueTop(); const createJob = vi.fn(() => ({ id: 'job-x', status: 'queued' })); const launch = vi.fn((_j, dir: string) => promoted.push(dir)); const failJob = vi.fn(); const handler = createAnalyzeUploadHandler({ createJob, launch, failJob }); const res = mockRes(); await handler( mockReq([ { name: 'manifest', value: JSON.stringify([`${top}/.gitnexus/meta.json`, `${top}/a.js`]) }, { name: 'files', filename: 'blob', data: Buffer.from('{"evil":true}') }, { name: 'files', filename: 'blob', data: Buffer.from('real') }, ]) as never, res as never, ); expect(res.statusCode).toBe(202); const dir = launch.mock.calls[0][1] as string; await expect(fs.access(path.join(dir, '.gitnexus'))).rejects.toBeTruthy(); expect(await fs.readFile(path.join(dir, 'a.js'), 'utf8')).toBe('real'); }); it('rejects a single-segment manifest before creating a job (no slot taken)', async () => { const createJob = vi.fn(() => ({ id: 'j', status: 'queued' })); const launch = vi.fn(); const failJob = vi.fn(); const handler = createAnalyzeUploadHandler({ createJob, launch, failJob }); const res = mockRes(); await handler( mockReq([ { name: 'manifest', value: JSON.stringify(['loosefile.js']) }, { name: 'files', filename: 'blob', data: Buffer.from('x') }, ]) as never, res as never, ); expect(res.statusCode).toBe(400); expect(createJob).not.toHaveBeenCalled(); // slot never taken → no wedge expect(launch).not.toHaveBeenCalled(); }); it('rejects a multi-top-folder manifest (would silently drop folders)', async () => { const createJob = vi.fn(() => ({ id: 'j', status: 'queued' })); const launch = vi.fn(); const failJob = vi.fn(); const handler = createAnalyzeUploadHandler({ createJob, launch, failJob }); const res = mockRes(); await handler( mockReq([ { name: 'manifest', value: JSON.stringify(['aaa/x.js', 'bbb/y.js']) }, { name: 'files', filename: 'blob', data: Buffer.from('1') }, { name: 'files', filename: 'blob', data: Buffer.from('2') }, ]) as never, res as never, ); expect(res.statusCode).toBe(400); expect(createJob).not.toHaveBeenCalled(); }); it('releases the single slot (failJob) when a step fails after createJob', async () => { const top = uniqueTop(); const createJob = vi.fn(() => ({ id: 'job-fail', status: 'queued' })); // launch throws AFTER createJob + promote — the slot must be released. const launch = vi.fn((_j, dir: string) => { promoted.push(dir); throw new Error('worker fork blew up'); }); const failJob = vi.fn(); const handler = createAnalyzeUploadHandler({ createJob, launch, failJob }); const res = mockRes(); await handler( mockReq([ { name: 'manifest', value: JSON.stringify([`${top}/a.js`]) }, { name: 'files', filename: 'blob', data: Buffer.from('x') }, ]) as never, res as never, ); expect(createJob).toHaveBeenCalledOnce(); expect(failJob).toHaveBeenCalledWith('job-fail', expect.any(String)); expect(res.statusCode).toBe(500); }); }); describe('createWriteOriginGuard (no bound host)', () => { function call(origin: string | undefined): { passed: boolean; status: number } { let passed = false; let status = 0; const req = { headers: origin === undefined ? {} : { origin } } as never; const res = { status: (c: number) => { status = c; return { json: () => {} }; }, } as never; createWriteOriginGuard()(req, res, () => { passed = true; }); return { passed, status }; } it('passes localhost / 127.0.0.1 / [::1] / no-origin', () => { expect(call('http://localhost:5173').passed).toBe(true); expect(call('http://127.0.0.1:4747').passed).toBe(true); expect(call('http://[::1]:4747').passed).toBe(true); expect(call(undefined).passed).toBe(true); }); it('rejects a public/cross origin with 403', () => { const r = call('https://gitnexus.vercel.app'); expect(r.passed).toBe(false); expect(r.status).toBe(403); }); it('rejects RFC1918 origins when no boundHost is set', () => { expect(call('http://10.0.0.1:4173').passed).toBe(false); expect(call('http://172.16.1.21:4173').passed).toBe(false); expect(call('http://192.168.1.100:4173').passed).toBe(false); }); it('rejects malformed and non-private hostnames with 403', () => { expect(call('http://my-local-server.local:4173').passed).toBe(false); expect(call('ftp://localhost:4173').passed).toBe(false); expect(call('null').passed).toBe(false); }); }); describe('createWriteOriginGuard (bound host)', () => { function callWith( boundHost: string, origin: string | undefined, ): { passed: boolean; status: number; body?: { error?: string; code?: string } } { const guard = createWriteOriginGuard(boundHost); let passed = false; let status = 0; let body: { error?: string; code?: string } | undefined; const req = { headers: origin === undefined ? {} : { origin } } as never; const res = { status: (c: number) => { status = c; return { json: (b: { error?: string; code?: string }) => { body = b; }, }; }, } as never; guard(req, res, () => { passed = true; }); return { passed, status, body }; } it('allows origin matching the bound host', () => { expect(callWith('192.168.1.100', 'http://192.168.1.100:4747').passed).toBe(true); expect(callWith('10.0.0.5', 'http://10.0.0.5:4173').passed).toBe(true); expect(callWith('172.16.1.21', 'http://172.16.1.21:4173').passed).toBe(true); }); it('still allows loopback regardless of bound host', () => { expect(callWith('192.168.1.100', 'http://localhost:5173').passed).toBe(true); expect(callWith('192.168.1.100', 'http://127.0.0.1:4747').passed).toBe(true); expect(callWith('192.168.1.100', 'http://[::1]:4747').passed).toBe(true); }); it('normalizes mixed-case host binds to match the WHATWG origin hostname', () => { // WHATWG lowercases the Origin hostname; boundHost must canonicalize the same way. expect(callWith('MyHost.local', 'http://myhost.local:4747').passed).toBe(true); }); it('normalizes IPv6 host binds (compressed + non-canonical) to match the origin', () => { expect(callWith('fe80::1', 'http://[fe80::1]:4747').passed).toBe(true); // Expanded form must compress to the same WHATWG hostname as the origin. expect(callWith('fe80:0:0:0:0:0:0:1', 'http://[fe80::1]:4747').passed).toBe(true); // Already-bracketed input is idempotent. expect(callWith('[fe80::1]', 'http://[fe80::1]:4747').passed).toBe(true); }); it('keeps wildcard binds (0.0.0.0 / :: / expanded) loopback-only', () => { // No browser Origin equals a wildcard, so non-loopback writes are rejected... expect(callWith('0.0.0.0', 'http://192.168.1.5:4747').passed).toBe(false); expect(callWith('::', 'http://[fe80::1]:4747').passed).toBe(false); expect(callWith('0:0:0:0:0:0:0:0', 'http://[fe80::1]:4747').passed).toBe(false); // ...while loopback still passes under a wildcard bind. expect(callWith('0.0.0.0', 'http://localhost:5173').passed).toBe(true); expect(callWith('::', 'http://127.0.0.1:4747').passed).toBe(true); }); it('rejects other RFC1918 origins that do not match bound host', () => { expect(callWith('192.168.1.100', 'http://192.168.1.101:4747').passed).toBe(false); expect(callWith('192.168.1.100', 'http://10.0.0.1:4747').passed).toBe(false); expect(callWith('10.0.0.5', 'http://172.16.1.21:4747').passed).toBe(false); }); it('rejects public origins even when bound to LAN', () => { const r = callWith('192.168.1.100', 'https://gitnexus.vercel.app'); expect(r.passed).toBe(false); expect(r.status).toBe(403); }); it('tags the rejection 403 with a machine-readable code', () => { const r = callWith('192.168.1.100', 'https://gitnexus.vercel.app'); expect(r.status).toBe(403); expect(r.body?.code).toBe('origin_not_allowed'); }); it('passes no-origin (non-browser) requests', () => { expect(callWith('192.168.1.100', undefined).passed).toBe(true); }); });