mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
397 lines
15 KiB
TypeScript
397 lines
15 KiB
TypeScript
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<string, string> } {
|
|
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<typeof multipart>[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);
|
|
});
|
|
});
|