Files
rea/src/application/MacHopper.test.ts
T
The one -Niu 650237efb0 fix(setup): pin Hopper's macOS publisher identity before install (#2481)
The macOS installer accepted any bundle that passed `codesign --verify`
and whose `Info.plist` said `com.cryptic-apps.hopper-web-4`. An ad-hoc
signed bundle with that identifier satisfies both, so a substituted DMG
could be copied to ~/Applications and launched.

Verify a pinned code requirement instead, covering the sealed bundle
identifier and the signing team together, and report a distinct
`publisher_verification_failed` code so a vendor key change is not
confused with a corrupt package.
2026-10-13 14:40:04 +08:00

146 lines
4.4 KiB
TypeScript

import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
installMacHopper,
type MacHopperBundleVerification,
type MacHopperInstallHost,
} from "./MacHopper.js";
class FakeMacHopperHost implements MacHopperInstallHost {
readonly archive = new TextEncoder().encode("hopper-dmg");
archiveOk = true;
verification: MacHopperBundleVerification = "verified";
existing = false;
mountSucceeds = true;
copySucceeds = true;
launcherSucceeds = true;
badHash = false;
unmounts = 0;
cleanups = 0;
installs = 0;
opens = 0;
replaces = false;
download = (url: string) =>
Promise.resolve(
url.includes("files-api")
? {
ok: true,
bytes: new TextEncoder().encode(
JSON.stringify({
"OS X": {
filename:
"https://www.hopperapp.com:443/downloader/public/Hopper-demo.dmg",
file_length: String(this.archive.byteLength),
file_hash: this.badHash
? "0".repeat(40)
: createHash("sha1").update(this.archive).digest("hex"),
},
}),
),
}
: { ok: this.archiveOk, bytes: this.archive },
);
createTemporaryDirectory = () => Promise.resolve("/tmp/rea-hopper-test");
createMountDirectory = () => Promise.resolve();
writePackage = () => Promise.resolve();
mount = () => Promise.resolve(this.mountSucceeds);
unmount = () => {
this.unmounts += 1;
return Promise.resolve();
};
appBundle = () => Promise.resolve("/mounted/Hopper Disassembler.app");
validBundle = () => Promise.resolve(this.verification);
destinationExists = () => Promise.resolve(this.existing);
installBundle = (
_source: string,
_destination: string,
_stage: string,
replaceExisting: boolean,
) => {
this.installs += 1;
this.replaces = replaceExisting;
return Promise.resolve(this.copySucceeds);
};
launcherReady = () => Promise.resolve(this.launcherSucceeds);
openApplication = () => {
this.opens += 1;
return Promise.resolve();
};
cleanup = () => {
this.cleanups += 1;
return Promise.resolve();
};
destination = () => "/home/user/Applications/Hopper Disassembler.app";
}
describe("macOS Hopper installation", () => {
it("installs, verifies, opens, and cleans the official package", async () => {
const host = new FakeMacHopperHost();
await expect(installMacHopper({}, host)).resolves.toEqual({
status: "installed",
launcherPath:
"/home/user/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
});
expect(host.opens).toBe(1);
expect(host.unmounts).toBe(1);
expect(host.cleanups).toBe(1);
});
it("rejects an integrity mismatch before mounting", async () => {
const host = new FakeMacHopperHost();
host.badHash = true;
await expect(installMacHopper({}, host)).resolves.toEqual({
status: "failed",
reason: "integrity",
});
expect(host.unmounts).toBe(0);
expect(host.cleanups).toBe(0);
});
it("refuses to overwrite an existing application", async () => {
const host = new FakeMacHopperHost();
host.existing = true;
await expect(installMacHopper({}, host)).resolves.toEqual({
status: "failed",
reason: "destination_exists",
});
});
it("replaces the managed application when explicitly requested", async () => {
const host = new FakeMacHopperHost();
host.existing = true;
await expect(
installMacHopper({ replaceExisting: true }, host),
).resolves.toMatchObject({ status: "installed" });
expect(host.replaces).toBe(true);
});
it("cleans and detaches after bundle validation fails", async () => {
const host = new FakeMacHopperHost();
host.verification = "invalid_signature";
await expect(installMacHopper({}, host)).resolves.toEqual({
status: "failed",
reason: "bundle",
});
expect(host.unmounts).toBe(1);
expect(host.cleanups).toBe(1);
});
it("reports a publisher mismatch without copying or launching the bundle", async () => {
const host = new FakeMacHopperHost();
host.verification = "unverified_publisher";
await expect(installMacHopper({}, host)).resolves.toEqual({
status: "failed",
reason: "publisher_verification_failed",
});
expect(host.installs).toBe(0);
expect(host.opens).toBe(0);
expect(host.unmounts).toBe(1);
expect(host.cleanups).toBe(1);
});
});