mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
294 lines
12 KiB
TypeScript
294 lines
12 KiB
TypeScript
/**
|
||
* Unit Tests: the write-route origin guard's port awareness, and what it
|
||
* reports at startup.
|
||
*
|
||
* cors.test.ts covers the read side (isAllowedOrigin) of the same matcher.
|
||
* This file covers the write side — createWriteOriginGuard — where a
|
||
* mismatch is a 403 rather than a missing CORS header, plus the port-aware
|
||
* bound-host comparison and logOriginPolicy's startup diagnostics.
|
||
*/
|
||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||
import {
|
||
PUBLIC_ORIGIN_ENV,
|
||
assertServeAuthForPublicOrigin,
|
||
createWriteOriginGuard,
|
||
isServeAuthConfigured,
|
||
logOriginPolicy,
|
||
} from '../../src/server/middleware.js';
|
||
import { _captureLogger, type LoggerCapture } from '../../src/core/logger.js';
|
||
|
||
// The bound-host cases never set the var, so clear the developer's ambient
|
||
// value for the file and return each test to that cleared baseline.
|
||
const ambientPublicOrigin = process.env[PUBLIC_ORIGIN_ENV];
|
||
beforeAll(() => {
|
||
delete process.env[PUBLIC_ORIGIN_ENV];
|
||
});
|
||
afterAll(() => {
|
||
if (ambientPublicOrigin !== undefined) process.env[PUBLIC_ORIGIN_ENV] = ambientPublicOrigin;
|
||
});
|
||
|
||
afterEach(() => {
|
||
delete process.env[PUBLIC_ORIGIN_ENV];
|
||
});
|
||
|
||
function setPublicOrigin(value: string | undefined): void {
|
||
if (value === undefined) delete process.env[PUBLIC_ORIGIN_ENV];
|
||
else process.env[PUBLIC_ORIGIN_ENV] = value;
|
||
}
|
||
|
||
interface GuardResult {
|
||
passed: boolean;
|
||
status: number;
|
||
body?: { error?: string; code?: string };
|
||
}
|
||
|
||
// The guard snapshots the env var at construction, so callers set it first.
|
||
function callGuard(
|
||
boundHost: string | undefined,
|
||
boundPort: number | undefined,
|
||
origin: string,
|
||
): GuardResult {
|
||
const guard = createWriteOriginGuard(boundHost, boundPort);
|
||
let passed = false;
|
||
let status = 0;
|
||
let body: { error?: string; code?: string } | undefined;
|
||
const req = { headers: { 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 };
|
||
}
|
||
|
||
describe('createWriteOriginGuard — bound host is matched on its port', () => {
|
||
it('admits the bound host on the bound port', () => {
|
||
expect(callGuard('192.168.1.10', 8443, 'http://192.168.1.10:8443').passed).toBe(true);
|
||
});
|
||
|
||
it('rejects the bound host on a different port with origin_not_allowed', () => {
|
||
const res = callGuard('192.168.1.10', 8443, 'http://192.168.1.10:9999');
|
||
expect(res.passed).toBe(false);
|
||
expect(res.status).toBe(403);
|
||
expect(res.body?.code).toBe('origin_not_allowed');
|
||
});
|
||
|
||
it('rejects the bound host on the default port when it is not the bound one', () => {
|
||
// The pre-existing bug: `hostname === normalizedBoundHost` matched here.
|
||
expect(callGuard('192.168.1.10', 8443, 'http://192.168.1.10').passed).toBe(false);
|
||
expect(callGuard('192.168.1.10', 8443, 'https://192.168.1.10').passed).toBe(false);
|
||
});
|
||
|
||
it('treats an elided default port as that port', () => {
|
||
expect(callGuard('192.168.1.10', 80, 'http://192.168.1.10').passed).toBe(true);
|
||
expect(callGuard('192.168.1.10', 443, 'https://192.168.1.10').passed).toBe(true);
|
||
expect(callGuard('192.168.1.10', 80, 'https://192.168.1.10').passed).toBe(false);
|
||
});
|
||
|
||
it('matches any port when no bound port is given', () => {
|
||
expect(callGuard('192.168.1.10', undefined, 'http://192.168.1.10:9999').passed).toBe(true);
|
||
});
|
||
|
||
it('keeps loopback port-agnostic — the dev UI runs on its own port', () => {
|
||
expect(callGuard('192.168.1.10', 8443, 'http://localhost:5173').passed).toBe(true);
|
||
expect(callGuard('192.168.1.10', 8443, 'http://127.0.0.1:4173').passed).toBe(true);
|
||
expect(callGuard('192.168.1.10', 8443, 'http://[::1]:4173').passed).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('createWriteOriginGuard — public origin is matched on its port', () => {
|
||
it('admits an exact scheme/host/port match on a wildcard bind', () => {
|
||
setPublicOrigin('https://app.example.com:8443');
|
||
expect(callGuard('0.0.0.0', 3000, 'https://app.example.com:8443').passed).toBe(true);
|
||
});
|
||
|
||
it('rejects a port mismatch with a 403 and origin_not_allowed', () => {
|
||
setPublicOrigin('https://app.example.com:8443');
|
||
const res = callGuard('0.0.0.0', 3000, 'https://app.example.com:9999');
|
||
expect(res.passed).toBe(false);
|
||
expect(res.status).toBe(403);
|
||
expect(res.body?.code).toBe('origin_not_allowed');
|
||
});
|
||
|
||
it('rejects a scheme mismatch when the configured value carries a scheme', () => {
|
||
setPublicOrigin('https://app.example.com');
|
||
expect(callGuard('0.0.0.0', 3000, 'http://app.example.com').passed).toBe(false);
|
||
});
|
||
|
||
// A bare host is permissive on the port but NOT on the scheme: it defaults to
|
||
// https, so `app.example.com` is not an http downgrade path into the write
|
||
// routes. Plain http needs the explicit form.
|
||
it('admits any port for a bare configured host, but only over https', () => {
|
||
setPublicOrigin('app.example.com');
|
||
expect(callGuard('0.0.0.0', 3000, 'https://app.example.com:9999').passed).toBe(true);
|
||
expect(callGuard('0.0.0.0', 3000, 'https://app.example.com').passed).toBe(true);
|
||
expect(callGuard('0.0.0.0', 3000, 'http://app.example.com').passed).toBe(false);
|
||
expect(callGuard('0.0.0.0', 3000, 'http://app.example.com:9999').passed).toBe(false);
|
||
});
|
||
|
||
it('admits http for a bare host only when http:// is spelled out', () => {
|
||
setPublicOrigin('http://app.example.com');
|
||
expect(callGuard('0.0.0.0', 3000, 'http://app.example.com:9999').passed).toBe(true);
|
||
expect(callGuard('0.0.0.0', 3000, 'https://app.example.com').passed).toBe(false);
|
||
});
|
||
|
||
it('admits a bracketed IPv6 literal, on any port when configured without one', () => {
|
||
setPublicOrigin('[2001:db8::1]');
|
||
expect(callGuard('0.0.0.0', 3000, 'https://[2001:db8::1]:4173').passed).toBe(true);
|
||
setPublicOrigin('https://[2001:db8::1]:8080');
|
||
expect(callGuard('0.0.0.0', 3000, 'https://[2001:db8::1]:8080').passed).toBe(true);
|
||
expect(callGuard('0.0.0.0', 3000, 'https://[2001:db8::1]:4173').passed).toBe(false);
|
||
});
|
||
|
||
// A trailing dot is a legal FQDN that survives `new URL` as `example.com.`,
|
||
// but a browser sends `example.com` — so it built a matcher nothing could
|
||
// satisfy while logOriginPolicy reported it as working.
|
||
it('admits nothing for a trailing-dot hostname', () => {
|
||
setPublicOrigin('app.example.com.');
|
||
expect(callGuard('0.0.0.0', 3000, 'https://app.example.com').passed).toBe(false);
|
||
expect(callGuard('0.0.0.0', 3000, 'https://app.example.com.').passed).toBe(false);
|
||
});
|
||
|
||
it('admits nothing extra when the configured value is not one reachable host', () => {
|
||
setPublicOrigin('a.com,b.com');
|
||
expect(callGuard('0.0.0.0', 3000, 'https://a.com').passed).toBe(false);
|
||
expect(callGuard('0.0.0.0', 3000, 'https://b.com').passed).toBe(false);
|
||
});
|
||
|
||
it('leaves the Origin-less passthrough alone — the CLI sends no Origin', () => {
|
||
setPublicOrigin('app.example.com');
|
||
const guard = createWriteOriginGuard('0.0.0.0', 3000);
|
||
let passed = false;
|
||
guard({ headers: {} } as never, {} as never, () => {
|
||
passed = true;
|
||
});
|
||
expect(passed).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('logOriginPolicy', () => {
|
||
let cap: LoggerCapture;
|
||
beforeEach(() => {
|
||
cap = _captureLogger();
|
||
});
|
||
afterEach(() => {
|
||
cap.restore();
|
||
});
|
||
|
||
const infos = () => cap.records().filter((r) => r.level === 30);
|
||
const warns = () => cap.records().filter((r) => r.level === 40);
|
||
|
||
it('says nothing on a specific bind with no public origin', () => {
|
||
setPublicOrigin(undefined);
|
||
logOriginPolicy('192.168.1.10');
|
||
expect(cap.records()).toEqual([]);
|
||
});
|
||
|
||
it('names the resolved hostname at info when a public origin is configured', () => {
|
||
setPublicOrigin('https://App.Example.com:8443');
|
||
logOriginPolicy('192.168.1.10');
|
||
expect(infos()).toHaveLength(1);
|
||
expect(String(infos()[0].msg)).toContain('app.example.com');
|
||
expect(infos()[0].hostname).toBe('app.example.com');
|
||
expect(warns()).toEqual([]);
|
||
});
|
||
|
||
// The four-way matrix: wildcard bind × public origin absent/valid/invalid.
|
||
it('warns on a wildcard bind with no public origin, pointing at both remedies', () => {
|
||
setPublicOrigin(undefined);
|
||
logOriginPolicy('0.0.0.0');
|
||
expect(warns()).toHaveLength(1);
|
||
expect(String(warns()[0].msg)).toContain('wildcard address (0.0.0.0)');
|
||
expect(String(warns()[0].msg)).toContain('--host');
|
||
expect(String(warns()[0].msg)).toContain(PUBLIC_ORIGIN_ENV);
|
||
});
|
||
|
||
it('still warns on a wildcard bind with a valid public origin, and names it', () => {
|
||
setPublicOrigin('https://app.example.com');
|
||
logOriginPolicy('::');
|
||
expect(infos()).toHaveLength(1);
|
||
expect(warns()).toHaveLength(1);
|
||
expect(String(warns()[0].msg)).toContain('app.example.com');
|
||
});
|
||
|
||
it('diagnoses an unusable public origin rather than reporting one', () => {
|
||
setPublicOrigin('a.com,b.com');
|
||
logOriginPolicy('0.0.0.0');
|
||
expect(infos()).toEqual([]);
|
||
// One for the unusable value, one for the wildcard bind it fails to rescue.
|
||
expect(warns()).toHaveLength(2);
|
||
expect(String(warns()[0].msg)).toContain('a.com,b.com');
|
||
expect(String(warns()[1].msg)).toContain('--host');
|
||
});
|
||
|
||
it('diagnoses an unusable public origin on a specific bind too', () => {
|
||
setPublicOrigin('*');
|
||
logOriginPolicy('192.168.1.10');
|
||
expect(warns()).toHaveLength(1);
|
||
expect(String(warns()[0].msg)).toContain(PUBLIC_ORIGIN_ENV);
|
||
});
|
||
|
||
it('does not report a trailing-dot hostname as a working origin', () => {
|
||
setPublicOrigin('app.example.com.');
|
||
logOriginPolicy('0.0.0.0');
|
||
expect(infos()).toEqual([]);
|
||
expect(String(warns()[0].msg)).toContain('app.example.com.');
|
||
});
|
||
});
|
||
|
||
/**
|
||
* `serve` has no authentication, and the guard above passes every request with
|
||
* no Origin header — so GITNEXUS_PUBLIC_ORIGIN, the setting that makes a public
|
||
* bind usable, must not be usable until the lock exists.
|
||
*/
|
||
describe('assertServeAuthForPublicOrigin', () => {
|
||
it('is a no-op when no public origin is configured', () => {
|
||
setPublicOrigin(undefined);
|
||
expect(() => assertServeAuthForPublicOrigin()).not.toThrow();
|
||
});
|
||
|
||
it.each([' ', ''])('treats a blank value (%j) as unset', (raw) => {
|
||
setPublicOrigin(raw);
|
||
expect(() => assertServeAuthForPublicOrigin()).not.toThrow();
|
||
});
|
||
|
||
it('throws when a public origin is configured and no auth is', () => {
|
||
setPublicOrigin('https://app.example.com');
|
||
expect(() => assertServeAuthForPublicOrigin()).toThrow(/has no authentication yet/);
|
||
});
|
||
|
||
it('names the variable and the value in the failure, and points at both remedies', () => {
|
||
setPublicOrigin('https://app.example.com');
|
||
let message = '';
|
||
try {
|
||
assertServeAuthForPublicOrigin();
|
||
} catch (err) {
|
||
message = err instanceof Error ? err.message : String(err);
|
||
}
|
||
expect(message).toContain(PUBLIC_ORIGIN_ENV);
|
||
expect(message).toContain('https://app.example.com');
|
||
expect(message).toContain('DELETE /api/repo');
|
||
expect(message).toContain('loopback');
|
||
});
|
||
|
||
// Even a value the matcher would reject throws: the operator's intent to serve
|
||
// a public origin is the risk, and an unusable value is not a safer one.
|
||
it.each(['a.com,b.com', '*', '8080'])('throws on an unusable value too: %s', (raw) => {
|
||
setPublicOrigin(raw);
|
||
expect(() => assertServeAuthForPublicOrigin()).toThrow();
|
||
});
|
||
|
||
// The auth change flips this predicate; the gate above is then satisfiable
|
||
// without rewriting it. Pinned so the flip cannot happen unnoticed.
|
||
it('reports no auth configured, since serve has none', () => {
|
||
expect(isServeAuthConfigured()).toBe(false);
|
||
});
|
||
});
|