// iOS IPA validation tests cover the App Store upload gate without real signing assets. import { execFileSync } from "node:child_process"; import { chmodSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs"; import os from "node:os"; import path from "node:path"; import JSZip from "jszip"; import { afterEach, describe, expect, it } from "vitest"; const SCRIPT = path.join(process.cwd(), "scripts", "ios-validate-app-store-ipa.sh"); const BASH_BIN = process.platform === "win32" ? "bash" : "/bin/bash"; const tempDirs: string[] = []; function bashArgs(scriptPath: string): string[] { return process.platform === "win32" ? [scriptPath] : ["--noprofile", "--norc", scriptPath]; } function writeExecutable(filePath: string, body: string): void { writeFileSync(filePath, body, "utf8"); chmodSync(filePath, 0o755); } function plistString(key: string, value: string): string { return `${key}${value}`; } function plistArray(key: string, values: readonly string[]): string { return `${key}${values.map((value) => `${value}`).join("")}`; } function plistDict(key: string, body: string): string { return `${key}${body}`; } function plist(body: string): string { return [ ``, ``, `${body}`, ].join("\n"); } function writeFakePlistBuddy(filePath: string): void { writeExecutable( filePath, `#!/usr/bin/env node const { readFileSync } = require("node:fs"); const commandIndex = process.argv.indexOf("-c"); if (commandIndex < 0) process.exit(2); const command = process.argv[commandIndex + 1] || ""; const file = process.argv[commandIndex + 2]; const keyPath = command.replace(/^Print:/, "").split(":").filter(Boolean); const xml = readFileSync(file, "utf8"); const tokens = [...xml.matchAll(/([^<]*)<\\/key>|([^<]*)<\\/string>||<\\/array>||<\\/dict>/g)]; let i = 0; function parseValue() { const token = tokens[i++]; if (!token) return undefined; const text = token[0]; if (token[2] !== undefined) return token[2]; if (text === "") return parseDict(); if (text === "") { const values = []; while (i < tokens.length && tokens[i][0] !== "") { const value = parseValue(); if (value !== undefined) values.push(value); } i++; return values; } return undefined; } function parseDict() { const obj = {}; while (i < tokens.length && tokens[i][0] !== "") { const key = tokens[i++][1]; if (key === undefined) continue; obj[key] = parseValue(); } i++; return obj; } while (i < tokens.length && tokens[i][0] !== "") i++; const root = parseValue(); let current = root; for (const key of keyPath) { if (!current || typeof current !== "object" || Array.isArray(current) || !(key in current)) { process.exit(1); } current = current[key]; } if (Array.isArray(current)) { console.log("Array {"); for (const value of current) console.log(" " + value); console.log("}"); } else if (current && typeof current === "object") { console.log("Dict {"); console.log("}"); } else if (typeof current === "string") { console.log(current); } else { process.exit(1); } `, ); } function writeFakeUnzip(filePath: string): void { writeExecutable( filePath, `#!/usr/bin/env node const { mkdirSync, readFileSync, writeFileSync } = require("node:fs"); const { createRequire } = require("node:module"); const path = require("node:path"); const requireFromRepo = createRequire(path.join(process.cwd(), "package.json")); const JSZip = requireFromRepo("jszip"); const args = process.argv.slice(2); let ipaPath = ""; let outputDir = ""; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === "-d") { outputDir = args[++i] || ""; } else if (!arg.startsWith("-")) { ipaPath = arg; } } if (!ipaPath || !outputDir) process.exit(2); (async () => { const zip = await JSZip.loadAsync(readFileSync(ipaPath)); for (const [entryPath, entry] of Object.entries(zip.files)) { const outputPath = path.join(outputDir, entryPath); if (entry.dir) { mkdirSync(outputPath, { recursive: true }); continue; } mkdirSync(path.dirname(outputPath), { recursive: true }); writeFileSync(outputPath, await entry.async("nodebuffer")); } })().catch(() => process.exit(1)); `, ); } async function writeIpaFixture(root: string): Promise { const zip = new JSZip(); function addTree(dirPath: string, zipPath: string): void { for (const entry of readdirSync(dirPath, { withFileTypes: true })) { const sourcePath = path.join(dirPath, entry.name); const entryZipPath = `${zipPath}/${entry.name}`; if (entry.isDirectory()) { addTree(sourcePath, entryZipPath); } else if (entry.isFile()) { zip.file(entryZipPath, readFileSync(sourcePath), { date: new Date(0) }); } } } addTree(path.join(root, "Payload"), "Payload"); const ipaPath = path.join(root, "OpenClaw.ipa"); const buffer = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" }); writeFileSync(ipaPath, buffer); return ipaPath; } async function writeValidFixture( root: string, options: { pushMode?: string; legacyKey?: boolean } = {}, ): Promise<{ ipaPath: string; plistBuddy: string; codesign: string; security: string; unzip: string; }> { const binDir = path.join(root, "bin"); const payloadDir = path.join(root, "Payload"); const appDir = path.join(payloadDir, "OpenClaw.app"); const fixturesDir = path.join(root, "fixtures"); mkdirSync(appDir, { recursive: true }); mkdirSync(binDir, { recursive: true }); mkdirSync(fixturesDir, { recursive: true }); const infoBody = [ plistString("CFBundleIdentifier", "ai.openclawfoundation.app"), plistString("OpenClawPushMode", options.pushMode ?? "appStore"), plistString("OpenClawPushRelayBaseURL", ""), options.legacyKey ? plistString("OpenClawPushRelayProfile", "production") : "", ].join(""); writeFileSync(path.join(appDir, "Info.plist"), plist(infoBody), "utf8"); writeFileSync(path.join(appDir, "embedded.mobileprovision"), "fixture profile", "utf8"); const entitlementsPath = path.join(fixturesDir, "entitlements.plist"); writeFileSync( entitlementsPath, plist( [ plistString("application-identifier", "FWJYW4S8P8.ai.openclawfoundation.app"), plistString("com.apple.developer.team-identifier", "FWJYW4S8P8"), plistString("aps-environment", "production"), plistString("com.apple.developer.devicecheck.appattest-environment", "production"), plistArray("com.apple.security.application-groups", [ "group.ai.openclawfoundation.app.shared", ]), ].join(""), ), "utf8", ); const profilePath = path.join(fixturesDir, "profile.plist"); writeFileSync( profilePath, plist( [ plistString("Name", "OpenClaw App Store ai.openclawfoundation.app"), plistArray("TeamIdentifier", ["FWJYW4S8P8"]), plistDict( "Entitlements", [ plistString("application-identifier", "FWJYW4S8P8.ai.openclawfoundation.app"), plistString("aps-environment", "production"), plistArray("com.apple.developer.devicecheck.appattest-environment", ["production"]), plistArray("com.apple.security.application-groups", [ "group.ai.openclawfoundation.app.shared", ]), ].join(""), ), ].join(""), ), "utf8", ); const plistBuddy = path.join(binDir, "plistbuddy"); writeFakePlistBuddy(plistBuddy); const unzip = path.join(binDir, "unzip"); writeFakeUnzip(unzip); const codesign = path.join(binDir, "codesign"); writeExecutable( codesign, `#!/usr/bin/env bash set -euo pipefail cat "${entitlementsPath}" `, ); const security = path.join(binDir, "security"); writeExecutable( security, `#!/usr/bin/env bash set -euo pipefail cat "${profilePath}" `, ); const ipaPath = await writeIpaFixture(root); return { ipaPath, plistBuddy, codesign, security, unzip }; } function runValidator(fixture: { ipaPath: string; plistBuddy: string; codesign: string; security: string; unzip: string; }): { ok: boolean; stdout: string; stderr: string } { try { const stdout = execFileSync(BASH_BIN, [...bashArgs(SCRIPT), "--ipa", fixture.ipaPath], { cwd: process.cwd(), env: { ...process.env, IOS_VALIDATE_PLIST_BUDDY_BIN: fixture.plistBuddy, IOS_VALIDATE_CODESIGN_BIN: fixture.codesign, IOS_VALIDATE_SECURITY_BIN: fixture.security, IOS_VALIDATE_UNZIP_BIN: fixture.unzip, }, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); return { ok: true, stdout, stderr: "" }; } catch (error) { const e = error as { stdout?: unknown; stderr?: unknown }; const stdout = Buffer.isBuffer(e.stdout) ? e.stdout.toString("utf8") : String(e.stdout ?? ""); const stderr = Buffer.isBuffer(e.stderr) ? e.stderr.toString("utf8") : String(e.stderr ?? ""); return { ok: false, stdout, stderr }; } } describe("scripts/ios-validate-app-store-ipa.sh", () => { afterEach(() => { for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } }); it("accepts an App Store IPA with appStore mode and production entitlements", async () => { const root = mkdtempSync(path.join(os.tmpdir(), "openclaw-ios-ipa-")); tempDirs.push(root); const fixture = await writeValidFixture(root); const result = runValidator(fixture); expect(result.ok).toBe(true); expect(result.stdout).toContain("Validated iOS App Store IPA"); }); it("rejects an IPA that was exported with a non-App-Store push mode", async () => { const root = mkdtempSync(path.join(os.tmpdir(), "openclaw-ios-ipa-")); tempDirs.push(root); const fixture = await writeValidFixture(root, { pushMode: "localProduction" }); const result = runValidator(fixture); expect(result.ok).toBe(false); expect(result.stderr).toContain("push mode mismatch"); }); it("rejects legacy independently selectable production push keys", async () => { const root = mkdtempSync(path.join(os.tmpdir(), "openclaw-ios-ipa-")); tempDirs.push(root); const fixture = await writeValidFixture(root, { legacyKey: true }); const result = runValidator(fixture); expect(result.ok).toBe(false); expect(result.stderr).toContain("legacy relay profile"); }); });