Files
pleno-vue/tests/unit/mobile-ios-device.spec.js
T
Jeppe B 8ddac065c6 Guard iOS debug signing with live CORS (#181)
Verify the stable API and exact Capacitor iOS CORS contract before signing device-debug IPAs, with regression coverage and troubleshooting guidance.
2026-07-20 14:37:17 +02:00

496 lines
21 KiB
JavaScript

import { createHash } from "node:crypto";
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
DEBUG_API_URL,
DEBUG_BUNDLE_ID,
DEBUG_DISPLAY_NAME,
DEBUG_EXECUTABLE_NAME,
assertMinimumIosCompatible,
createRedactedLineWriter,
inspectIpa,
main,
parseArgs,
redactUdids,
requiresDeveloperMode,
selectIpaPayload,
selectUsbDevice,
spawnCommand,
validateDevelopmentArtifact,
validateManifest,
verifySha256File,
} from "../../scripts/mobile/ios-device.mjs";
const TEST_UDID = "00008110-0012345678901234";
const temporaryDirectories = [];
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
function validArtifact(overrides = {}) {
const info = {
CFBundleIdentifier: DEBUG_BUNDLE_ID,
CFBundleDisplayName: DEBUG_DISPLAY_NAME,
CFBundleExecutable: DEBUG_EXECUTABLE_NAME,
CFBundleShortVersionString: "0.0.42",
CFBundleVersion: "4201",
MinimumOSVersion: "15.0",
...overrides.info,
};
const profile = {
ExpirationDate: "2027-01-01T00:00:00Z",
ProvisionedDevices: [TEST_UDID],
TeamIdentifier: ["TEAM123456"],
Entitlements: {
"application-identifier": `TEAM123456.${DEBUG_BUNDLE_ID}`,
"com.apple.developer.team-identifier": "TEAM123456",
"get-task-allow": true,
...overrides.entitlements,
},
...overrides.profile,
};
return { info, profile };
}
function installedApp(overrides = {}) {
return {
CFBundleIdentifier: DEBUG_BUNDLE_ID,
CFBundleExecutable: DEBUG_EXECUTABLE_NAME,
CFBundleShortVersionString: "0.0.42",
CFBundleVersion: "4201",
...overrides,
};
}
function commandResult(stdout = "", stderr = "", code = 0) {
return { code, stdout: Buffer.from(stdout), stderr: Buffer.from(stderr) };
}
function createHarness({
developerMode = "enabled",
installedSequence = [],
installationProxyError,
pairingError,
productVersion = "27.0",
minimumIos = "15.0",
} = {}) {
const calls = [];
const output = [];
const errors = [];
const installed = [...installedSequence];
const run = async (command, args, options = {}) => {
calls.push({ command, args, options });
if (command === "idevice_id") return commandResult(`${TEST_UDID}\n`);
if (command === "idevicepair") {
return pairingError ? commandResult("", pairingError, 1) : commandResult("SUCCESS");
}
if (command === "idevicedevmodectl") {
return commandResult(`Device DeveloperMode\n${TEST_UDID} ${developerMode}\n`);
}
if (command === "ideviceinfo") {
const key = args.at(-1);
const values = {
ActivationState: "Activated",
ProductType: "iPhone15,4",
ProductVersion: productVersion,
};
return commandResult(values[key] ?? "");
}
if (command === "ideviceinstaller") {
if (installationProxyError && args.includes("list")) {
return commandResult("", installationProxyError, 1);
}
return commandResult();
}
if (command === "idevicesyslog" || command === "idevicecrashreport") {
return commandResult();
}
throw new Error(`Unexpected command: ${command}`);
};
return {
calls,
output,
errors,
overrides: {
run,
commandExists: async () => true,
inspectIpa: async (_context, ipaPath) => ({
ipaPath,
validated: {
executable: DEBUG_EXECUTABLE_NAME,
version: "0.0.42",
build: "4201",
minimumIos,
},
}),
getInstalledDebugApp: async () => installed.shift() ?? null,
now: () => new Date("2026-07-20T00:00:00Z"),
out: (message) => output.push(message),
err: (message) => errors.push(message),
},
};
}
describe("argument and device selection safety", () => {
it("accepts the documented command shapes", () => {
expect(parseArgs(["doctor", "--udid", TEST_UDID])).toMatchObject({
command: "doctor",
udid: TEST_UDID,
});
expect(parseArgs(["install", "build.ipa", "--manifest", "manifest.json"])).toMatchObject({
command: "install",
manifest: "manifest.json",
positionals: ["build.ipa"],
});
});
it("requires the exact debug identifier for uninstall", () => {
expect(() => parseArgs(["uninstall", "--confirm", "io.truckwash.app"])).toThrow("Refusing to uninstall");
expect(parseArgs(["uninstall", "--confirm", DEBUG_BUNDLE_ID]).confirm).toBe(DEBUG_BUNDLE_ID);
});
it("requires exactly one USB device by default", () => {
expect(() => selectUsbDevice("", undefined)).toThrow("No cable-connected iPhone");
expect(() => selectUsbDevice("device-a\ndevice-b\n", undefined)).toThrow("Select one explicitly");
expect(selectUsbDevice("device-a\ndevice-b\n", "device-b").udid).toBe("device-b");
});
it("redacts both modern and legacy UDIDs", () => {
const legacy = "a".repeat(40);
expect(redactUdids(`device ${TEST_UDID} and ${legacy}`)).toBe("device <redacted-udid> and <redacted-udid>");
});
it("redacts a UDID split across streamed child-process chunks", () => {
let output = "";
const writer = createRedactedLineWriter({ write: (value) => (output += value) }, [TEST_UDID]);
writer.write(Buffer.from(`connected: ${TEST_UDID.slice(0, 10)}`));
writer.write(Buffer.from(`${TEST_UDID.slice(10)}\nready\n`));
writer.end();
expect(output).toBe("connected: <redacted-udid>\nready\n");
});
it("writes real child-process stdout to a file only after UDID redaction", async () => {
const directory = await mkdtemp(join(tmpdir(), "mobile-ios-redacted-log-test-"));
temporaryDirectories.push(directory);
const outputPath = join(directory, "device.log");
const script = `process.stdout.write(${JSON.stringify(`connected:${TEST_UDID}\nmessage\n`)})`;
await expect(
spawnCommand(process.execPath, ["-e", script], {
streamRedactedUdids: [TEST_UDID],
redactedStdoutFile: outputPath,
})
).resolves.toMatchObject({ code: 0 });
expect(await readFile(outputPath, "utf8")).toBe("connected:<redacted-udid>\nmessage\n");
expect((await stat(outputPath)).mode & 0o777).toBe(0o600);
});
it("rejects an invalid redacted output path before starting the child", async () => {
const directory = await mkdtemp(join(tmpdir(), "mobile-ios-invalid-log-test-"));
temporaryDirectories.push(directory);
const outputPath = join(directory, "missing", "device.log");
await expect(
spawnCommand(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
streamRedactedUdids: [TEST_UDID],
redactedStdoutFile: outputPath,
})
).rejects.toMatchObject({ code: "ENOENT" });
});
});
describe("IPA validation", () => {
it.each([
["equal versions", "17.0", "17"],
["newer connected iOS", "15.2.1", "17.0"],
["prerelease suffixes", "17.0-beta.1", "17.0rc2"],
])("accepts compatible minimum iOS for %s", (_name, minimumIos, deviceIos) => {
expect(() => assertMinimumIosCompatible(minimumIos, deviceIos)).not.toThrow();
});
it("rejects an IPA requiring newer iOS", () => {
expect(() => assertMinimumIosCompatible("18.0", "17.6.1")).toThrow("IPA requires iOS 18.0");
});
it.each([
["malformed minimum", "17..0", "17.0", "IPA minimum iOS version is malformed"],
["malformed device version", "17.0", "version-17", "Connected iPhone iOS version is malformed"],
["missing minimum", "", "17.0", "IPA minimum iOS version is malformed"],
])("rejects %s", (_name, minimumIos, deviceIos, message) => {
expect(() => assertMinimumIosCompatible(minimumIos, deviceIos)).toThrow(message);
});
it("accepts a valid development artifact", () => {
expect(validateDevelopmentArtifact(validArtifact(), TEST_UDID, new Date("2026-07-20T00:00:00Z"))).toMatchObject({
bundleId: DEBUG_BUNDLE_ID,
executable: DEBUG_EXECUTABLE_NAME,
version: "0.0.42",
build: "4201",
});
});
it.each([
["production bundle", { info: { CFBundleIdentifier: "io.truckwash.app" } }, "expected io.truckwash.app.debug"],
["production executable", { info: { CFBundleExecutable: "App" } }, `expected ${DEBUG_EXECUTABLE_NAME}`],
["App Store profile", { entitlements: { "get-task-allow": false } }, "get-task-allow"],
[
"ad-hoc profile",
{ entitlements: { "get-task-allow": false }, profile: { ProvisionedDevices: [TEST_UDID] } },
"get-task-allow",
],
["missing device", { profile: { ProvisionedDevices: ["another-device"] } }, "not included"],
["expired profile", { profile: { ExpirationDate: "2026-01-01T00:00:00Z" } }, "expired"],
[
"wrong application identifier",
{ entitlements: { "application-identifier": "TEAM123456.io.other.app" } },
"application identifier",
],
["wrong team identifier", { profile: { TeamIdentifier: ["OTHERTEAM"] } }, "entitlement team identifier"],
])("rejects a %s", (_name, overrides, message) => {
expect(() =>
validateDevelopmentArtifact(validArtifact(overrides), TEST_UDID, new Date("2026-07-20T00:00:00Z"))
).toThrow(message);
});
it("requires exactly one top-level app payload and its profile", () => {
expect(selectIpaPayload(["Payload/App.app/Info.plist", "Payload/App.app/embedded.mobileprovision"])).toMatchObject({
appRoot: "Payload/App.app",
});
expect(() =>
selectIpaPayload([
"Payload/App.app/Info.plist",
"Payload/Other.app/Info.plist",
"Payload/App.app/embedded.mobileprovision",
])
).toThrow("exactly one app payload");
expect(() => selectIpaPayload(["Payload/App.app/Info.plist"])).toThrow("embedded provisioning profile");
});
it("compares manifest identity and version fields with the IPA", () => {
const artifact = validArtifact();
expect(() =>
validateManifest(
{
schema_version: 1,
signing_method: "development",
repository: "copenhagentruckwash/pleno-vue",
source_ref: "agent/ios-device-debug",
source_sha: "a".repeat(40),
api_url: DEBUG_API_URL,
release_manager_control_api_url: DEBUG_API_URL,
bundle_id: DEBUG_BUNDLE_ID,
display_name: DEBUG_DISPLAY_NAME,
executable_name: DEBUG_EXECUTABLE_NAME,
version: "0.0.42",
build: "4201",
minimum_ios: "15.0",
profile_expiration_utc: "2027-01-01T00:00:00Z",
ipa_filename: "debug.ipa",
},
artifact,
"/tmp/debug.ipa"
)
).not.toThrow();
expect(() =>
validateManifest({ schema_version: 1, signing_method: "development" }, artifact, "/tmp/debug.ipa")
).toThrow("repository");
expect(() =>
validateManifest({ schema_version: 1, signing_method: "app-store" }, artifact, "/tmp/debug.ipa")
).toThrow("signing method");
});
it("requires Developer Mode only on iOS 16 and newer", () => {
expect(requiresDeveloperMode("15.8.4")).toBe(false);
expect(requiresDeveloperMode("16.0")).toBe(true);
expect(requiresDeveloperMode("27.0 beta")).toBe(true);
});
it("verifies SHA256SUMS and rejects a mismatch", async () => {
const directory = await mkdtemp(join(tmpdir(), "mobile-ios-test-"));
temporaryDirectories.push(directory);
const ipaPath = join(directory, "debug.ipa");
const checksumPath = join(directory, "SHA256SUMS");
const content = Buffer.from("fake IPA fixture");
await writeFile(ipaPath, content);
const digest = createHash("sha256").update(content).digest("hex");
await writeFile(checksumPath, `${digest} debug.ipa\n`);
await expect(verifySha256File(ipaPath, checksumPath)).resolves.toBeUndefined();
await writeFile(checksumPath, `${"0".repeat(64)} debug.ipa\n`);
await expect(verifySha256File(ipaPath, checksumPath)).rejects.toThrow("Checksum verification failed");
expect(await readFile(ipaPath, "utf8")).toBe("fake IPA fixture");
});
it("requires the workflow manifest and checksums before inspecting an IPA", async () => {
const directory = await mkdtemp(join(tmpdir(), "mobile-ios-artifact-test-"));
temporaryDirectories.push(directory);
const ipaPath = join(directory, "debug.ipa");
await writeFile(ipaPath, "fixture");
const context = { commandExists: async () => true };
await expect(inspectIpa(context, ipaPath, { connectedUdid: TEST_UDID })).rejects.toThrow("Manifest file not found");
await writeFile(join(directory, "manifest.json"), "{}");
await expect(inspectIpa(context, ipaPath, { connectedUdid: TEST_UDID })).rejects.toThrow("Checksum file not found");
});
});
describe("device commands", () => {
it("doctor checks pairing, activation, installation access, and Developer Mode", async () => {
const harness = createHarness();
await expect(main(["doctor"], harness.overrides)).resolves.toBe(0);
expect(harness.calls.map(({ command }) => command)).toEqual(
expect.arrayContaining(["idevice_id", "idevicepair", "ideviceinfo", "ideviceinstaller", "idevicedevmodectl"])
);
expect(harness.output.join("\n")).not.toContain(TEST_UDID);
expect(harness.output.join("\n")).toContain("Developer Mode: enabled");
});
it("doctor gives targeted guidance when Developer Mode is disabled", async () => {
const harness = createHarness({ developerMode: "disabled" });
await expect(main(["doctor"], harness.overrides)).resolves.toBe(1);
expect(harness.errors.join("\n")).toContain("Developer Mode is disabled");
expect(harness.errors.join("\n")).not.toContain(TEST_UDID);
});
it("doctor accepts an iOS 15 device without invoking Developer Mode tooling", async () => {
const harness = createHarness({ developerMode: "disabled", productVersion: "15.8.4" });
await expect(main(["doctor"], harness.overrides)).resolves.toBe(0);
expect(harness.calls.some(({ command }) => command === "idevicedevmodectl")).toBe(false);
expect(harness.output.join("\n")).toContain("not required before iOS 16");
});
it("doctor gives targeted guidance when the phone is locked", async () => {
const harness = createHarness({
installationProxyError: "Could not connect: device is password protected and locked",
});
await expect(main(["doctor"], harness.overrides)).resolves.toBe(1);
expect(harness.errors.join("\n")).toContain("iPhone is locked");
expect(harness.errors.join("\n")).not.toContain(TEST_UDID);
});
it("doctor gives targeted guidance when trust is invalid", async () => {
const harness = createHarness({ pairingError: "ERROR: Invalid HostID / pairing record" });
await expect(main(["doctor"], harness.overrides)).resolves.toBe(1);
expect(harness.errors.join("\n")).toContain("Pairing is not valid");
expect(harness.errors.join("\n")).not.toContain(TEST_UDID);
});
it.each([
["install", null],
["upgrade", installedApp()],
])("chooses %s and verifies the installed version", async (action, initialApp) => {
const harness = createHarness({
installedSequence: [initialApp, installedApp()],
});
await expect(main(["install", "/tmp/debug.ipa"], harness.overrides)).resolves.toBe(0);
expect(
harness.calls.some(
({ command, args }) =>
command === "ideviceinstaller" && args.includes(action) && args.includes("/tmp/debug.ipa")
)
).toBe(true);
expect(harness.output.at(-1)).toContain("0.0.42 (4201)");
});
it("rejects an incompatible device before any install or upgrade mutation", async () => {
const harness = createHarness({ productVersion: "14.8.1", minimumIos: "15.0" });
await expect(main(["install", "/tmp/debug.ipa"], harness.overrides)).resolves.toBe(1);
expect(harness.errors.join("\n")).toContain("IPA requires iOS 15.0");
const mutatingCalls = harness.calls.filter(
({ command, args }) =>
command === "ideviceinstaller" &&
(args.includes("install") || args.includes("upgrade") || args.includes("uninstall"))
);
expect(mutatingCalls).toHaveLength(0);
});
it("rejects a post-install version mismatch", async () => {
const harness = createHarness({
installedSequence: [null, installedApp({ CFBundleVersion: "wrong" })],
});
await expect(main(["install", "/tmp/debug.ipa"], harness.overrides)).resolves.toBe(1);
expect(harness.errors.join("\n")).toContain("Installed version verification failed");
});
it("filters logs by the exact installed executable", async () => {
const harness = createHarness({ installedSequence: [installedApp()] });
await expect(main(["logs", "--output", "device.log"], harness.overrides)).resolves.toBe(0);
const call = harness.calls.find(({ command }) => command === "idevicesyslog");
expect(call.args).toEqual(expect.arrayContaining(["-p", DEBUG_EXECUTABLE_NAME]));
expect(call.args).not.toContain("--output");
expect(call.options).toMatchObject({
streamRedactedUdids: [TEST_UDID],
redactedStdoutFile: expect.stringMatching(/device\.log$/u),
});
});
it("copies and extracts only debug-app crashes while keeping them on-device", async () => {
const directory = await mkdtemp(join(tmpdir(), "mobile-ios-crashes-test-"));
temporaryDirectories.push(directory);
const harness = createHarness({ installedSequence: [installedApp({ CFBundleExecutable: DEBUG_EXECUTABLE_NAME })] });
await expect(main(["crashes", directory], harness.overrides)).resolves.toBe(0);
const call = harness.calls.find(({ command }) => command === "idevicecrashreport");
expect(call.args).toEqual(
expect.arrayContaining(["--keep", "--extract", "--filter", DEBUG_EXECUTABLE_NAME, directory])
);
expect(call.args).not.toContain("--remove-all");
});
it("refuses log collection from an older debug build with the production executable name", async () => {
const harness = createHarness({ installedSequence: [installedApp({ CFBundleExecutable: "App" })] });
await expect(main(["logs"], harness.overrides)).resolves.toBe(1);
expect(harness.errors.join("\n")).toContain(`expected ${DEBUG_EXECUTABLE_NAME}`);
expect(harness.calls.some(({ command }) => command === "idevicesyslog")).toBe(false);
});
it("uninstalls only the debug bundle after exact confirmation", async () => {
const harness = createHarness({ installedSequence: [installedApp(), null] });
await expect(main(["uninstall", "--confirm", DEBUG_BUNDLE_ID], harness.overrides)).resolves.toBe(0);
const call = harness.calls.find(
({ command, args }) => command === "ideviceinstaller" && args.includes("uninstall")
);
expect(call.args.at(-1)).toBe(DEBUG_BUNDLE_ID);
expect(call.args).not.toContain("io.truckwash.app");
});
it("does not touch the device when uninstall confirmation is wrong", async () => {
const harness = createHarness();
await expect(main(["uninstall", "--confirm", "io.truckwash.app"], harness.overrides)).resolves.toBe(1);
expect(harness.calls).toHaveLength(0);
});
});
describe("repository device-debug configuration", () => {
it("keeps Debug and Release as distinct installable apps", async () => {
const project = await readFile("ios/App/App.xcodeproj/project.pbxproj", "utf8");
const debugTarget = project.match(/504EC3171FED79650016851F \/\* Debug \*\/ = \{[\s\S]*?\n\t\t\};/u)?.[0];
const releaseTarget = project.match(/504EC3181FED79650016851F \/\* Release \*\/ = \{[\s\S]*?\n\t\t\};/u)?.[0];
const infoPlist = await readFile("ios/App/App/Info.plist", "utf8");
expect(debugTarget).toContain('APP_DISPLAY_NAME = "Truck Wash Debug";');
expect(debugTarget).toContain(`PRODUCT_BUNDLE_IDENTIFIER = ${DEBUG_BUNDLE_ID};`);
expect(debugTarget).toContain(`PRODUCT_NAME = ${DEBUG_EXECUTABLE_NAME};`);
expect(releaseTarget).toContain('APP_DISPLAY_NAME = "Truck Wash";');
expect(releaseTarget).toContain("PRODUCT_BUNDLE_IDENTIFIER = io.truckwash.app;");
expect(infoPlist).toContain("<string>$(APP_DISPLAY_NAME)</string>");
});
it("keeps signing secrets behind the master-only environment workflow", async () => {
const workflow = await readFile(".github/workflows/ios-device-debug.yml", "utf8");
expect(workflow).toContain('if [[ "$WORKFLOW_REF" != "refs/heads/master" ]]');
expect(workflow).toContain("name: mobile-device-debug");
expect(workflow).toContain("RELEASE_COMMIT_SHA: ${{ env.RESOLVED_SOURCE_SHA }}");
expect(workflow).toContain("IOS_DEBUG_CERTIFICATE_BASE64: ${{ secrets.IOS_DEBUG_CERTIFICATE_BASE64 }}");
expect(workflow).toContain("Origin: capacitor://localhost");
expect(workflow).toContain("Stable API did not allow the exact capacitor://localhost origin");
expect(workflow).toContain("Stable API did not allow credentialed Capacitor requests");
expect(workflow).toContain("Stable API did not allow the Authorization header from the Capacitor origin");
expect(workflow).not.toContain("IOS_DEBUG_KEYCHAIN_PASSWORD");
expect(workflow).not.toMatch(/upload-app|notarytool|transporter/iu);
});
});