GitNexus/gitnexus/test/unit/publish.test.ts
mengkaka 79543c8f83
feat(storage): add configurable index storage and content retention tiers (#3060)
* feat(storage): add configurable index storage and content retention tiers

Rebase #3060 onto current origin/main. Keep GITNEXUS_STORAGE_PATH,
GITNEXUS_STORAGE_ROOT, and GITNEXUS_CONTENT_RETENTION, and fold in
main's FTS skip, embed-session, and help-text updates.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Keep legacy registry rows on the local storage fallback, resolve
symlinks before the destructive-path guard, and align hook lookup
with CLI branch slugs, branch-slot metadata, and longest-path match.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Only list swept upload directories after a successful removal so
callers cannot treat a permission or transient rm failure as gone.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Document that getStoragePath may consult registered storage while
this module still does not mutate the global registry.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(storage): close review findings for external indexes and retention

Re-inspect ownership under the analyze lock, fail-closed when the
registry file is missing, and keep skip-git hook discovery plus
retention fields on HTTP/MCP list surfaces. /api/file stays 410
unless contentRetention is full.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* Address PR review feedback (#3060)

Treat lock-only index dirs as empty, honor HTTP --force storage policy, and prefer registered plus branch-aware slots in hooks and augment.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Keep hook fallbacks inside the current worktree, compare foreign-local slots canonically, and make storage fixtures survive ownership validation.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix macOS hook test expecting realpath'd registry paths.

resolveHookRepo returns the written registry path, not a filesystem realpath, so the assertion must match that.

* Address gitnexus-check warnings on hook install docs and slot tests.

The Cursor troubleshooting list omitted registry-query.cjs, and the writable-slot test only checked that isDirectory exists instead of that the path is a directory.

* Align the HTTP catalog source-scan with skippable resolveRepo validation.

resolveRepo lists fresh repos with validate: options.validateStorage !== false so DELETE can skip prune; the test still required a literal validate: true.

* Harden storage path sinks so CodeQL path-injection and ReDoS alerts clear.

Contain every filesystem probe inside the resolved storage slot with the inline path.relative idiom, reject filesystem-root slots, and trim slot basenames in linear time.

* Settle bridge stamps before writing so CI size/mtime matches stay stable.

LadybugDB can still flush into bridge.lbug after close+rename; persist whole-millisecond mtimes and wait for consecutive stats to agree so a freshly written pair matches.

* Type the settled bridge stat as fs.Stats so tsc does not see bigint.

Awaited<ReturnType<typeof fsp.stat>> collapsed the bigint overload and broke prepare/typecheck on CI.

* Keep the bridge mtime stamp exact so same-size swaps still fail the pair check.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Wrap the bridge stamp predicate so prettier --check stays green.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Require a quiet interval before stamping a settled bridge file.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Reuse shared storage and settle helpers instead of local copies.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-12 20:31:55 +00:00

318 lines
12 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { performance } from 'node:perf_hooks';
import {
buildUqDispatchPayload,
isValidOwnerRepo,
parseOwnerRepoFromRemote,
stripGitSuffix,
UNDERSTAND_QUICKLY_TOKEN_ENV,
} from 'gitnexus-shared';
describe('understand-quickly helpers (gitnexus-shared)', () => {
describe('isValidOwnerRepo', () => {
it.each([
['looptech-ai/understand-quickly', true],
['abhigyanpatwari/GitNexus', true],
// LOW 8: GitHub user/org slugs are alnum/hyphen only — no underscore.
['Some_Org/Some.Repo-2', false],
['', false],
['just-a-name', false],
['/Users/me/code/repo', false],
['org/with spaces', false],
['org//double', false],
// LOW 8 additions:
['some_org/repo', false], // underscore in owner — invalid
['-org/repo', false], // leading hyphen — invalid
['org-/repo', false], // trailing hyphen — GitHub rejects at account creation; we mirror that here
['org/repo_with_underscore', true],
['org/.dotfile', true], // repos may start with dot
])('returns %s for %j', (id, expected) => {
expect(isValidOwnerRepo(id as string)).toBe(expected);
});
});
describe('stripGitSuffix (BLOCKER 1 — ReDoS-safe)', () => {
it.each([
['https://github.com/o/r.git', 'https://github.com/o/r'],
['https://github.com/o/r.git/', 'https://github.com/o/r'],
['https://github.com/o/r/', 'https://github.com/o/r'],
['https://github.com/o/r', 'https://github.com/o/r'],
['https://github.com/o/r.GIT', 'https://github.com/o/r'],
['https://github.com/o/r//', 'https://github.com/o/r'],
['', ''],
['/', ''],
])('strips %j -> %j', (input, expected) => {
expect(stripGitSuffix(input)).toBe(expected);
});
test('linear time on adversarial trailing slashes (regression for ReDoS)', () => {
const adversarial = 'https://github.com/o/r' + '/'.repeat(10_000);
const start = performance.now();
const result = stripGitSuffix(adversarial);
const elapsed = performance.now() - start;
expect(result).toBe('https://github.com/o/r');
expect(elapsed).toBeLessThan(50); // generous; should be sub-millisecond
});
test('parseOwnerRepoFromRemote terminates quickly on adversarial input', () => {
const adversarial = 'https://github.com/o/r.git' + '/'.repeat(10_000);
const start = performance.now();
const result = parseOwnerRepoFromRemote(adversarial);
const elapsed = performance.now() - start;
expect(result).toBe('o/r');
expect(elapsed).toBeLessThan(50);
});
});
describe('parseOwnerRepoFromRemote', () => {
it.each([
['git@github.com:looptech-ai/understand-quickly.git', 'looptech-ai/understand-quickly'],
['https://github.com/looptech-ai/understand-quickly', 'looptech-ai/understand-quickly'],
['https://github.com/looptech-ai/understand-quickly.git', 'looptech-ai/understand-quickly'],
['ssh://git@github.com/abhigyanpatwari/GitNexus.git', 'abhigyanpatwari/GitNexus'],
])('parses %s -> %s', (url, expected) => {
expect(parseOwnerRepoFromRemote(url)).toBe(expected);
});
// LOW 9: non-GitHub remotes must be rejected — a wrong id is worse
// than no id, since the user can always pass --id explicitly.
it.each([
['https://gitlab.example.com/group/sub/project.git'],
['git@gitlab.example.com:group/sub/project.git'],
['https://bitbucket.org/team/repo.git'],
])('returns null for non-GitHub host %j', (input) => {
expect(parseOwnerRepoFromRemote(input)).toBeNull();
});
it.each([null, undefined, '', ' ', 'not-a-url', 'https://github.com/'])(
'returns null for %j',
(input) => {
expect(parseOwnerRepoFromRemote(input as string | null | undefined)).toBeNull();
},
);
});
describe('buildUqDispatchPayload', () => {
it('wraps the id in the registry-expected event shape', () => {
expect(buildUqDispatchPayload('looptech-ai/understand-quickly')).toEqual({
event_type: 'sync-entry',
client_payload: { id: 'looptech-ai/understand-quickly' },
});
});
it('throws on a malformed id rather than building an invalid payload', () => {
expect(() => buildUqDispatchPayload('just-a-name')).toThrow(/owner\/repo/);
expect(() => buildUqDispatchPayload('/Users/me/repo')).toThrow(/owner\/repo/);
});
});
});
describe('publishCommand (no-token no-op)', () => {
let tempDir: string;
let originalToken: string | undefined;
let exitCodeBefore: number | undefined;
beforeEach(async () => {
vi.resetModules();
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-publish-test-'));
// Simulate an existing owned index with a LadybugDB directory.
await fs.mkdir(path.join(tempDir, '.gitnexus'), { recursive: true });
await fs.mkdir(path.join(tempDir, '.gitnexus', 'lbug'), { recursive: true });
await fs.writeFile(
path.join(tempDir, '.gitnexus', 'meta.json'),
JSON.stringify({ repoPath: tempDir, lastCommit: '', indexedAt: '' }),
'utf-8',
);
originalToken = process.env[UNDERSTAND_QUICKLY_TOKEN_ENV];
delete process.env[UNDERSTAND_QUICKLY_TOKEN_ENV];
exitCodeBefore = process.exitCode;
process.exitCode = 0;
});
afterEach(async () => {
if (originalToken !== undefined) {
process.env[UNDERSTAND_QUICKLY_TOKEN_ENV] = originalToken;
} else {
delete process.env[UNDERSTAND_QUICKLY_TOKEN_ENV];
}
process.exitCode = exitCodeBefore;
await fs.rm(tempDir, { recursive: true, force: true });
});
it('exits 0 without firing a network call when the token is unset', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => {
throw new Error('publishCommand should NOT call fetch when the token is missing');
});
const { publishCommand } = await import('../../src/cli/publish.js');
await publishCommand(tempDir, { id: 'looptech-ai/understand-quickly', skipGit: true });
expect(fetchSpy).not.toHaveBeenCalled();
expect(process.exitCode ?? 0).toBe(0);
fetchSpy.mockRestore();
});
it('exits 0 with no token even when no index/repo exists (BLOCKER 2)', async () => {
// Per the README, CLI --help, and PR body: without a token, the
// command must be a no-op even if the repo lacks `.gitnexus/`.
const noIndexDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-publish-noidx-'));
try {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => {
throw new Error('publishCommand should NOT call fetch when the token is missing');
});
const { publishCommand } = await import('../../src/cli/publish.js');
await publishCommand(noIndexDir, {
id: 'looptech-ai/understand-quickly',
skipGit: true,
});
expect(fetchSpy).not.toHaveBeenCalled();
expect(process.exitCode ?? 0).toBe(0);
fetchSpy.mockRestore();
} finally {
await fs.rm(noIndexDir, { recursive: true, force: true });
}
});
});
describe('publishCommand response branches (MEDIUM 5)', () => {
let tempDir: string;
let originalToken: string | undefined;
let exitCodeBefore: number | undefined;
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
vi.resetModules();
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-publish-resp-'));
await fs.mkdir(path.join(tempDir, '.gitnexus'), { recursive: true });
await fs.mkdir(path.join(tempDir, '.gitnexus', 'lbug'), { recursive: true });
await fs.writeFile(
path.join(tempDir, '.gitnexus', 'meta.json'),
JSON.stringify({ repoPath: tempDir, lastCommit: '', indexedAt: '' }),
'utf-8',
);
originalToken = process.env[UNDERSTAND_QUICKLY_TOKEN_ENV];
process.env[UNDERSTAND_QUICKLY_TOKEN_ENV] = 'pat_test';
exitCodeBefore = process.exitCode;
process.exitCode = 0;
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(async () => {
if (originalToken !== undefined) {
process.env[UNDERSTAND_QUICKLY_TOKEN_ENV] = originalToken;
} else {
delete process.env[UNDERSTAND_QUICKLY_TOKEN_ENV];
}
process.exitCode = exitCodeBefore;
vi.restoreAllMocks();
await fs.rm(tempDir, { recursive: true, force: true });
});
function mockResponse(status: number, body = '') {
fetchSpy.mockResolvedValueOnce({
status,
ok: status >= 200 && status < 300,
text: async () => body,
body: { cancel: async () => {} },
headers: new Headers(),
} as unknown as Response);
}
it('204 → exit 0 with success message', async () => {
mockResponse(204);
const { publishCommand } = await import('../../src/cli/publish.js');
await publishCommand(tempDir, {
id: 'looptech-ai/understand-quickly',
skipGit: true,
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(process.exitCode ?? 0).toBe(0);
});
it('401 → exit 1 with PAT-invalid hint', async () => {
mockResponse(401, '{"message":"Bad credentials"}');
const { publishCommand } = await import('../../src/cli/publish.js');
await publishCommand(tempDir, {
id: 'looptech-ai/understand-quickly',
skipGit: true,
});
expect(process.exitCode).toBe(1);
});
it('403 → exit 1 with scope-missing hint', async () => {
mockResponse(403, '{"message":"Resource not accessible"}');
const { publishCommand } = await import('../../src/cli/publish.js');
await publishCommand(tempDir, {
id: 'looptech-ai/understand-quickly',
skipGit: true,
});
expect(process.exitCode).toBe(1);
});
it('404 → exit 1 with repo-access hint', async () => {
mockResponse(404, '{"message":"Not Found"}');
const { publishCommand } = await import('../../src/cli/publish.js');
await publishCommand(tempDir, {
id: 'looptech-ai/understand-quickly',
skipGit: true,
});
expect(process.exitCode).toBe(1);
});
it('5xx → exit 1 with raw body', async () => {
mockResponse(503, 'gateway timeout');
const { publishCommand } = await import('../../src/cli/publish.js');
await publishCommand(tempDir, {
id: 'looptech-ai/understand-quickly',
skipGit: true,
});
expect(process.exitCode).toBe(1);
});
it('network throw → exit 1', async () => {
fetchSpy.mockRejectedValueOnce(new Error('ECONNRESET'));
const { publishCommand } = await import('../../src/cli/publish.js');
await publishCommand(tempDir, {
id: 'looptech-ai/understand-quickly',
skipGit: true,
});
expect(process.exitCode).toBe(1);
});
it('TimeoutError (HIGH 4 — fetch timeout) → exit 1 with timed-out message', async () => {
// `AbortSignal.timeout()` throws a real `DOMException` with
// `name === 'TimeoutError'`. Faking it as `Error{name:'AbortError'}`
// (the previous shape of this test) hid a mismatch in publish.ts —
// the catch branch only matched 'AbortError' and the user-facing
// "timed out" message never fired in production.
const abort = new DOMException('The operation was aborted due to timeout', 'TimeoutError');
fetchSpy.mockRejectedValueOnce(abort);
const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const { publishCommand } = await import('../../src/cli/publish.js');
await publishCommand(tempDir, {
id: 'looptech-ai/understand-quickly',
skipGit: true,
});
expect(process.exitCode).toBe(1);
const written = errSpy.mock.calls.map((c) => String(c[0])).join('');
expect(written).toMatch(/timed out/i);
errSpy.mockRestore();
});
it('token never appears in any logged output', async () => {
process.env[UNDERSTAND_QUICKLY_TOKEN_ENV] = 'pat_secret_value';
mockResponse(401, '');
const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const { publishCommand } = await import('../../src/cli/publish.js');
await publishCommand(tempDir, {
id: 'looptech-ai/understand-quickly',
skipGit: true,
});
const written = errSpy.mock.calls.map((c) => String(c[0])).join('');
expect(written).not.toContain('pat_secret_value');
errSpy.mockRestore();
});
});