277 lines
8.7 KiB
TypeScript
277 lines
8.7 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
installLinuxHopper,
|
|
linuxHopperLauncherDigestSupported,
|
|
linuxPackageManagerCommands,
|
|
linuxSharedLibrariesAvailable,
|
|
parseLinuxDistribution,
|
|
supportsReaHost,
|
|
type LinuxDistribution,
|
|
type LinuxHopperDownload,
|
|
type LinuxHopperInstallHost,
|
|
type LinuxHopperLauncherStatus,
|
|
type LinuxPackageFamily,
|
|
} from "./LinuxHopper.js";
|
|
|
|
class RecordingLinuxHost implements LinuxHopperInstallHost {
|
|
distributionValue: LinuxDistribution | undefined = {
|
|
id: "ubuntu",
|
|
versionId: "24.04",
|
|
packageFamily: "deb",
|
|
hopperSetupSupported: true,
|
|
};
|
|
archive = new Uint8Array([1, 2, 3]);
|
|
archiveOk = true;
|
|
installSucceeds = true;
|
|
launcherStatusValue: LinuxHopperLauncherStatus = "ready";
|
|
downloads: string[] = [];
|
|
installed: Array<{ family: LinuxPackageFamily; archive: string }> = [];
|
|
cleaned: string[] = [];
|
|
|
|
distribution = (): Promise<LinuxDistribution | undefined> =>
|
|
Promise.resolve(this.distributionValue);
|
|
download = (url: string): Promise<LinuxHopperDownload> => {
|
|
this.downloads.push(url);
|
|
return Promise.resolve({ ok: this.archiveOk, bytes: this.archive });
|
|
};
|
|
createTemporaryDirectory = (): Promise<string> =>
|
|
Promise.resolve("/tmp/rea test");
|
|
writeArchive = (): Promise<void> => Promise.resolve();
|
|
installPackage = (
|
|
family: LinuxPackageFamily,
|
|
archive: string,
|
|
): Promise<boolean> => {
|
|
this.installed.push({ family, archive });
|
|
return Promise.resolve(this.installSucceeds);
|
|
};
|
|
launcherStatus = (): Promise<LinuxHopperLauncherStatus> =>
|
|
Promise.resolve(this.launcherStatusValue);
|
|
cleanup = (path: string): Promise<void> => {
|
|
this.cleaned.push(path);
|
|
return Promise.resolve();
|
|
};
|
|
}
|
|
|
|
describe("Linux Hopper host classification", () => {
|
|
it.each([
|
|
['ID=ubuntu\nVERSION_ID="24.04"\n', "deb"],
|
|
["ID=fedora\nVERSION_ID=41\n", "rpm"],
|
|
['ID=nobara\nVERSION_ID="44"\nID_LIKE="rhel centos fedora"\n', "rpm"],
|
|
["ID=arch\n", "arch"],
|
|
["ID=cachyos\nID_LIKE=arch\n", "arch"],
|
|
['ID=omarchy\nID_LIKE=arch\nVERSION_ID="4.0.4"\n', "arch"],
|
|
] as const)(
|
|
"accepts an explicitly supported distribution",
|
|
(document, family) => {
|
|
expect(parseLinuxDistribution(document)).toMatchObject({
|
|
packageFamily: family,
|
|
hopperSetupSupported: true,
|
|
});
|
|
},
|
|
);
|
|
|
|
it("detects unresolved or loader-reported shared libraries", () => {
|
|
expect(
|
|
linuxSharedLibrariesAvailable(
|
|
"libQt6Network.so.6 => not found\nlibc.so.6 => /lib/libc.so.6",
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
linuxSharedLibrariesAvailable(
|
|
"error while loading shared libraries: libQt6Gui.so.6",
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
linuxSharedLibrariesAvailable(
|
|
"libQt6Network.so.6 => /usr/lib/libQt6Network.so.6",
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
'ID=ubuntu\nVERSION_ID="22.04"\n',
|
|
"ID=fedora\nVERSION_ID=40\n",
|
|
'ID=nobara\nVERSION_ID="43"\nID_LIKE="rhel centos fedora"\n',
|
|
'ID=derivative\nVERSION_ID="44"\nID_LIKE="rhel centos fedora"\n',
|
|
'ID=debian\nVERSION_ID="13"\nID_LIKE=debian\n',
|
|
"ID=manjaro\nID_LIKE=arch\n",
|
|
"ID=garuda\nID_LIKE=arch\n",
|
|
"ID=omarchy\n",
|
|
"ID=omarchy\nID_LIKE=debian\n",
|
|
])(
|
|
"rejects vendor/version combinations without a tested Hopper installer",
|
|
(document) => {
|
|
expect(parseLinuxDistribution(document).hopperSetupSupported).toBe(false);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
'ID=kali\nVERSION_ID="2026.3"\nID_LIKE=debian\n',
|
|
'ID=debian\nVERSION_ID="13"\nID_LIKE=debian\n',
|
|
'ID=linuxmint\nVERSION_ID="22.1"\nID_LIKE="ubuntu debian"\n',
|
|
"ID=manjaro\nID_LIKE=arch\n",
|
|
"ID=derivative\nID_LIKE='debian'\n",
|
|
"ID='debian'\nVERSION_ID='13'\n",
|
|
])(
|
|
"hosts REA on a distribution Hopper has no tested package for: %s",
|
|
(document) => {
|
|
const distribution = parseLinuxDistribution(document);
|
|
expect(distribution.hopperSetupSupported).toBe(false);
|
|
expect(supportsReaHost(distribution)).toBe(true);
|
|
},
|
|
);
|
|
|
|
it("withholds host support from a distribution with no package family", () => {
|
|
expect(supportsReaHost(parseLinuxDistribution("ID=nixos\n"))).toBe(false);
|
|
expect(supportsReaHost(undefined)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("Linux Hopper installation", () => {
|
|
it.each([
|
|
[
|
|
"Debian and Arch",
|
|
"0294ced141cc373468ee22d8343e7dac41980cb05a937994ca81c9f09afe7ded",
|
|
],
|
|
["RPM", "1339f9e58377442b0c6fcb0dfc3cec20d593cc557408521fad9a00dbc6b8da13"],
|
|
])("accepts the pinned %s launcher build", (_, digest) => {
|
|
expect(linuxHopperLauncherDigestSupported(digest)).toBe(true);
|
|
});
|
|
|
|
it("rejects an unverified launcher build", () => {
|
|
expect(linuxHopperLauncherDigestSupported("0".repeat(64))).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
["deb", "apt-get", ["xvfb", "xauth", "python3", "libx11-6", "libxtst6"]],
|
|
[
|
|
"rpm",
|
|
"dnf",
|
|
[
|
|
"xorg-x11-server-Xvfb",
|
|
"xorg-x11-xauth",
|
|
"python3",
|
|
"libX11",
|
|
"libXtst",
|
|
],
|
|
],
|
|
[
|
|
"arch",
|
|
"pacman",
|
|
["xorg-server-xvfb", "xorg-xauth", "python", "libx11", "libxtst"],
|
|
],
|
|
] as const)(
|
|
"selects the %s native package manager and dependencies",
|
|
(family, executable, packages) => {
|
|
expect(
|
|
linuxPackageManagerCommands(family, "/tmp/Hopper package", true),
|
|
).toContainEqual(
|
|
expect.objectContaining({
|
|
executable,
|
|
args: expect.arrayContaining([...packages]),
|
|
}),
|
|
);
|
|
expect(
|
|
linuxPackageManagerCommands(family, "/tmp/Hopper package", false),
|
|
).toContainEqual(
|
|
expect.objectContaining({
|
|
executable: "pkexec",
|
|
args: expect.arrayContaining([executable, "/tmp/Hopper package"]),
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each(["deb", "rpm", "arch"] as const)(
|
|
"downloads, verifies, installs, reads back, and cleans a %s package",
|
|
async (family) => {
|
|
const host = new RecordingLinuxHost();
|
|
host.distributionValue = {
|
|
id: family,
|
|
packageFamily: family,
|
|
hopperSetupSupported: true,
|
|
};
|
|
const result = await installFixture(host);
|
|
expect(result).toEqual({
|
|
status: "installed",
|
|
launcherPath: "/opt/hopper/bin/Hopper",
|
|
});
|
|
expect(host.installed).toEqual([
|
|
{ family, archive: `/tmp/rea test/hopper.${family}` },
|
|
]);
|
|
expect(host.cleaned).toEqual(["/tmp/rea test"]);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["unsupported host", "unsupported_host"],
|
|
["package request failure", "download"],
|
|
["checksum mismatch", "integrity"],
|
|
["package manager failure", "authorization_or_package_manager"],
|
|
["missing launcher", "launcher_missing"],
|
|
["missing runtime", "runtime_dependencies"],
|
|
["unsupported build", "unsupported_hopper_build"],
|
|
] as const)("classifies %s", async (scenario, reason) => {
|
|
const host = new RecordingLinuxHost();
|
|
if (scenario === "unsupported host")
|
|
host.distributionValue = {
|
|
id: "debian",
|
|
packageFamily: "deb",
|
|
hopperSetupSupported: false,
|
|
};
|
|
if (scenario === "package request failure") host.archiveOk = false;
|
|
if (scenario === "checksum mismatch") host.archive = new Uint8Array([9]);
|
|
if (scenario === "package manager failure") host.installSucceeds = false;
|
|
if (scenario === "missing launcher") host.launcherStatusValue = "missing";
|
|
if (scenario === "missing runtime")
|
|
host.launcherStatusValue = "runtime_dependencies";
|
|
if (scenario === "unsupported build")
|
|
host.launcherStatusValue = "unsupported_hopper_build";
|
|
expect(await installFixture(host)).toEqual({
|
|
status: "failed",
|
|
reason,
|
|
});
|
|
if (host.installed.length > 0)
|
|
expect(host.cleaned).toEqual(["/tmp/rea test"]);
|
|
});
|
|
|
|
it("cancels before network or mutation", async () => {
|
|
const host = new RecordingLinuxHost();
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
expect(
|
|
await installLinuxHopper(host, { signal: controller.signal }),
|
|
).toEqual({
|
|
status: "failed",
|
|
reason: "cancelled",
|
|
});
|
|
expect(host.downloads).toEqual([]);
|
|
expect(host.installed).toEqual([]);
|
|
});
|
|
});
|
|
|
|
function releasesFor(
|
|
bytes: Uint8Array,
|
|
): Record<
|
|
LinuxPackageFamily,
|
|
{ filename: string; file_length: string; file_hash: string }
|
|
> {
|
|
const release = (extension: string) => ({
|
|
filename: `https://www.hopperapp.com:443/downloader/public/Hopper.${extension}`,
|
|
file_length: String(bytes.byteLength),
|
|
file_hash: createHash("sha1").update(bytes).digest("hex"),
|
|
});
|
|
return {
|
|
deb: release("deb"),
|
|
rpm: release("rpm"),
|
|
arch: release("pkg.tar.xz"),
|
|
};
|
|
}
|
|
|
|
const installFixture = (host: RecordingLinuxHost) =>
|
|
installLinuxHopper(host, {
|
|
releases: releasesFor(new Uint8Array([1, 2, 3])),
|
|
});
|