Skip to content

Commit 6e92f35

Browse files
test: git handler HTTP contract coverage + fix DATA_ROOT pollution in auto-init suite (#375) (#549)
src/handlers/git.js had zero automated coverage — #371 (CORS) and #373 (multi-slash) were verified by hand. New test/git-handler.test.js pins the HTTP contract with 8 cases: - OPTIONS preflight → 200 + canonical git CORS headers (#371) - single/double/triple-slash info/refs advertise → 200 (#373) - missing repo → 404 WITH CORS headers (#374) - path traversal (percent-encoded so fetch doesn't normalize it away) → blocked 403/404, never 200/500, with CORS - unauthenticated push advertise → 401 + WWW-Authenticate on a non-public server. Deliberately does NOT assert CORS: the WAC preHandler's 401 path doesn't set the git CORS headers today — that gap is now tracked as #548; the test asserts what is true. - end-to-end push: real `git push HEAD:main` via async spawn → exit 0 → pushed file served as a static resource (receive-pack POST through http-backend + updateInstead extraction). The push MUST use async spawn — spawnSync blocks the event loop and the in-process server can never respond (deadlock). Also fixes a latent cross-test pollution bug in test/git-auto-init.test.js: the unauthenticated-ACL test boots a second server with root './test-data-git-auto-init-authcheck', and createServer({ root }) mutates process.env.DATA_ROOT globally without restoring it. Every test after it silently ran against the WRONG data root — handleGit auto-inited public/apps/penny inside the authcheck dir, the suite cleanup missed it, and the orphaned directory kept reappearing after every full test run. Snapshot/restore the env in the test's finally block (same pollution class as the #543 review finding). The authcheck dir no longer survives a run. Full suite: 934/934 passing. Closes #375.
1 parent 9a76f0b commit 6e92f35

2 files changed

Lines changed: 248 additions & 0 deletions

File tree

test/git-auto-init.test.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,14 @@ describe('Git auto-init on first push', () => {
141141
await fs.ensureDir(DATA_DIR_AUTH);
142142
const port = await getAvailablePort();
143143
const authBaseUrl = `http://${TEST_HOST}:${port}`;
144+
// createServer({ root }) mutates process.env.DATA_ROOT globally, and
145+
// handleGit resolves the data root PER REQUEST via getDataRoot().
146+
// Without restoring the env afterwards, every later test in this
147+
// file silently runs against DATA_DIR_AUTH instead of DATA_DIR —
148+
// auto-initing repos in the wrong tree and leaving the authcheck
149+
// directory behind after the suite's cleanup (which only removes
150+
// DATA_DIR). Same pollution class as the #543 review finding.
151+
const originalDataRoot = process.env.DATA_ROOT;
144152
const authServer = createServer({
145153
logger: false,
146154
root: DATA_DIR_AUTH,
@@ -162,6 +170,8 @@ describe('Git auto-init on first push', () => {
162170
'auto-init MUST NOT create a directory for an unauthenticated request');
163171
} finally {
164172
await authServer.close();
173+
if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
174+
else process.env.DATA_ROOT = originalDataRoot;
165175
await fs.remove(DATA_DIR_AUTH);
166176
}
167177
});

test/git-handler.test.js

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
/**
2+
* Git handler HTTP contract (#375).
3+
*
4+
* src/handlers/git.js previously had no automated coverage — the #371
5+
* (CORS) and #373 (multi-slash) fixes were verified by hand. These
6+
* tests pin the HTTP-level contract:
7+
*
8+
* - OPTIONS preflight carries the canonical git CORS headers
9+
* - info/refs advertise works through single/double/triple-slash
10+
* URLs (#373 regression — git http-backend rejects "aliased"
11+
* paths, so JSS collapses multi-slash before forwarding)
12+
* - missing repo → 404 WITH CORS headers (#374 regression — without
13+
* them a browser git client sees a CORS error, not the 404)
14+
* - path traversal attempts are blocked (never 200, never 500)
15+
* with CORS headers
16+
* - unauthenticated push advertise → 401 + WWW-Authenticate
17+
* (NOTE: this 401 is emitted by the server.js WAC preHandler,
18+
* which does NOT currently set the git CORS headers — that gap is
19+
* tracked separately; the test asserts what is true today)
20+
* - a real end-to-end `git push` succeeds and the pushed file is
21+
* served as a static resource (receive-pack POST through
22+
* http-backend + updateInstead extraction)
23+
*
24+
* Boot pattern mirrors test/git-auto-init.test.js: temp data dir,
25+
* `git: true, public: true` so WAC is open and the handler logic is
26+
* exercised in isolation. The 401 case boots a second, non-public
27+
* server and snapshots/restores process.env.DATA_ROOT around it
28+
* (createServer({ root }) mutates it globally).
29+
*/
30+
31+
import { describe, it, before, after } from 'node:test';
32+
import assert from 'node:assert';
33+
import { createServer } from '../src/server.js';
34+
import { createServer as createNetServer } from 'net';
35+
import { spawn, spawnSync } from 'child_process';
36+
import fs from 'fs-extra';
37+
import path from 'path';
38+
39+
const TEST_HOST = 'localhost';
40+
const DATA_DIR = './test-data-git-handler';
41+
const LOCAL_REPO = './test-data-git-handler-local';
42+
43+
function getAvailablePort() {
44+
return new Promise((resolve, reject) => {
45+
const srv = createNetServer();
46+
srv.on('error', reject);
47+
srv.listen(0, TEST_HOST, () => {
48+
const port = srv.address().port;
49+
srv.close(() => resolve(port));
50+
});
51+
});
52+
}
53+
54+
// Synchronous git for purely-LOCAL operations (init/add/commit). Fine
55+
// to block the event loop because no in-process server is involved.
56+
function git(args, cwd) {
57+
const res = spawnSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
58+
return {
59+
status: res.status,
60+
stdout: res.stdout?.toString() || '',
61+
stderr: res.stderr?.toString() || '',
62+
};
63+
}
64+
65+
// Async git for operations that talk to the in-process test server
66+
// (push). spawnSync would block the event loop, the server could never
67+
// respond, and the test would deadlock until the runner times out.
68+
//
69+
// GIT_TERMINAL_PROMPT=0 makes git fail fast instead of waiting on a
70+
// credential prompt if the remote unexpectedly challenges (stdin is
71+
// 'ignore', so a prompt could otherwise wedge the test in CI). The
72+
// `error` listener covers spawn-level failures (e.g. git missing from
73+
// PATH), where `close` may never fire and the promise would hang.
74+
function gitAsync(args, cwd) {
75+
return new Promise((resolve) => {
76+
const child = spawn('git', args, {
77+
cwd,
78+
stdio: ['ignore', 'pipe', 'pipe'],
79+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
80+
});
81+
let stdout = '';
82+
let stderr = '';
83+
child.stdout.on('data', (d) => { stdout += d; });
84+
child.stderr.on('data', (d) => { stderr += d; });
85+
child.on('error', (err) => resolve({ status: -1, stdout, stderr: `spawn failed: ${err.message}` }));
86+
child.on('close', (status) => resolve({ status, stdout, stderr }));
87+
});
88+
}
89+
90+
function assertGitCors(res, label) {
91+
assert.strictEqual(res.headers.get('access-control-allow-origin'), '*',
92+
`${label}: Access-Control-Allow-Origin must be *`);
93+
assert.strictEqual(res.headers.get('access-control-allow-methods'), 'GET, POST, OPTIONS',
94+
`${label}: Access-Control-Allow-Methods mismatch`);
95+
assert.strictEqual(res.headers.get('access-control-allow-headers'), 'Content-Type, Authorization, Git-Protocol',
96+
`${label}: Access-Control-Allow-Headers must include Git-Protocol (#371)`);
97+
}
98+
99+
describe('git handler HTTP contract (#375)', () => {
100+
let server;
101+
let baseUrl;
102+
// Repo created once in before() via a real receive-pack advertise
103+
// (auto-init), then reused by the advertise/slash tests below.
104+
const REPO = 'public/site';
105+
106+
before(async () => {
107+
await fs.remove(DATA_DIR);
108+
await fs.ensureDir(DATA_DIR);
109+
110+
const port = await getAvailablePort();
111+
baseUrl = `http://${TEST_HOST}:${port}`;
112+
113+
server = createServer({
114+
logger: false,
115+
root: DATA_DIR,
116+
git: true,
117+
public: true,
118+
});
119+
await server.listen({ port, host: TEST_HOST });
120+
121+
// Materialize a repo at REPO via the auto-init path so the
122+
// upload-pack advertise tests have something real to talk to.
123+
const res = await fetch(`${baseUrl}/${REPO}/info/refs?service=git-receive-pack`);
124+
assert.strictEqual(res.status, 200, 'prereq: auto-init advertise must succeed');
125+
});
126+
127+
after(async () => {
128+
if (server) await server.close();
129+
await fs.remove(DATA_DIR);
130+
await fs.remove(LOCAL_REPO);
131+
});
132+
133+
it('OPTIONS preflight returns 200 with the canonical git CORS headers', async () => {
134+
const res = await fetch(`${baseUrl}/${REPO}/info/refs?service=git-upload-pack`, {
135+
method: 'OPTIONS',
136+
});
137+
assert.strictEqual(res.status, 200, 'preflight must be 200');
138+
assertGitCors(res, 'OPTIONS preflight');
139+
});
140+
141+
it('single-slash info/refs advertise returns 200 with the advertisement content type', async () => {
142+
const res = await fetch(`${baseUrl}/${REPO}/info/refs?service=git-upload-pack`);
143+
assert.strictEqual(res.status, 200);
144+
assert.strictEqual(res.headers.get('content-type'),
145+
'application/x-git-upload-pack-advertisement');
146+
});
147+
148+
it('double-slash //info/refs returns 200 (#373 regression)', async () => {
149+
// git http-backend rejects "aliased" multi-slash paths; JSS must
150+
// collapse them before forwarding or the request 500s.
151+
const res = await fetch(`${baseUrl}/${REPO}//info/refs?service=git-upload-pack`);
152+
assert.strictEqual(res.status, 200,
153+
`double slash must not break the advertise; got ${res.status}`);
154+
});
155+
156+
it('triple-slash ///info/refs returns 200 (#373 regression)', async () => {
157+
const res = await fetch(`${baseUrl}/${REPO}///info/refs?service=git-upload-pack`);
158+
assert.strictEqual(res.status, 200,
159+
`triple slash must not break the advertise; got ${res.status}`);
160+
});
161+
162+
it('missing repo returns 404 with CORS headers (#374 regression)', async () => {
163+
// upload-pack (fetch) on a non-existent path: no auto-init, plain 404.
164+
const res = await fetch(`${baseUrl}/public/no-such-repo/info/refs?service=git-upload-pack`);
165+
assert.strictEqual(res.status, 404);
166+
assertGitCors(res, 'missing-repo 404');
167+
});
168+
169+
it('path traversal attempt is blocked, never 200/500, with CORS headers', async () => {
170+
// Percent-encoded so fetch() doesn't normalize the dots away
171+
// client-side. handleGit decodes, strips `..`, and containment-
172+
// checks the result — depending on what survives sanitization the
173+
// response is 403 (escape detected) or 404 (sanitized path has no
174+
// repo). Both are acceptable; 200 (leak) and 500 (crash) are not.
175+
const res = await fetch(
176+
`${baseUrl}/%2e%2e/%2e%2e/%2e%2e/etc/info/refs?service=git-upload-pack`);
177+
assert.ok(res.status === 403 || res.status === 404,
178+
`traversal must be blocked with 403/404, got ${res.status}`);
179+
assertGitCors(res, 'traversal block');
180+
});
181+
182+
it('unauthenticated push advertise returns 401 with WWW-Authenticate (non-public server)', async () => {
183+
// Second server WITHOUT public:true so the WAC preHandler gates
184+
// the push. Snapshot/restore DATA_ROOT — createServer({ root })
185+
// mutates it process-wide and the main suite's server reads it
186+
// per-request.
187+
const DATA_DIR_AUTH = './test-data-git-handler-auth';
188+
await fs.remove(DATA_DIR_AUTH);
189+
await fs.ensureDir(DATA_DIR_AUTH);
190+
const port = await getAvailablePort();
191+
const originalDataRoot = process.env.DATA_ROOT;
192+
const authServer = createServer({ logger: false, root: DATA_DIR_AUTH, git: true });
193+
try {
194+
await authServer.listen({ port, host: TEST_HOST });
195+
const res = await fetch(
196+
`http://${TEST_HOST}:${port}/public/x/info/refs?service=git-receive-pack`);
197+
assert.strictEqual(res.status, 401,
198+
`unauthenticated push advertise must be 401, got ${res.status}`);
199+
assert.ok(res.headers.get('www-authenticate'),
200+
'WWW-Authenticate must be present so git CLI clients prompt for credentials');
201+
// Deliberately NOT asserting CORS here: the WAC preHandler's
202+
// 401 path does not currently set the git CORS headers (tracked
203+
// as a follow-up). Assert truth, not aspiration.
204+
} finally {
205+
await authServer.close();
206+
if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
207+
else process.env.DATA_ROOT = originalDataRoot;
208+
await fs.remove(DATA_DIR_AUTH);
209+
}
210+
});
211+
212+
it('end-to-end push succeeds and the pushed file is served as a static resource', async () => {
213+
// Build a local repo with one file and push it. Auto-init pins the
214+
// server-side HEAD to refs/heads/main (#471), so pushing HEAD:main
215+
// triggers receive.denyCurrentBranch=updateInstead extraction and
216+
// the file becomes a static resource at the pod URL.
217+
await fs.remove(LOCAL_REPO);
218+
await fs.ensureDir(LOCAL_REPO);
219+
assert.strictEqual(git(['init', '--quiet'], LOCAL_REPO).status, 0, 'git init');
220+
await fs.writeFile(path.join(LOCAL_REPO, 'index.html'), '<p>pushed-via-test</p>\n');
221+
assert.strictEqual(git(['add', 'index.html'], LOCAL_REPO).status, 0, 'git add');
222+
const commit = git([
223+
'-c', 'user.email=test@example.invalid',
224+
'-c', 'user.name=jss-test',
225+
'commit', '--quiet', '-m', 'test commit',
226+
], LOCAL_REPO);
227+
assert.strictEqual(commit.status, 0, `git commit failed: ${commit.stderr}`);
228+
229+
const push = await gitAsync(['push', '--quiet', `${baseUrl}/public/pushed-site`, 'HEAD:main'], LOCAL_REPO);
230+
assert.strictEqual(push.status, 0, `git push failed: ${push.stderr.slice(0, 400)}`);
231+
232+
// updateInstead extracted the working tree → file is now served.
233+
const res = await fetch(`${baseUrl}/public/pushed-site/index.html`);
234+
assert.strictEqual(res.status, 200, 'pushed file must be served over HTTP');
235+
const body = await res.text();
236+
assert.ok(body.includes('pushed-via-test'), 'served content must match the pushed file');
237+
});
238+
});

0 commit comments

Comments
 (0)