PushResult has a singular `error: string | null` and a `refs` map — there
is no top-level `errors: string[]`. Replace the fabricated type and the
`errors`-based rejection guard with a correct PushResultRuntime type and a
belt-and-suspenders refsOk check. The real rejection path is a thrown
GitPushError (isomorphic-git throws, never returns, when a hook declines);
replace the single fiction-testing test with (a) a throw-path test that mocks
push to reject with a GitPushError and (b) a defensive non-throwing test that
mocks push to resolve { ok: false, error: 'unpack failed' }.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKBbrAjmpVXMEK15pNi3Ha
211 lines
9.5 KiB
TypeScript
211 lines
9.5 KiB
TypeScript
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 <o@acme> 1750000000 +0000\ncommitter Owner <o@acme> 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://<host>/<repo>.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/<repo>.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' });
|
|
});
|
|
});
|