22e3fcdeba
Must-fix:
- Throttle the raw /git HTTP-Basic path: it bypasses Nest/ThrottlerGuard, so
verifyUserCredentials (bcrypt) ran unthrottled. Wrap it in the SAME
FailedLoginLimiter the /mcp path uses (5/60s; per-IP, per-IP+email, global
per-email keys; atomic tryReserve BEFORE bcrypt; success resets, non-credential
errors release). The (threshold+1)-th attempt now gets 429 pre-bcrypt. Sweep
timer + onModuleDestroy mirror McpService.
- Fix the mcp schema mirror drift: packages/mcp details `open` attr now reads via
hasAttribute (matches editor-ext canon + git-sync copy); getAttribute dropped a
bare `<details open>` state. (build/ is gitignored — rebuilt locally.)
Tests added:
- /git brute-force throttle: pre-bcrypt 429 on the 6th failure; success resets;
non-credential error releases the budget.
- git-http-backend lost-lock AbortSignal: already-aborted -> no spawn + 500;
live abort mid-request -> SIGTERM + response closed.
- orchestrator divergentDocmost -> WARN + flag surfaced in status (+ clean case).
- pollTick re-entrancy guard skips an overlapping tick.
- datasource NotFound early-throws (getPageJson/move/rename) + updatedAt:undefined
stale-read branch (importPageMarkdown/createPage).
Suggestions:
- space.repo updateGitSyncSettings: parameterize the jsonb key (`${prefKey}::text`)
instead of sql.raw (latent-injection footgun); value stays sql.lit. Spec updated.
- pollTick re-entrancy guard (private `polling` flag).
- page-change.listener docstring: honest about the move/rename/delete over-skip
(loop-guard keys only on lastUpdatedSource) -> ~poll-interval latency, not loss.
- AGENTS.md: document the root /git smart-HTTP route + GitSyncModule.
- Remove redundant redteam-provenance.spec.ts (covered e2e in
persistence.extension.spec.ts:145).
- Extract the duplicated SIGTERM->SIGKILL+finish block (watchdog + abort) into
terminateChild; centralize watchdog-timer teardown in done().
Architecture (deferred, documented): mcp schema header now carries the three-copy
keep-in-sync + schema-core note; the editor-ext contract test documents that the
mcp copy and attribute-behaviour drift (details `open`) are not mechanically
covered yet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
415 lines
15 KiB
TypeScript
415 lines
15 KiB
TypeScript
// Unit tests for the pure CGI-response helpers used by GitHttpBackendService.
|
|
// The header/body split MUST treat the body as binary (Buffer) and never
|
|
// stringify it; the Status: header sets the HTTP status (default 200).
|
|
import { EventEmitter } from 'node:events';
|
|
import { spawn } from 'node:child_process';
|
|
|
|
// Mock the spawn boundary so run() never launches a real `git http-backend`; the
|
|
// fake child lets us drive every stdout/stderr/error/close branch by hand.
|
|
jest.mock('node:child_process', () => ({ spawn: jest.fn() }));
|
|
// vaultGitEnv just builds the CGI env overlay; stub it to a passthrough so the
|
|
// service runs without the real engine. The service loads it at runtime via the
|
|
// `loadGitSync()` bridge (the ESM `@docmost/git-sync` package cannot be
|
|
// `require()`d under jest), so we mock that loader rather than the package.
|
|
jest.mock('../git-sync.loader', () => ({
|
|
loadGitSync: jest.fn(async () => ({
|
|
vaultGitEnv: (overlay: Record<string, string>) => overlay,
|
|
})),
|
|
}));
|
|
|
|
import {
|
|
parseCgiResponse,
|
|
splitCgiBuffer,
|
|
buildGitBackendCgiEnv,
|
|
GitHttpBackendService,
|
|
} from './git-http-backend.service';
|
|
import { Logger } from '@nestjs/common';
|
|
import type { GitHttpBackendRequest } from './git-http-backend.service';
|
|
|
|
const spawnMock = spawn as unknown as jest.Mock;
|
|
|
|
/** A fake `git http-backend` child: EventEmitter + stdout/stderr/stdin streams. */
|
|
function fakeChild() {
|
|
const child = new EventEmitter() as any;
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
// stdin is written/ended/piped to; capture the calls, swallow nothing.
|
|
child.stdin = Object.assign(new EventEmitter(), {
|
|
end: jest.fn(),
|
|
write: jest.fn(),
|
|
});
|
|
// The watchdog kills the child on timeout; capture the signal.
|
|
child.kill = jest.fn();
|
|
return child;
|
|
}
|
|
|
|
/** A fake raw Node ServerResponse capturing status/headers/body/end. */
|
|
function fakeRes() {
|
|
const res: any = {
|
|
headersSent: false,
|
|
writableEnded: false,
|
|
statusCode: 200,
|
|
_headers: {} as Record<string, string>,
|
|
_written: [] as Buffer[],
|
|
setHeader: jest.fn((name: string, value: string) => {
|
|
res._headers[name] = value;
|
|
}),
|
|
write: jest.fn((chunk: Buffer) => {
|
|
res._written.push(chunk);
|
|
return true;
|
|
}),
|
|
end: jest.fn((chunk?: Buffer | string) => {
|
|
if (chunk !== undefined) res._written.push(chunk as Buffer);
|
|
res.writableEnded = true;
|
|
}),
|
|
};
|
|
return res;
|
|
}
|
|
|
|
/** A fake raw Node IncomingMessage (GET => no body piped). */
|
|
function fakeReq() {
|
|
const req = new EventEmitter() as any;
|
|
req.pipe = jest.fn();
|
|
return req;
|
|
}
|
|
|
|
const baseRequest: GitHttpBackendRequest = {
|
|
spaceId: 'space-1',
|
|
subpath: 'info/refs',
|
|
method: 'GET',
|
|
queryString: 'service=git-upload-pack',
|
|
contentType: '',
|
|
remoteUser: 'alice@example.com',
|
|
};
|
|
|
|
function buildService(backendTimeoutMs = 120000) {
|
|
const env = {
|
|
getGitSyncDataDir: jest.fn(() => '/vaults'),
|
|
// The watchdog timeout for the spawned git http-backend. Tests inject a tiny
|
|
// value (or use fake timers) to drive the timeout branch.
|
|
getGitSyncBackendTimeoutMs: jest.fn(() => backendTimeoutMs),
|
|
};
|
|
return new GitHttpBackendService(env as any);
|
|
}
|
|
|
|
// `run()` now awaits the async `loadGitSync()` bridge before it spawns the
|
|
// child, so the spawn (and its stream-handler wiring) happens one microtask
|
|
// after `run()` is called. These tests drive the fake child synchronously, so
|
|
// flush the microtask queue first to let `run()` reach the spawn.
|
|
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
|
|
|
describe('GitHttpBackendService.run', () => {
|
|
beforeEach(() => {
|
|
spawnMock.mockReset();
|
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
|
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
});
|
|
afterEach(() => jest.restoreAllMocks());
|
|
|
|
it('(a) responds 500 when the child errors before any headers were written', async () => {
|
|
const child = fakeChild();
|
|
spawnMock.mockReturnValue(child);
|
|
const service = buildService();
|
|
const res = fakeRes();
|
|
|
|
const p = service.run(baseRequest, fakeReq(), res);
|
|
await flush();
|
|
// Emit a child 'error' before any stdout -> 500, headers not already sent.
|
|
child.emit('error', new Error('ENOENT spawn git'));
|
|
await p;
|
|
|
|
expect(res.statusCode).toBe(500);
|
|
expect(res._headers['Content-Type']).toBe('text/plain');
|
|
expect(res.end).toHaveBeenCalledWith('Internal server error');
|
|
});
|
|
|
|
it('(a) responds 500 when the child closes before a complete CGI header block', async () => {
|
|
const child = fakeChild();
|
|
spawnMock.mockReturnValue(child);
|
|
const service = buildService();
|
|
const res = fakeRes();
|
|
|
|
const p = service.run(baseRequest, fakeReq(), res);
|
|
await flush();
|
|
// stderr diagnostics, then a close with no valid CGI output -> 500.
|
|
child.stderr.emit('data', Buffer.from('fatal: boom'));
|
|
child.emit('close', 128);
|
|
await p;
|
|
|
|
expect(res.statusCode).toBe(500);
|
|
expect(res.end).toHaveBeenCalledWith('Internal server error');
|
|
});
|
|
|
|
it('(b) parses the CGI header block, sets status/headers, writes the body', async () => {
|
|
const child = fakeChild();
|
|
spawnMock.mockReturnValue(child);
|
|
const service = buildService();
|
|
const res = fakeRes();
|
|
|
|
const p = service.run(baseRequest, fakeReq(), res);
|
|
await flush();
|
|
// A full CGI response: status line + header + blank line + body.
|
|
child.stdout.emit(
|
|
'data',
|
|
Buffer.from(
|
|
'Status: 200 OK\r\nContent-Type: application/x-git-upload-pack-advertisement\r\n\r\nPACKBODY',
|
|
'utf8',
|
|
),
|
|
);
|
|
child.emit('close', 0);
|
|
await p;
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res._headers['Content-Type']).toBe(
|
|
'application/x-git-upload-pack-advertisement',
|
|
);
|
|
expect(Buffer.concat(res._written.map((c) => Buffer.from(c))).toString()).toContain(
|
|
'PACKBODY',
|
|
);
|
|
expect(res.writableEnded).toBe(true);
|
|
});
|
|
|
|
it('(c) swallows a stdout stream error (EPIPE) without throwing or 500ing', async () => {
|
|
const child = fakeChild();
|
|
spawnMock.mockReturnValue(child);
|
|
const service = buildService();
|
|
const res = fakeRes();
|
|
const warnSpy = jest.spyOn(Logger.prototype, 'warn');
|
|
|
|
const p = service.run(baseRequest, fakeReq(), res);
|
|
await flush();
|
|
// The stdout 'error' handler must absorb this — no unhandled throw, no 500.
|
|
expect(() => child.stdout.emit('error', new Error('EPIPE'))).not.toThrow();
|
|
expect(() => child.stderr.emit('error', new Error('EPIPE'))).not.toThrow();
|
|
expect(warnSpy).toHaveBeenCalled();
|
|
expect(res.statusCode).not.toBe(500);
|
|
|
|
// Let run() settle so the promise does not dangle.
|
|
child.emit('close', 0);
|
|
await p;
|
|
});
|
|
|
|
it('(d) timeout: a child that never closes is killed and a 500 is sent', async () => {
|
|
// The child never emits stdout/close (a stalled git-receive-pack). With a
|
|
// tiny injected watchdog timeout the run() promise must still resolve: the
|
|
// child is killed and a clean 500 is sent (no headers were sent yet).
|
|
const child = fakeChild();
|
|
spawnMock.mockReturnValue(child);
|
|
const service = buildService(5); // 5ms watchdog
|
|
const res = fakeRes();
|
|
const warnSpy = jest.spyOn(Logger.prototype, 'warn');
|
|
|
|
// run() resolves only via the watchdog firing (no close/error emitted).
|
|
await service.run(baseRequest, fakeReq(), res);
|
|
|
|
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
|
|
expect(warnSpy).toHaveBeenCalled();
|
|
expect(res.statusCode).toBe(500);
|
|
expect(res.end).toHaveBeenCalledWith('Internal server error');
|
|
});
|
|
|
|
it('(d) timeout watchdog is cleared on a normal close (no kill, no 500)', async () => {
|
|
// A normal request that completes well within the watchdog window must NOT be
|
|
// killed and must NOT trip the timeout 500 — the timer is cleared on close.
|
|
jest.useFakeTimers();
|
|
try {
|
|
const child = fakeChild();
|
|
spawnMock.mockReturnValue(child);
|
|
const service = buildService(120000);
|
|
const res = fakeRes();
|
|
|
|
const p = service.run(baseRequest, fakeReq(), res);
|
|
// loadGitSync resolves on a real microtask; advance it under fake timers.
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
child.stdout.emit(
|
|
'data',
|
|
Buffer.from('Status: 200 OK\r\nContent-Type: text/plain\r\n\r\nOK', 'utf8'),
|
|
);
|
|
child.emit('close', 0);
|
|
await p;
|
|
|
|
// The watchdog never fired even if we advance past its window.
|
|
jest.advanceTimersByTime(200000);
|
|
expect(child.kill).not.toHaveBeenCalled();
|
|
expect(res.statusCode).toBe(200);
|
|
} finally {
|
|
jest.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('spawn throwing synchronously -> 500 (spawn-failed)', async () => {
|
|
spawnMock.mockImplementation(() => {
|
|
throw new Error('spawn EACCES');
|
|
});
|
|
const service = buildService();
|
|
const res = fakeRes();
|
|
|
|
await service.run(baseRequest, fakeReq(), res);
|
|
|
|
expect(res.statusCode).toBe(500);
|
|
expect(res.end).toHaveBeenCalledWith('Internal server error');
|
|
});
|
|
|
|
it('(abort) an ALREADY-aborted signal -> no spawn, 500 lock-lost', async () => {
|
|
// The per-space lock was already lost before run() reached the spawn: we must
|
|
// NOT start writing the working tree after a possible lock takeover.
|
|
const child = fakeChild();
|
|
spawnMock.mockReturnValue(child);
|
|
const service = buildService();
|
|
const res = fakeRes();
|
|
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
await service.run(baseRequest, fakeReq(), res, controller.signal);
|
|
|
|
expect(spawnMock).not.toHaveBeenCalled();
|
|
expect(res.statusCode).toBe(500);
|
|
expect(res.end).toHaveBeenCalledWith('Internal server error');
|
|
});
|
|
|
|
it('(abort) a live signal aborted mid-request -> child SIGTERM + response closed', async () => {
|
|
// The lock lapses mid-push: the abort fires, the child is killed (SIGTERM,
|
|
// then SIGKILL on escalation), and the response is finished.
|
|
const child = fakeChild();
|
|
spawnMock.mockReturnValue(child);
|
|
const service = buildService();
|
|
const res = fakeRes();
|
|
const warnSpy = jest.spyOn(Logger.prototype, 'warn');
|
|
|
|
const controller = new AbortController();
|
|
const p = service.run(baseRequest, fakeReq(), res, controller.signal);
|
|
await flush(); // let run() reach the spawn + wire the abort listener
|
|
controller.abort();
|
|
await p;
|
|
|
|
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
|
|
expect(warnSpy).toHaveBeenCalled();
|
|
// No headers were sent before the abort -> a clean 500 is sent and ended.
|
|
expect(res.statusCode).toBe(500);
|
|
expect(res.writableEnded).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('buildGitBackendCgiEnv', () => {
|
|
const base = {
|
|
spaceId: 'space-1',
|
|
subpath: 'info/refs',
|
|
method: 'GET',
|
|
queryString: 'service=git-upload-pack',
|
|
contentType: '',
|
|
remoteUser: 'alice@example.com',
|
|
};
|
|
|
|
it('points PATH_INFO at the NON-bare repo dir (no .git suffix)', () => {
|
|
// Regression guard: the vault lives at <root>/<spaceId> (a working repo), so
|
|
// PATH_INFO must be /<spaceId>/<subpath>. A `.git` suffix made git
|
|
// http-backend resolve <root>/<spaceId>.git and 404 every fetch/push.
|
|
const env = buildGitBackendCgiEnv(base, '/vaults');
|
|
expect(env.PATH_INFO).toBe('/space-1/info/refs');
|
|
expect(env.PATH_INFO).not.toContain('.git');
|
|
expect(env.GIT_PROJECT_ROOT).toBe('/vaults');
|
|
});
|
|
|
|
it('forwards method/query/content-type/remote-user and exports all repos', () => {
|
|
const env = buildGitBackendCgiEnv(
|
|
{ ...base, method: 'POST', subpath: 'git-receive-pack', contentType: 'application/x-git-receive-pack-request', queryString: '' },
|
|
'/vaults',
|
|
);
|
|
expect(env.REQUEST_METHOD).toBe('POST');
|
|
expect(env.PATH_INFO).toBe('/space-1/git-receive-pack');
|
|
expect(env.CONTENT_TYPE).toBe('application/x-git-receive-pack-request');
|
|
expect(env.REMOTE_USER).toBe('alice@example.com');
|
|
expect(env.GIT_HTTP_EXPORT_ALL).toBe('1');
|
|
});
|
|
|
|
it('sets GIT_PROTOCOL only when the client sent the header', () => {
|
|
expect(buildGitBackendCgiEnv(base, '/vaults').GIT_PROTOCOL).toBeUndefined();
|
|
expect(
|
|
buildGitBackendCgiEnv({ ...base, gitProtocol: 'version=2' }, '/vaults')
|
|
.GIT_PROTOCOL,
|
|
).toBe('version=2');
|
|
});
|
|
});
|
|
|
|
describe('parseCgiResponse', () => {
|
|
it('defaults to status 200 with no Status header', () => {
|
|
const r = parseCgiResponse('Content-Type: application/x-git-upload-pack-result');
|
|
expect(r.statusCode).toBe(200);
|
|
expect(r.headers).toEqual([
|
|
['Content-Type', 'application/x-git-upload-pack-result'],
|
|
]);
|
|
});
|
|
|
|
it('honors a Status header and does not forward it', () => {
|
|
const r = parseCgiResponse('Status: 404 Not Found\nContent-Type: text/plain');
|
|
expect(r.statusCode).toBe(404);
|
|
expect(r.headers).toEqual([['Content-Type', 'text/plain']]);
|
|
});
|
|
|
|
it('parses multiple headers and trims whitespace', () => {
|
|
const r = parseCgiResponse(
|
|
'Status: 403 Forbidden\r\nContent-Type: text/plain \r\nX-Foo: bar ',
|
|
);
|
|
expect(r.statusCode).toBe(403);
|
|
expect(r.headers).toEqual([
|
|
['Content-Type', 'text/plain'],
|
|
['X-Foo', 'bar'],
|
|
]);
|
|
});
|
|
|
|
it('ignores malformed (colon-less) lines defensively', () => {
|
|
const r = parseCgiResponse('Content-Type: text/plain\ngarbage-line\nX-A: b');
|
|
expect(r.statusCode).toBe(200);
|
|
expect(r.headers).toEqual([
|
|
['Content-Type', 'text/plain'],
|
|
['X-A', 'b'],
|
|
]);
|
|
});
|
|
|
|
it('ignores an out-of-range Status code and keeps the default', () => {
|
|
const r = parseCgiResponse('Status: not-a-number\nContent-Type: text/plain');
|
|
expect(r.statusCode).toBe(200);
|
|
});
|
|
|
|
it('treats the Status header case-insensitively', () => {
|
|
const r = parseCgiResponse('status: 500 Boom');
|
|
expect(r.statusCode).toBe(500);
|
|
expect(r.headers).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('splitCgiBuffer', () => {
|
|
it('splits on a CRLF blank line and keeps the body as bytes', () => {
|
|
const buf = Buffer.concat([
|
|
Buffer.from('Status: 200 OK\r\nContent-Type: text/plain\r\n\r\n', 'utf8'),
|
|
Buffer.from([0x00, 0x01, 0x02, 0xff]),
|
|
]);
|
|
const split = splitCgiBuffer(buf);
|
|
expect(split).not.toBeNull();
|
|
expect(split!.headerText).toBe('Status: 200 OK\r\nContent-Type: text/plain');
|
|
expect(Array.from(split!.body)).toEqual([0x00, 0x01, 0x02, 0xff]);
|
|
});
|
|
|
|
it('splits on a bare LF blank line', () => {
|
|
const buf = Buffer.from('Content-Type: text/plain\n\nhello', 'utf8');
|
|
const split = splitCgiBuffer(buf);
|
|
expect(split).not.toBeNull();
|
|
expect(split!.headerText).toBe('Content-Type: text/plain');
|
|
expect(split!.body.toString('utf8')).toBe('hello');
|
|
});
|
|
|
|
it('returns an empty body when nothing follows the separator', () => {
|
|
const buf = Buffer.from('Content-Type: text/plain\r\n\r\n', 'utf8');
|
|
const split = splitCgiBuffer(buf);
|
|
expect(split).not.toBeNull();
|
|
expect(split!.body.length).toBe(0);
|
|
});
|
|
|
|
it('returns null when there is no blank-line separator yet', () => {
|
|
const buf = Buffer.from('Content-Type: text/plain\r\nincomplete', 'utf8');
|
|
expect(splitCgiBuffer(buf)).toBeNull();
|
|
});
|
|
});
|