fix(ext/sw): model isomorphic-git PushResult correctly; test the real push-rejection throw
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
This commit is contained in:
@@ -147,14 +147,32 @@ describe('commitSignedPush engine', () => {
|
|||||||
expect(args.onAuth()).toEqual(AUTH);
|
expect(args.onAuth()).toEqual(AUTH);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('5 — push rejection throws with server detail', async () => {
|
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
vi.mocked(git.push).mockResolvedValueOnce({ ok: false, errors: ['pre-receive hook declined'] } as any);
|
vi.mocked(git.push).mockRejectedValueOnce(hookError);
|
||||||
const files: CommitFile[] = [{ path: 'vault/item.enc', content: new Uint8Array([1]) }];
|
const files: CommitFile[] = [{ path: 'vault/item.enc', content: new Uint8Array([1]) }];
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
commitSignedPush({ gitUrl: GIT_URL, branch: BRANCH, auth: AUTH, files, message: 'test', signer: makeSigner() }),
|
commitSignedPush({ gitUrl: GIT_URL, branch: BRANCH, auth: AUTH, files, message: 'test', signer: makeSigner() }),
|
||||||
).rejects.toThrow('pre-receive hook declined');
|
).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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,9 @@ import { createMemFs } from './mem-fs';
|
|||||||
import { buildSshSig } from './sshsig';
|
import { buildSshSig } from './sshsig';
|
||||||
import type { CommitFile, CommitSigner } from './git-host';
|
import type { CommitFile, CommitSigner } from './git-host';
|
||||||
|
|
||||||
// Runtime push results from isomorphic-git include an `errors` array that is
|
// isomorphic-git PushResult: { ok: boolean; error: string | null; refs: {[ref]: {ok, error}} }
|
||||||
// not reflected in the TypeScript PushResult type. Intersect it here so the
|
// There is NO top-level `errors: string[]`; the type below reflects reality.
|
||||||
// rejection check compiles under strict mode.
|
type PushResultRuntime = { ok: boolean; error?: string | null; refs?: Record<string, { ok: boolean; error?: string | null }> };
|
||||||
type PushResultRuntime = { ok: boolean; errors?: string[] };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clone `gitUrl` at depth 1, write `files`, sign the commit with SSHSIG, and
|
* Clone `gitUrl` at depth 1, write `files`, sign the commit with SSHSIG, and
|
||||||
@@ -93,8 +92,7 @@ export async function commitSignedPush(opts: {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Push over git-receive-pack. The runtime result includes `errors[]` when a
|
// Push over git-receive-pack.
|
||||||
// pre-receive hook declines — cast to PushResultRuntime to access it.
|
|
||||||
const res = await git.push({
|
const res = await git.push({
|
||||||
fs, http, dir,
|
fs, http, dir,
|
||||||
url: opts.gitUrl,
|
url: opts.gitUrl,
|
||||||
@@ -103,11 +101,13 @@ export async function commitSignedPush(opts: {
|
|||||||
onAuth,
|
onAuth,
|
||||||
}) as unknown as PushResultRuntime;
|
}) as unknown as PushResultRuntime;
|
||||||
|
|
||||||
const ok = res?.ok && (!res.errors || res.errors.length === 0);
|
// isomorphic-git THROWS GitPushError when the server/pre-receive hook declines
|
||||||
if (!ok) {
|
// a ref (the normal org-write rejection path); that error propagates to the
|
||||||
throw new Error(
|
// caller unchanged. This guard is defensive belt-and-suspenders for any
|
||||||
`org push rejected by server/hook: ${JSON.stringify(res?.errors ?? res)}`,
|
// non-throwing failure: push() only returns when ok && every ref ok.
|
||||||
);
|
const refsOk = !res?.refs || Object.values(res.refs).every((r) => r.ok);
|
||||||
|
if (!res || res.ok !== true || !refsOk) {
|
||||||
|
throw new Error(`org push rejected by server/hook: ${res?.error ?? JSON.stringify(res)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user