import { beforeEach, describe, expect, test, vi } from 'vitest'; import { createPrivateKey, sign as nodeSign } from 'node:crypto'; import * as git from 'isomorphic-git'; import { commitSignedPush } from '../git-push'; import { GiteaHost } from '../gitea'; import { GitHubHost } from '../github'; import type { CommitFile, CommitSigner } from '../git-host'; // --------------------------------------------------------------------------- // Mock isomorphic-git so no network or filesystem I/O occurs. // The vi.mock calls are hoisted by Vitest and run before any imports. // --------------------------------------------------------------------------- vi.mock('isomorphic-git', () => ({ clone: vi.fn(async () => {}), add: vi.fn(async () => {}), commit: vi.fn(async () => 'oid'), push: vi.fn(async () => ({ ok: true, errors: [] })), })); vi.mock('isomorphic-git/http/web', () => ({ default: {} })); // --------------------------------------------------------------------------- // Golden-vector constants — byte-exact, identical to sshsig.test.ts. // Tied to the SSHSIG spec proven against `git verify-commit`. // --------------------------------------------------------------------------- const SEED_HEX = '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'; const PUB_OPENSSH = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAOhB7/zzhC+HXDdGOdLwJln5NYwm6UNXx3chmQSVTG4'; const PAYLOAD = 'tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor Owner 1750000000 +0000\ncommitter Owner 1750000000 +0000\n\norg add\n'; const GOLDEN_ARMOR = '-----BEGIN SSH SIGNATURE-----\n' + 'U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgA6EHv/POEL4dcN0Y50vAmWfk1j\n' + 'CbpQ1fHdyGZBJVMbgAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\n' + 'AAAAQFLDkEyykOw6Z+I9qLKtTDToj2pXIfgvn+PoNDa5PfE6DQ/BmetpymL2aVdQXSpQ4k\n' + 'i4fjWKgx63Q43NsyR/cAs=\n' + '-----END SSH SIGNATURE-----'; // --------------------------------------------------------------------------- // Fixed-seed deterministic ed25519 signer (Node.js only; test-only). // The injected rawSign approach mirrors the production SW wiring, where // rawSign wraps wasm.sign_for_git; here we use node:crypto for the seed. // --------------------------------------------------------------------------- function fixedSigner(): (data: Uint8Array) => Uint8Array { const pkcs8 = Buffer.concat([ Buffer.from('302e020100300506032b657004220420', 'hex'), Buffer.from(SEED_HEX, 'hex'), ]); const key = createPrivateKey({ key: pkcs8, format: 'der', type: 'pkcs8' }); return (data: Uint8Array) => new Uint8Array(nodeSign(null, Buffer.from(data), key)); } // --------------------------------------------------------------------------- // Shared test fixtures // --------------------------------------------------------------------------- const GIT_URL = 'https://git.example.com/org/vault.git'; const BRANCH = 'main'; const AUTH = { username: 'relicario', password: 'test-token' }; function makeSigner(): CommitSigner { return { author: { name: 'Test User', email: 'test@example.com' }, signingPubOpenssh: PUB_OPENSSH, rawSign: fixedSigner(), }; } // --------------------------------------------------------------------------- // Engine tests // --------------------------------------------------------------------------- describe('commitSignedPush engine', () => { beforeEach(() => { vi.clearAllMocks(); }); test('1 — clone called once with correct url / ref / singleBranch / depth / onAuth', async () => { const files: CommitFile[] = [{ path: 'vault/item.enc', content: new Uint8Array([1, 2, 3]) }]; await commitSignedPush({ gitUrl: GIT_URL, branch: BRANCH, auth: AUTH, files, message: 'test', signer: makeSigner() }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const args = (vi.mocked(git.clone).mock.calls[0][0] as any); expect(vi.mocked(git.clone)).toHaveBeenCalledTimes(1); expect(args.url).toBe(GIT_URL); expect(args.ref).toBe(BRANCH); expect(args.singleBranch).toBe(true); expect(args.depth).toBe(1); expect(typeof args.onAuth).toBe('function'); expect(args.onAuth()).toEqual(AUTH); }); test('2 — add called once per file with the right filepath', async () => { const files: CommitFile[] = [ { path: 'a/b.enc', content: new Uint8Array([1]) }, { path: 'c.enc', content: new Uint8Array([2]) }, ]; await commitSignedPush({ gitUrl: GIT_URL, branch: BRANCH, auth: AUTH, files, message: 'test', signer: makeSigner() }); expect(vi.mocked(git.add)).toHaveBeenCalledTimes(2); // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((vi.mocked(git.add).mock.calls[0][0] as any).filepath).toBe('a/b.enc'); // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((vi.mocked(git.add).mock.calls[1][0] as any).filepath).toBe('c.enc'); }); test('3 — golden-vector tie: onSign wires buildSshSig correctly', async () => { // Use the fixed vector signer (same seed/key as sshsig.test.ts). const vectorSigner: CommitSigner = { author: { name: 'Owner', email: 'o@acme' }, signingPubOpenssh: PUB_OPENSSH, rawSign: fixedSigner(), }; await commitSignedPush({ gitUrl: GIT_URL, branch: BRANCH, auth: AUTH, files: [], message: 'org add', signer: vectorSigner, }); // Capture the onSign that was passed to git.commit and invoke it with the // fixed PAYLOAD. The returned signature MUST equal GOLDEN_ARMOR byte-for-byte. // eslint-disable-next-line @typescript-eslint/no-explicit-any const commitArgs = (vi.mocked(git.commit).mock.calls[0][0] as any); const { signature } = await commitArgs.onSign({ payload: PAYLOAD, secretKey: 'device' }); expect(signature).toBe(GOLDEN_ARMOR); }); test('4 — push called with correct url / ref / remoteRef / onAuth', async () => { const files: CommitFile[] = [{ path: 'vault/item.enc', content: new Uint8Array([1]) }]; await commitSignedPush({ gitUrl: GIT_URL, branch: BRANCH, auth: AUTH, files, message: 'test', signer: makeSigner() }); expect(vi.mocked(git.push)).toHaveBeenCalledTimes(1); // eslint-disable-next-line @typescript-eslint/no-explicit-any const args = (vi.mocked(git.push).mock.calls[0][0] as any); expect(args.url).toBe(GIT_URL); expect(args.ref).toBe(BRANCH); expect(args.remoteRef).toBe(BRANCH); expect(typeof args.onAuth).toBe('function'); expect(args.onAuth()).toEqual(AUTH); }); test('5a — push rejection throw path (real): GitPushError thrown by isomorphic-git propagates', async () => { // isomorphic-git THROWS GitPushError (never returns) when a pre-receive hook declines. // This is the real rejection path; commitSignedPush must not swallow it. const hookError = Object.assign( new Error('One or more branches were not updated: pre-receive hook declined'), { code: 'GitPushError' }, ); // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.mocked(git.push).mockRejectedValueOnce(hookError); const files: CommitFile[] = [{ path: 'vault/item.enc', content: new Uint8Array([1]) }]; await expect( commitSignedPush({ gitUrl: GIT_URL, branch: BRANCH, auth: AUTH, files, message: 'test', signer: makeSigner() }), ).rejects.toThrow('not updated'); }); test('5b — push rejection defensive path: non-throwing ok:false triggers guard', async () => { // Belt-and-suspenders: if push() somehow resolves with ok:false (non-standard), // the guard must still throw with the error detail from the result. // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.mocked(git.push).mockResolvedValueOnce({ ok: false, error: 'unpack failed', refs: {} } as any); const files: CommitFile[] = [{ path: 'vault/item.enc', content: new Uint8Array([1]) }]; await expect( commitSignedPush({ gitUrl: GIT_URL, branch: BRANCH, auth: AUTH, files, message: 'test', signer: makeSigner() }), ).rejects.toThrow('unpack failed'); }); }); // --------------------------------------------------------------------------- // Host wrapper tests — verifies commitSigned delegates correctly to the engine. // The mocked git.clone captures the gitUrl and auth shape for each host. // --------------------------------------------------------------------------- describe('GitHost wrappers — commitSigned wires commitSignedPush correctly', () => { beforeEach(() => { vi.clearAllMocks(); }); test('GiteaHost: builds https:///.git gitUrl; auth.password === apiToken', async () => { // LIVE RESIDUAL: exact Basic-auth acceptance confirmed only by a live push against Gitea. const signer = makeSigner(); await new GiteaHost('https://git.example', 'o/r', 'tok').commitSigned([], 'm', signer); // eslint-disable-next-line @typescript-eslint/no-explicit-any const args = (vi.mocked(git.clone).mock.calls[0][0] as any); expect(args.url).toBe('https://git.example/o/r.git'); expect(args.onAuth()).toEqual({ username: 'relicario', password: 'tok' }); }); test('GitHubHost: builds https://github.com/.git; auth.username === apiToken', async () => { // LIVE RESIDUAL: GitHub PAT-over-Basic (username=token, password=x-oauth-basic) unconfirmed live. const signer = makeSigner(); await new GitHubHost('o/r', 'tok').commitSigned([], 'm', signer); // eslint-disable-next-line @typescript-eslint/no-explicit-any const args = (vi.mocked(git.clone).mock.calls[0][0] as any); expect(args.url).toBe('https://github.com/o/r.git'); expect(args.onAuth()).toEqual({ username: 'tok', password: 'x-oauth-basic' }); }); });