Files
software-mansion__argent/packages/tool-server/test/ios-device-team-detect.test.ts
Sebastian Flajszer 1068430b91 feat(ios-device): small fixes related with ios-device support (#1031)
Follow-up to #944: the fixes made after the review that did not make the
squash.

## What changes

- **Signing note**: delivered only to a call whose device argument
resolves to a physical iPhone, including `flow-execute`'s `device`, and
held (not dropped) across other calls. The label is now `Apple
Development (KEYID)`; the certificate CN's Apple ID no longer reaches
agent context or logs.
- **Flow device resolution**: the no-device-matched refusal names the
cabled phone and the exact `device <udid>` / `--device` spelling. A
physical iPhone is never picked automatically.
- **`gesture-swipe`**: the description states the hardware semantics
(edge gesture needs `fromX` 0, `durationMs` sets drag speed,
`momentum:false` does not damp).
- **Skills**: `argent-ios-simulator-setup` skips `kind: "device"`
entries; `argent-ios-device-setup` and `-interact` state the runner
build timing, the full list of tools that do not exist on hardware, the
backgrounded-target behaviour of each read tool, `actionButton` only on
models that have one, and the swipe and `open-url` rules; the flow,
Metro and profiler skills each carry one line on physical-iPhone limits.
- **Docs**: `physical-ios-devices`, `flows`, `flow-yaml` and `tools`
corrected to match the behaviour above; README platform row; runner
`PROTOCOL.md`.

## Verification

- tool-server suite 400 files / 5173 tests, `tsc`, lint, prettier and
the docs build clean.
- Live re-check against 0.23.0 on three simulators and a physical iPhone
15: no regression; the note lands once on the first phone call with no
e-mail address, and flow auto-detection is byte-identical to 0.23.0 with
0, 1 and 3 simulators booted.
2026-09-03 14:53:25 +02:00

243 lines
8.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__setCertificateListerForTests,
announceDetectedSigningTeam,
buildSigningDetectionNote,
consumePendingSigningDetectionNote,
detectSigningTeams,
parseSigningTeams,
type DetectedSigningTeam,
signingLabel,
} from "../src/utils/ios-device/team-detect";
import {
NO_TEAM_PEM,
TEAM_A_PEM,
TEAM_B_OLDER_PEM,
TEAM_B_PEM,
TIE_A_PEM,
TIE_B_PEM,
} from "./fixtures/signing-certs";
// Every case pins the lister seam, so this suite never reads the developer's
// real keychain; the seam reset also clears the memo and the announce-once
// state between cases.
beforeEach(() => __setCertificateListerForTests(async () => ""));
afterEach(() => __setCertificateListerForTests(null));
describe("signingLabel", () => {
it("keeps the certificate kind and key id and drops the account identity", () => {
expect(signingLabel("Apple Development: alice@example.com (ALICEKEY01)")).toBe(
"Apple Development (ALICEKEY01)"
);
expect(signingLabel("iPhone Developer: Alice Example (ALICEKEY01)")).toBe(
"iPhone Developer (ALICEKEY01)"
);
});
it("falls back to the kind alone when the CN has no key id", () => {
expect(signingLabel("Apple Development: alice@example.com")).toBe("Apple Development");
expect(signingLabel("Apple Development")).toBe("Apple Development");
});
});
describe("parseSigningTeams", () => {
it("reads team id (OU), the redacted label (CN) and notBefore out of a PEM block", () => {
const teams = parseSigningTeams(TEAM_A_PEM);
expect(teams).toEqual([
{
teamId: "ABCDE12345",
label: "Apple Development (ALICEKEY01)",
issuedAtMs: Date.parse("2024-01-15T12:00:00Z"),
},
]);
});
it("orders distinct teams newest certificate first", () => {
// TEAM_A (2024) deliberately precedes TEAM_B (2025) in the input, so the
// order below can only come from the notBefore sort.
const teams = parseSigningTeams([TEAM_A_PEM, TEAM_B_PEM].join("\n"));
expect(teams.map((t) => t.teamId)).toEqual(["FGHIJ67890", "ABCDE12345"]);
});
it("dedups by team id, keeping the team's newest certificate", () => {
// The older FGHIJ67890 certificate comes last, so "newest wins" cannot be
// an accident of insertion order.
const teams = parseSigningTeams([TEAM_B_OLDER_PEM, TEAM_B_PEM].join("\n"));
expect(teams).toHaveLength(1);
expect(teams[0]).toMatchObject({
teamId: "FGHIJ67890",
label: "Apple Development (BOBKEY0002)",
});
});
it("breaks a shared notBefore deterministically by team id", () => {
const forward = parseSigningTeams([TIE_A_PEM, TIE_B_PEM].join("\n"));
const reversed = parseSigningTeams([TIE_B_PEM, TIE_A_PEM].join("\n"));
expect(forward.map((t) => t.teamId)).toEqual(["TIEAA11111", "TIEBB22222"]);
expect(reversed.map((t) => t.teamId)).toEqual(["TIEAA11111", "TIEBB22222"]);
});
it("skips blocks that do not parse and certificates without a team OU", () => {
const garbageBlock =
"-----BEGIN CERTIFICATE-----\nnot base64 at all!!\n-----END CERTIFICATE-----";
const teams = parseSigningTeams([garbageBlock, NO_TEAM_PEM, TEAM_A_PEM].join("\n"));
expect(teams.map((t) => t.teamId)).toEqual(["ABCDE12345"]);
});
it("returns [] for empty output and for text without PEM blocks", () => {
expect(parseSigningTeams("")).toEqual([]);
expect(parseSigningTeams("security: no matches found")).toEqual([]);
});
});
describe("detectSigningTeams", () => {
it("queries the current and the legacy common name and merges both", async () => {
const lister = vi.fn(async (commonName: string) => {
if (commonName === "Apple Development") return TEAM_B_PEM;
if (commonName === "iPhone Developer") return TEAM_A_PEM;
return "";
});
__setCertificateListerForTests(lister);
const teams = await detectSigningTeams();
expect(lister.mock.calls.map(([cn]) => cn)).toEqual(["Apple Development", "iPhone Developer"]);
expect(teams.map((t) => t.teamId)).toEqual(["FGHIJ67890", "ABCDE12345"]);
});
it("memoizes a populated result for the process lifetime until the test seam resets it", async () => {
const lister = vi.fn(async () => TEAM_A_PEM);
__setCertificateListerForTests(lister);
const first = await detectSigningTeams();
const second = await detectSigningTeams();
expect(second).toBe(first);
// Two common names, one shellout each; the second detect hits the memo.
expect(lister).toHaveBeenCalledTimes(2);
__setCertificateListerForTests(lister);
await detectSigningTeams();
expect(lister).toHaveBeenCalledTimes(4);
});
it("re-detects after an empty result instead of caching it for the process lifetime", async () => {
// The lister reads `pem` on every call, so the keychain gains a
// certificate between the two detections without a seam reset (which
// would clear the memo and hide the bug).
let pem = "";
const lister = vi.fn(async () => pem);
__setCertificateListerForTests(lister);
expect(await detectSigningTeams()).toEqual([]);
expect(lister).toHaveBeenCalledTimes(2);
pem = TEAM_A_PEM;
const teams = await detectSigningTeams();
expect(teams.map((t) => t.teamId)).toEqual(["ABCDE12345"]);
expect(lister).toHaveBeenCalledTimes(4);
// Once populated, the memo holds again.
expect(await detectSigningTeams()).toBe(teams);
expect(lister).toHaveBeenCalledTimes(4);
});
it("does not memoize a rejected detection", async () => {
let pem: string | null = null;
const lister = vi.fn(async () => {
if (pem === null) throw new Error("keychain unavailable");
return pem;
});
__setCertificateListerForTests(lister);
await expect(detectSigningTeams()).rejects.toThrow("keychain unavailable");
expect(lister).toHaveBeenCalledTimes(2);
pem = TEAM_A_PEM;
const teams = await detectSigningTeams();
expect(teams.map((t) => t.teamId)).toEqual(["ABCDE12345"]);
expect(lister).toHaveBeenCalledTimes(4);
});
});
const ONE_TEAM: DetectedSigningTeam[] = [
{
teamId: "ABCDE12345",
label: "Apple Development (ALICEKEY01)",
issuedAtMs: Date.parse("2024-01-15T12:00:00Z"),
},
];
const THREE_TEAMS: DetectedSigningTeam[] = [
{
teamId: "FGHIJ67890",
label: "Apple Development (BOBKEY0002)",
issuedAtMs: Date.parse("2025-06-01T09:00:00Z"),
},
...ONE_TEAM,
{
teamId: "KLMNO13579",
label: "Apple Development (CAROLKEY01)",
issuedAtMs: Date.parse("2023-02-02T00:00:00Z"),
},
];
describe("buildSigningDetectionNote", () => {
it("names the single detected team and the override variable", () => {
expect(buildSigningDetectionNote(ONE_TEAM)).toBe(
"Signing the on-device runner with team ABCDE12345 " +
"(Apple Development (ALICEKEY01)), detected from this Mac's " +
"keychain. Set ARGENT_IOS_TEAM_ID in the tool-server's environment to override."
);
});
it("lists the losing teams and the exact restart command when several exist", () => {
expect(buildSigningDetectionNote(THREE_TEAMS)).toBe(
"Signing the on-device runner with team FGHIJ67890 " +
"(Apple Development (BOBKEY0002)), the newest of 3 signing " +
"identities in this Mac's keychain. Also found: ABCDE12345 " +
"(Apple Development (ALICEKEY01)), KLMNO13579 " +
"(Apple Development (CAROLKEY01)). To sign under a different " +
"team, set ARGENT_IOS_TEAM_ID in the tool-server's environment: " +
"argent server stop && ARGENT_IOS_TEAM_ID=<team-id> argent server start --detach"
);
});
});
describe("announceDetectedSigningTeam", () => {
it("logs once to stderr and stages the note for exactly one drain", () => {
const write = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
try {
announceDetectedSigningTeam(ONE_TEAM);
announceDetectedSigningTeam(ONE_TEAM);
expect(write).toHaveBeenCalledTimes(1);
expect(write.mock.calls[0]?.[0]).toBe(
`[ios-device-signing] ${buildSigningDetectionNote(ONE_TEAM)}\n`
);
expect(consumePendingSigningDetectionNote()).toBe(buildSigningDetectionNote(ONE_TEAM));
expect(consumePendingSigningDetectionNote()).toBeNull();
} finally {
write.mockRestore();
}
});
it("ignores an empty detection instead of announcing nothing", () => {
const write = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
try {
announceDetectedSigningTeam([]);
expect(write).not.toHaveBeenCalled();
expect(consumePendingSigningDetectionNote()).toBeNull();
} finally {
write.mockRestore();
}
});
});