Files
gitmost/apps/server/src/integrations/git-sync/http/git-http-backend.service.spec.ts
T
a 22e3fcdeba fix(git-sync): address PR #119 review #2 — throttle /git Basic auth, fix mcp schema drift + warnings/tests
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>
2026-06-28 15:10:10 +03:00

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();
});
});