diff --git a/.github/workflows/ios-appstore-screenshots.yml b/.github/workflows/ios-appstore-screenshots.yml new file mode 100644 index 00000000..5a228388 --- /dev/null +++ b/.github/workflows/ios-appstore-screenshots.yml @@ -0,0 +1,93 @@ +name: iOS App Store Screenshots + +# Captures the App Store screenshots for the CodeCaps iOS companion. +# +# There is no iOS simulator runtime on Jay's Mac, so the captures have to come +# from a runner. The script builds the companion for one iPhone and one iPad +# simulator, seeds each with a realistic quota snapshot and writes one PNG per +# screen at the exact pixel size App Store Connect accepts, asserting the size +# before the file is handed on. +# +# The app needs seconds to draw its first frame, so the script polls the screen +# and keeps the first frame that holds more than the status bar. A fixed +# sleep captured a blank window, and a blank window is a picture of nothing. +# +# The upload to App Store Connect is deliberately NOT done here. It is a +# separate, explicit step run from a machine with the App Store Connect key: +# +# node scripts/ios-fleet/asc-api.mjs upload-screenshots \ +# auto app_iphone_67-fleet-status.png +# node scripts/ios-fleet/asc-api.mjs upload-screenshots \ +# auto app_ipad_pro_129-fleet-status.png +# +# so a re-run of this workflow can never quietly add duplicate screenshots to +# a live version. + +on: + workflow_dispatch: + push: + branches: [main] + paths: + - "ios/CodeCapsCompanion/**" + # The exact shared files the companion compiles, and not the whole of + # Sources/QuotaCore: most QuotaCore edits are Mac-only, and a capture + # that shows more than the shipped build would be worse than a stale + # one. This list mirrors ios-ship.yml, so the screenshots and the + # TestFlight build change together. + - "Sources/QuotaCore/CompanionReadTokenStore.swift" + - "Sources/QuotaCore/FleetSourceDisagreement.swift" + - "Sources/QuotaCore/QuotaWindowVocabulary.swift" + - "Sources/QuotaCore/ResetAlarmCadence.swift" + - "Sources/QuotaCore/ResetAlarmMessage.swift" + - "Sources/QuotaCore/ResetAlarmTracker.swift" + - "Sources/QuotaCore/WidgetPlatformDisplay.swift" + - "Sources/QuotaCore/WidgetSnapshotFetcher.swift" + # The helpers the capture calls. A change to either of them changes + # what the capture keeps, so it has to bring a new capture with it. + - "scripts/ios-fleet/capture-appstore-screenshots.sh" + - "scripts/ios-fleet/pick_simulator.py" + - "scripts/ios-fleet/screenshot_ink.py" + - ".github/workflows/ios-appstore-screenshots.yml" + +concurrency: + group: ios-appstore-screenshots-${{ github.ref }} + # Only supersede a run for the same pull request. Cancelling on a push to + # main would report cancelled checks for a commit that nothing replaced. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + screenshots: + name: Capture iPhone and iPad screenshots + runs-on: macos-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v7 + + - name: Install XcodeGen + run: | + if ! command -v xcodegen >/dev/null 2>&1; then + brew install xcodegen + fi + + - name: Capture App Store screenshots + run: | + chmod +x scripts/ios-fleet/capture-appstore-screenshots.sh + scripts/ios-fleet/capture-appstore-screenshots.sh "$RUNNER_TEMP/appstore-screenshots" + + - name: Report the captured sizes + run: | + for shot in "$RUNNER_TEMP"/appstore-screenshots/*; do + echo "::group::$(basename "$shot")" + sips -g pixelWidth -g pixelHeight -g hasAlpha "$shot" | grep -E "pixel|/sips" || true + echo "::endgroup::" + done + + - name: Upload the captured screenshots + uses: actions/upload-artifact@v7 + with: + name: appstore-screenshots + path: ${{ runner.temp }}/appstore-screenshots/* + if-no-files-found: error diff --git a/.github/workflows/ios-ship-contract.yml b/.github/workflows/ios-ship-contract.yml index da8270af..470093d9 100644 --- a/.github/workflows/ios-ship-contract.yml +++ b/.github/workflows/ios-ship-contract.yml @@ -45,3 +45,10 @@ jobs: node --test scripts/ios-ship-workflow.test.mjs bash scripts/ios-fleet/test-asc-credentials.sh node --test scripts/ios-fleet/test-asc-credentials.mjs + node --test scripts/ios-fleet/test-asc-client.mjs + node --test scripts/ios-fleet/test-asc-upload.mjs + node --test scripts/ios-fleet/test-asc-submit.mjs + node --test scripts/ios-fleet/test-asc-key.test.mjs + python3 scripts/ios-fleet/test_pick_simulator.py + python3 scripts/ios-fleet/test_screenshot_ink.py + bash scripts/ios-fleet/test-capture-script.sh diff --git a/.github/workflows/ios-ship.yml b/.github/workflows/ios-ship.yml index b3ffe631..8a213e8a 100644 --- a/.github/workflows/ios-ship.yml +++ b/.github/workflows/ios-ship.yml @@ -64,6 +64,7 @@ on: # ship a TestFlight build. - 'Sources/QuotaCore/CompanionReadTokenStore.swift' - 'Sources/QuotaCore/FleetSourceDisagreement.swift' + - 'Sources/QuotaCore/QuotaWindowCaption.swift' - 'Sources/QuotaCore/QuotaWindowVocabulary.swift' - 'Sources/QuotaCore/ResetAlarmCadence.swift' - 'Sources/QuotaCore/ResetAlarmMessage.swift' @@ -130,6 +131,7 @@ jobs: --path-prefix 'ios/CodeCapsCompanion/' \ --path-prefix 'Sources/QuotaCore/CompanionReadTokenStore.swift' \ --path-prefix 'Sources/QuotaCore/FleetSourceDisagreement.swift' \ + --path-prefix 'Sources/QuotaCore/QuotaWindowCaption.swift' \ --path-prefix 'Sources/QuotaCore/QuotaWindowVocabulary.swift' \ --path-prefix 'Sources/QuotaCore/ResetAlarmCadence.swift' \ --path-prefix 'Sources/QuotaCore/ResetAlarmMessage.swift' \ @@ -338,8 +340,19 @@ jobs: exit 0 fi set +e - node scripts/ios-fleet/asc-api.mjs ensure-standing-testers "$appleid" - rc=$? + # The client resolves the signing key from its environment and never + # from a path (Kody rule 42), so the staged key is handed to the + # child through the child's environment by the hardened reader. The + # key stays out of argv, out of stdout, out of $GITHUB_ENV and out + # of the log. + if [ -n "${ASC_KEY_PATH:-}" ]; then + node scripts/ios-fleet/asc-key.mjs exec "$ASC_KEY_PATH" -- \ + node scripts/ios-fleet/asc-api.mjs ensure-standing-testers "$appleid" + rc=$? + else + node scripts/ios-fleet/asc-api.mjs ensure-standing-testers "$appleid" + rc=$? + fi set -e if [ "$rc" -ne 0 ]; then echo "::warning title=TestFlight standing testers::tester sync exited ${rc}; see the standing-testers lines above. The TestFlight upload itself succeeded." diff --git a/Sources/QuotaCore/QuotaWindowCaption.swift b/Sources/QuotaCore/QuotaWindowCaption.swift new file mode 100644 index 00000000..0b514fbb --- /dev/null +++ b/Sources/QuotaCore/QuotaWindowCaption.swift @@ -0,0 +1,47 @@ +import Foundation + +/// What a window's caption says. +/// +/// A caption is three parts: the cadence token beside the bar, the qualifier that +/// tells two bars of the same cadence apart, and the reset line. +/// +/// The qualifier is the part that used to go missing. A caption row built without it +/// printed a bare "7d" where the row was meant to read "7d weekly", and nothing in +/// the type said so: the qualifier was an argument with a default, so a surface +/// that rendered a window's caption could leave it out and the omission showed up +/// only in a review. +/// +/// The type is shared so the widget and the tests use the same one. A caption cannot +/// be built without stating its qualifier, and a surface that renders a window's +/// caption builds it from that window, so a qualifier cannot quietly go missing +/// again. +/// +/// Plain Foundation; no other QuotaCore type. +public struct QuotaWindowCaption: Equatable, Sendable { + /// The cadence token: "5h", "7d", "1m". + public let token: String + /// The word that tells two bars of the same cadence apart: "weekly", + /// "additional". Empty when the bar has no sibling to tell apart from. + public let qualifier: String + /// The reset line: "Resets in 12d 22m". + public let resetCaption: String + + public init(token: String, qualifier: String, resetCaption: String) { + self.token = token + self.qualifier = qualifier + self.resetCaption = resetCaption + } + + /// The caption of a bar that has no sibling, and so no qualifier to print. + /// + /// It is a separate name on purpose: a caller that has a window uses the + /// window's own caption, and a caller that has no window says so here. + public static func withoutQualifier(token: String, resetCaption: String) -> QuotaWindowCaption { + QuotaWindowCaption(token: token, qualifier: "", resetCaption: resetCaption) + } + + /// Whether the caption says anything at all. + public var isEmpty: Bool { + token.isEmpty && qualifier.isEmpty && resetCaption.isEmpty + } +} diff --git a/Sources/QuotaCore/QuotaWindowVocabulary.swift b/Sources/QuotaCore/QuotaWindowVocabulary.swift index 3938dc85..e32adc88 100644 --- a/Sources/QuotaCore/QuotaWindowVocabulary.swift +++ b/Sources/QuotaCore/QuotaWindowVocabulary.swift @@ -12,6 +12,199 @@ public enum QuotaWindowVocabulary { public static let oneMonth = "1-month" public static let otherLimit = "Other limit" + /// Short distinguishing nouns for the widget caption row. + /// + /// These sit *next to* the cadence token (`5h`, `7d`, `1m`) and must not + /// repeat its cadence form; "7-day" beside a `7d` token is the duplication + /// owner 2026-10-21 caught on the Antigravity weekly row. A noun that + /// names the window kind ("weekly", "monthly", "additional") is what the + /// caption needs when two bars share one cadence. + public static let barQualifierWeekly = "weekly" + public static let barQualifierMonthly = "monthly" + /// Not "daily": the caption already shows the `1d` token, so the + /// qualifier must not restate the same cadence. "daily allowance" names + /// the kind of allowance instead. + public static let barQualifierDaily = "daily allowance" + public static let barQualifierHourly = "hourly" + public static let barQualifierAdditional = "additional" + + /// The cadence forms a bar qualifier must never repeat: the hyphenated + /// labels and the compact tokens the caption row already carries beside + /// the qualifier. + public static let barQualifierForbiddenCadenceForms = [ + fiveHour, oneDay, sevenDay, oneMonth, otherLimit, + "5h", "1d", "7d", "1m", "+" + ] + + /// The caption facts of one quota bar: the cadence it shows and the label + /// it carries. A row of bars is resolved together, because whether a bar + /// needs a qualifier depends on the bars beside it. + public struct BarCaption: Equatable, Sendable { + public let cadence: String + public let label: String + /// True when the window is the extra allowance of another window on the + /// same plan. The label alone cannot answer that: the label pipeline + /// rewrites an extra allowance into the same "Other limit" copy every + /// unknown window gets, so the identity has to travel with the bar. + public let isExtraAllowance: Bool + + public init(cadence: String, label: String, isExtraAllowance: Bool = false) { + self.cadence = cadence + self.label = label + self.isExtraAllowance = isExtraAllowance + } + } + + /// The lowercased lexical tokens of a label, split on everything that is + /// not a letter or a number. + /// + /// Canonical qualifiers are matched against these tokens and never as + /// substrings, so a label such as "nothourly" is not an hourly window. + public static func lexicalTokens(_ text: String) -> Set { + Set( + text.lowercased() + .split(whereSeparator: { !$0.isLetter && !$0.isNumber }) + .map(String.init) + ) + } + + /// The short distinguishing noun for a bar caption row, or "" when the + /// row needs none. + /// + /// The qualifier is named from the canonical cadence the caption row + /// already prints, not from prose words: the widget's own label pipeline + /// rewrites a label into the hyphenated cadence copy ("7-day window"), so + /// a matcher that looks for "weekly" in the label never matches a label + /// the widget renders (Kody review 2026-10-21). + /// + /// A cadence word is only worth printing when a sibling bar shares the + /// cadence, because on a lone bar "7d weekly" says the same thing twice. + /// + /// The label is consulted for the extra-allowance case alone, which is + /// the one qualifier the label still names. + /// + /// `isExtraAllowance` carries the identity the label cannot: an extra + /// allowance is rewritten into the same "Other limit" copy every unknown + /// window gets, so a bar that is known to be the extra allowance has to + /// say so, or it is read as an ordinary window of its period. + public static func barQualifier( + forCadence cadence: String, + label: String, + sharesCadence: Bool, + isExtraAllowance: Bool = false + ) -> String { + guard sharesCadence else { return "" } + if isExtraAllowance || isExtraAllowanceLabel(label) { return barQualifierAdditional } + switch cadence { + case sevenDay: return barQualifierWeekly + case oneMonth: return barQualifierMonthly + case oneDay: return barQualifierDaily + case fiveHour: return barQualifierHourly + default: return "" + } + } + + /// The canonical cadence a window belongs to, used to group the bars that + /// share one period. + /// + /// The window's own cadence decides the group, because that is what the + /// caption token beside the bar prints. The label is deliberately not + /// handed to the canonicalizer: the prose matcher behind it reads + /// substrings, so a label such as "notweekly" or "notdaily" was read as a + /// weekly or daily window before the exact-token fallback below ever ran + /// (Kody rule 40, binding 2026-10-21). A label resolves a group only + /// through whole lexical tokens, below. + /// + /// The canonical forms the widget renders — "7-day window", "5-hour + /// window", "1-day window", "1-month window" — name their period through + /// whole tokens too, so a bar whose own cadence does not resolve still + /// groups with the sibling that prints the same caption token. + /// + /// An extra allowance is extra capacity of the period it doubles, so it + /// groups with that window rather than standing alone. It carries no + /// cadence of its own, so its label is left out of the lookup rather + /// than collapsing the row to "Other limit". + public static func barCadenceGroup( + cadence: String, + label: String, + isExtraAllowance: Bool = false + ) -> String { + let canonical = standardCadence( + windowToken: cadence, + label: "" + ) + if canonical != otherLimit { return canonical } + // An extra allowance names no period of its own, and its label is + // left out of the lookup: it joins the group of the one sibling + // that has a period, which is what makes the owner-approved pair + // ("weekly" / "additional") reachable. + if isExtraAllowance { return otherLimit } + // The label resolves a group only through whole lexical tokens: + // "weekly" names the weekly group, "notweekly" names no group at all. + let tokens = lexicalTokens(label) + if tokens.contains("weekly") { return sevenDay } + if tokens.contains("monthly") || tokens.contains("billing") { return oneMonth } + if tokens.contains("daily") { return oneDay } + if tokens.contains("hourly") { return fiveHour } + // The canonical forms the widget renders still name their period: + // "7-day window", "5-hour window", "1-day window", "1-month window". + // The caption token beside the bar resolves the same form through + // the prose matcher, so the group has to resolve it too, or a bar + // that prints "7d" is grouped as "Other limit" and split from the + // sibling that prints the same token. + let hasDay = tokens.contains("day") || tokens.contains("days") + let hasHour = tokens.contains("hour") || tokens.contains("hours") + let hasMonth = tokens.contains("month") || tokens.contains("months") + if tokens.contains("7"), hasDay { return sevenDay } + if tokens.contains("1"), hasDay { return oneDay } + if tokens.contains("5"), hasHour { return fiveHour } + if tokens.contains("1"), hasMonth { return oneMonth } + return canonical + } + + /// The qualifier of every bar in a caption row, in the same order. + /// + /// Each bar is resolved against its siblings, so a bar that shares its + /// cadence with another bar gets the word that tells the two apart and a + /// lone bar gets none. + /// + /// An extra allowance has no period of its own: it is extra capacity of + /// the period it doubles, and a provider that reports it without a token + /// names no period at all. When its own cadence resolves to nothing, it + /// joins the group of the one sibling that has a period, which is what + /// makes the owner-approved pair ("weekly" / "additional") reachable. + /// The sibling has to be unambiguous, so a platform with two different + /// periods beside it leaves the extra allowance on its own. + public static func barQualifiers(forBars bars: [BarCaption]) -> [String] { + var groups = bars.map { + barCadenceGroup( + cadence: $0.cadence, + label: $0.label, + isExtraAllowance: $0.isExtraAllowance + ) + } + for index in bars.indices where bars[index].isExtraAllowance && groups[index] == otherLimit { + let siblingGroups = Set( + bars.indices + .filter { $0 != index } + .map { groups[$0] } + .filter { $0 != otherLimit } + ) + if siblingGroups.count == 1, let adopted = siblingGroups.first { + groups[index] = adopted + } + } + return bars.indices.map { index in + let siblings = groups.indices.filter { $0 != index }.map { groups[$0] } + return barQualifier( + forCadence: groups[index], + label: bars[index].label, + sharesCadence: siblings.contains(groups[index]), + isExtraAllowance: bars[index].isExtraAllowance + ) + } + } + /// Full cadence label for a window, suitable for main-window copy and wire export. public static func standardCadence( windowToken: String?, @@ -139,6 +332,29 @@ public enum QuotaWindowVocabulary { || lower.hasSuffix(" extra") } + /// Whether a window is the extra allowance of another window on the same + /// plan. + /// + /// The label detector answers for labels that name the kind ("Additional + /// allowance", "+ extra"). A provider that files its extra allowance + /// without naming it is caught by the provider-aware check: Muse Assist + /// reports it as a tokenless window that resolves to no period, and the + /// Mac popover already treats exactly that window as the extra allowance + /// (`isMuseAssistExtra`). + /// + /// The identity has to be carried with the window rather than read back + /// from its label, because the label pipeline rewrites an extra allowance + /// into the same "Other limit" copy every unknown window gets. + public static func isExtraAllowance( + providerKey: String?, + label: String, + windowToken: String? = nil + ) -> Bool { + if isExtraAllowanceLabel(label) { return true } + guard let providerKey, !providerKey.isEmpty else { return false } + return isMuseAssistExtra(providerKey: providerKey, windowToken: windowToken, label: label) + } + public static func isOpaqueProviderKey(_ text: String) -> Bool { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return false } @@ -171,9 +387,9 @@ public enum QuotaWindowVocabulary { ) -> String? { let lower = token.lowercased() if ["5h", "5hr", "5-hour", "5hours", "300m"].contains(lower) { return fiveHour } - if ["1d", "24h", "daily", "day"].contains(lower) { return oneDay } - if ["7d", "168h", "1w", "weekly", "week"].contains(lower) { return sevenDay } - if ["1m", "30d", "mo", "month", "monthly"].contains(lower) { return oneMonth } + if ["1d", "24h", "daily", "day", "1-day"].contains(lower) { return oneDay } + if ["7d", "168h", "1w", "weekly", "week", "7-day"].contains(lower) { return sevenDay } + if ["1m", "30d", "mo", "month", "monthly", "1-month"].contains(lower) { return oneMonth } if let seconds = ResetAlarmCadence.durationSeconds(lower) { return cadenceFromDuration(seconds, label: label, monthlyHint: monthlyHint) } diff --git a/Tests/CodeCapsTests/CompanionModelTests.swift b/Tests/CodeCapsTests/CompanionModelTests.swift index 62bfdbc5..96723e1f 100644 --- a/Tests/CodeCapsTests/CompanionModelTests.swift +++ b/Tests/CodeCapsTests/CompanionModelTests.swift @@ -454,4 +454,67 @@ final class CompanionModelTests: XCTestCase { "Reset alarm bell button must meet the 44x44 minimum touch target standard" ) } + + // MARK: - Source scope label names the device the reader is holding + + func testSourceScopeLabelNamesTheDeviceFamily() throws { + let source = try readCompanionModelSource() + XCTAssertTrue( + source.contains(#"case thisIPhone = "This iPhone""#), + "The scope's stored raw value must stay \"This iPhone\" so a saved preference is not lost" + ) + XCTAssertTrue( + source.contains(#"case .thisIPhone: return "This \(family)""#), + "The this-device scope must be labelled with the device the reader is holding" + ) + XCTAssertTrue( + source.contains("case .allSources: return rawValue"), + "The all-sources scope must keep its own label" + ) + XCTAssertTrue( + source.contains(#"UIDevice.current.userInterfaceIdiom == .pad ? "iPad" : "iPhone""#), + "The device family must follow the interface idiom, so an iPad never says This iPhone" + ) + XCTAssertTrue( + source.contains(#"return "Mac""#), + "The Mac build of the companion must name the Mac" + ) + } + + func testSourceScopePickerShowsTheDeviceAwareLabel() throws { + let view = try readCompanionContentViewSource() + XCTAssertTrue( + view.contains("Text(scope.displayTitle).tag(scope)"), + "The source-scope picker must show the device-aware label" + ) + XCTAssertFalse( + view.contains("Text(scope.rawValue).tag(scope)"), + "The source-scope picker must not show the stored raw value, which always says This iPhone" + ) + } + + // MARK: - A launch argument opens the alarms tab + + func testAlarmsLaunchArgumentOpensTheAlarmsTab() throws { + let view = try readCompanionContentViewSource() + // Both spellings are accepted: -openAlarms is the screenshot + // capture's, and -openAlerts is the one the alert work introduced + // for the same tab. Either one has to open it. + XCTAssertTrue( + view.contains(#"contains("-openAlarms")"#), + "A launch argument must open the Alarms tab, so the screenshot capture can reach it without a tap" + ) + XCTAssertTrue( + view.contains(#"contains("-openAlerts")"#), + "The alert work's launch argument must open the same tab" + ) + XCTAssertTrue( + view.contains(#"initialValue: opensAlarms ? .alarms : .display"#), + "Either launch argument must select the Alarms tab" + ) + XCTAssertTrue( + view.contains(#"case alarms = "Alarms""#), + "The Alarms tab must still exist as a tab of the settings sheet" + ) + } } diff --git a/Tests/CodeCapsTests/PipInteractionTests.swift b/Tests/CodeCapsTests/PipInteractionTests.swift index baf8cc36..8721d645 100644 --- a/Tests/CodeCapsTests/PipInteractionTests.swift +++ b/Tests/CodeCapsTests/PipInteractionTests.swift @@ -83,8 +83,15 @@ final class PipInteractionTests: XCTestCase { func testFailedAntigravityReadingStaysPinnedWithoutShowingCachedQuota() throws { let (model, defaults, suite) = GlanceFixtures.makeModel(view: .fromMac, alarmsAll: false, fleet: false) defer { defaults.removePersistentDomain(forName: suite) } - let windows = [GlanceFixtures.window("antigravity:gemini:weekly", provider: "google-antigravity", - label: "Gemini Models · Weekly", token: "weekly", remaining: 54, resetIn: 86_400)] + // The demo model plus a failed Antigravity provider. The weekly + // reading is the cached 54 percent the owner saw in a screenshot, and + // the provider fails to refresh it. + let windows = GlanceFixtures.localWindows.map { window in + window.id == "antigravity:gemini:weekly" + ? GlanceFixtures.window("antigravity:gemini:weekly", provider: "google-antigravity", + label: "Gemini Models · Weekly", token: "weekly", remaining: 54, resetIn: 86_400) + : window + } let sections = QuotaResponse(generatedAt: "", windows: windows).platformSections(now: GlanceFixtures.now) model.injectForTests(sections: sections, now: GlanceFixtures.now, issues: ["google-antigravity": "Helper failed"]) @@ -97,8 +104,32 @@ final class PipInteractionTests: XCTestCase { issue: model.issues[row.providerKey]).isEmpty) XCTAssertEqual(PipWidgetController.meterCount(for: row, now: model.now, issue: model.issues[row.providerKey]), 0) + // Unpinning releases the row, but the panel does not go blank: the + // automatic selection falls back to the rows it can honestly measure, + // the rule testAllUnavailableFallbackStillShowsStatusRows asserts. + // What matters is that the failed provider never comes back with the + // cached 54 percent, and that every listed row is drawn honestly. model.pipPinnedRowIds = [] - XCTAssertFalse(PipWidgetController.targetRows(for: model).contains { $0.id == row.id }) + let rows = PipWidgetController.targetRows(for: model) + XCTAssertFalse(rows.isEmpty, "the panel must not go blank when the failed row is released") + XCTAssertFalse(rows.contains { $0.id == row.id }, + "the failed row must not be listed: it would show the cached 54 percent") + for candidate in rows { + let candidateIssue = model.issues[candidate.providerKey] + let status = PipWidgetController.readingStatus(for: candidate, now: model.now, + issue: candidateIssue) + let meters = PipWidgetController.meterSnapshots(for: candidate, now: model.now, + issue: candidateIssue) + if candidateIssue != nil { + XCTAssertNotNil(status, "\(candidate.id) must carry a status, never a live meter") + XCTAssertTrue(meters.isEmpty, "\(candidate.id) must not draw a cached meter") + } else if status == nil { + XCTAssertFalse(meters.isEmpty, "\(candidate.id) must show its live meters") + } else { + XCTAssertTrue(meters.isEmpty, + "\(candidate.id) must not draw meters for a row that only reports a status") + } + } } func testStaleOrDisabledReadingCannotDrawCurrentMeters() throws { diff --git a/Tests/QuotaCoreTests/QuotaWindowCaptionTests.swift b/Tests/QuotaCoreTests/QuotaWindowCaptionTests.swift new file mode 100644 index 00000000..c9cc274b --- /dev/null +++ b/Tests/QuotaCoreTests/QuotaWindowCaptionTests.swift @@ -0,0 +1,164 @@ +import XCTest +@testable import QuotaCore + +/// The caption a bar prints, starting from the bytes the backend sends. +/// +/// The earlier fixture was a custom Swift struct that called the vocabulary helpers +/// directly, so it could not catch a surface that left the qualifier out: it never +/// went near a payload, a decode, or a caption. This one starts from the payload +/// the backend sends for a Muse Assist plan, decodes it, derives the bars the way +/// the widget derives them, and takes the qualifiers from the production +/// resolution. The caption that comes out carries the qualifier, which is what the +/// widget prints beside the bar. +/// +/// The two links this cannot cover are the iOS parser and the SwiftUI render: both +/// live in the iOS target, which has no test target. What holds them is the type. +/// `QuotaWindowCaption` carries its qualifier, a caption built from a window carries +/// that window's, and a caption row takes a caption, so a surface that renders a +/// window's caption cannot leave the qualifier out without saying so. +final class QuotaWindowCaptionTests: XCTestCase { + /// A Muse Assist plan as the backend sends it. + /// + /// The weekly limit arrives with a "weekly" token. The extra allowance arrives + /// with no token at all and with the label the label pipeline has already + /// rewritten to the catch-all "Other limit" copy, so nothing in the label says + /// what kind of window it is: the provider and the missing token are the whole + /// of the signal. + private static let museAssistPayload = """ + { + "windows": [ + { + "id": "muse-weekly", + "provider": "muse-assist", + "providerKey": "muse-assist", + "label": "Weekly limit", + "window": "weekly", + "remainingPercent": 80 + }, + { + "id": "muse-extra", + "provider": "muse-assist", + "providerKey": "muse-assist", + "label": "Other limit", + "window": null, + "remainingPercent": 60 + } + ] + } + """.data(using: .utf8)! + + /// The wire shape, as the backend sends it. + private struct WireWindow: Decodable { + let id: String + let provider: String + let providerKey: String? + let label: String + let window: String? + let remainingPercent: Double? + } + + private struct WireEnvelope: Decodable { + let windows: [WireWindow]? + } + + /// The bar the widget draws for a decoded window. + /// + /// The cadence and the extra-allowance identity come from the production + /// vocabulary, the way the widget's parser asks for them. + private func bar(for window: WireWindow) -> QuotaWindowVocabulary.BarCaption { + QuotaWindowVocabulary.BarCaption( + cadence: QuotaWindowVocabulary.standardCadence( + windowToken: window.window, + label: window.label + ), + label: window.label, + isExtraAllowance: QuotaWindowVocabulary.isExtraAllowance( + providerKey: window.providerKey, + label: window.label, + windowToken: window.window + ) + ) + } + + /// The caption the widget prints for a decoded window. + private func caption(for window: WireWindow, qualifier: String) -> QuotaWindowCaption { + QuotaWindowCaption( + token: QuotaWindowVocabulary.popoverCaption( + windowToken: window.window, + label: window.label + ), + qualifier: qualifier, + resetCaption: "" + ) + } + + /// A payload's bars resolve to the owner-approved pair. + /// + /// The weekly limit reads "weekly" and the extra allowance reads "additional", + /// and the extra allowance carries no token of its own, so the qualifier is the + /// only thing that tells the two bars apart. + func test_a_payload_resolves_to_the_owner_approved_pair() throws { + let envelope = try JSONDecoder().decode(WireEnvelope.self, from: Self.museAssistPayload) + let windows = try XCTUnwrap(envelope.windows) + XCTAssertEqual(windows.count, 2) + + let qualifiers = QuotaWindowVocabulary.barQualifiers(forBars: windows.map { bar(for: $0) }) + XCTAssertEqual(qualifiers.count, 2) + XCTAssertEqual(qualifiers[0], QuotaWindowVocabulary.barQualifierWeekly) + XCTAssertEqual(qualifiers[1], QuotaWindowVocabulary.barQualifierAdditional) + } + + /// The caption a bar prints carries the qualifier the payload resolved. + /// + /// This is the link that was missing: a caption row built from the window's + /// caption prints the qualifier, and a caption built without one does not, so + /// the two are different values and a surface cannot confuse them. + func test_the_caption_carries_the_qualifier_the_payload_resolved() throws { + let envelope = try JSONDecoder().decode(WireEnvelope.self, from: Self.museAssistPayload) + let windows = try XCTUnwrap(envelope.windows) + let qualifiers = QuotaWindowVocabulary.barQualifiers(forBars: windows.map { bar(for: $0) }) + let captions = zip(windows, qualifiers).map { caption(for: $0, qualifier: $1) } + + XCTAssertEqual(captions[0].qualifier, "weekly") + XCTAssertEqual(captions[1].qualifier, "additional") + // The two captions differ in the qualifier alone, which is what tells the + // two bars apart. + XCTAssertNotEqual(captions[0].qualifier, captions[1].qualifier) + } + + /// A lone bar prints no qualifier, and the caption says so. + func test_a_lone_bar_prints_no_qualifier() throws { + let envelope = try JSONDecoder().decode(WireEnvelope.self, from: Self.museAssistPayload) + let windows = try XCTUnwrap(envelope.windows) + let lone = try XCTUnwrap(windows.first) + let qualifiers = QuotaWindowVocabulary.barQualifiers(forBars: [bar(for: lone)]) + XCTAssertEqual(qualifiers, [""]) + XCTAssertEqual(caption(for: lone, qualifier: qualifiers[0]).qualifier, "") + } + + /// A caption says whether it has anything to print. + func test_an_empty_caption_is_empty() { + XCTAssertTrue(QuotaWindowCaption(token: "", qualifier: "", resetCaption: "").isEmpty) + XCTAssertFalse( + QuotaWindowCaption(token: "7d", qualifier: "", resetCaption: "").isEmpty + ) + XCTAssertFalse( + QuotaWindowCaption(token: "", qualifier: "weekly", resetCaption: "").isEmpty + ) + } + + /// The only way to have a caption without a qualifier is to ask for one. + /// + /// The qualifier used to be an argument with a default, so a surface could leave + /// it out. Now a caption states its qualifier, and the no-qualifier form has + /// its own name, so the omission is visible in the code that makes it. + func test_a_caption_without_a_qualifier_is_asked_for_by_name() { + let caption = QuotaWindowCaption.withoutQualifier( + token: "7d", + resetCaption: "Resets in 3h" + ) + XCTAssertEqual(caption.qualifier, "") + XCTAssertEqual(caption.token, "7d") + XCTAssertEqual(caption.resetCaption, "Resets in 3h") + } +} diff --git a/Tests/QuotaCoreTests/QuotaWindowVocabularyTests.swift b/Tests/QuotaCoreTests/QuotaWindowVocabularyTests.swift index 083b5ffd..b796a94b 100644 --- a/Tests/QuotaCoreTests/QuotaWindowVocabularyTests.swift +++ b/Tests/QuotaCoreTests/QuotaWindowVocabularyTests.swift @@ -8,6 +8,47 @@ final class QuotaWindowVocabularyTests: XCTestCase { XCTAssertEqual(QuotaWindowVocabulary.standardCadence(windowToken: "1m", label: ""), "1-month") } + /// An extra allowance names no period of its own. + /// + /// It joins the group of the one sibling that has a period, and the sibling + /// resolution is what does that. The group lookup itself must not hand it a + /// period, or a bar that prints "7d" would be split from the sibling that + /// prints the same token (Kody review 2026-10-21). + func testAnExtraAllowanceHasNoGroupOfItsOwn() { + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup( + cadence: "Other limit", + label: "Other limit", + isExtraAllowance: true + ), + QuotaWindowVocabulary.otherLimit + ) + } + + /// A canonical label names its period. + /// + /// The widget renders "7-day window", "5-hour window" and "1-month window", + /// and a bar whose label says one of them has to group with the bar that + /// prints the same token, or the two are split (Kody review 2026-10-21). + func testACanonicalLabelNamesItsPeriod() { + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "Other limit", label: "7-day window"), + QuotaWindowVocabulary.sevenDay + ) + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "Other limit", label: "5-hour window"), + QuotaWindowVocabulary.fiveHour + ) + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "Other limit", label: "1-month window"), + QuotaWindowVocabulary.oneMonth + ) + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "Other limit", label: "1-day window"), + QuotaWindowVocabulary.oneDay + ) + } + func testOpaqueAnthropicKeyMapsToOtherLimit() { let label = QuotaWindowVocabulary.windowLabel(windowToken: nil, existingLabel: "iguana_necktie", resetAt: nil, periodStart: nil) XCTAssertEqual(label, "Other limit window") diff --git a/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift b/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift index f8ed92b6..94b17899 100644 --- a/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift +++ b/Tests/QuotaCoreTests/WidgetPresentationVocabularyTests.swift @@ -21,4 +21,509 @@ final class WidgetPresentationVocabularyTests: XCTestCase { "7d" ) } + + /// Owner 2026-10-21 saw "7d 7-day" duplicate in the Mac widget caption row + /// for the Antigravity weekly allowance. The cadence token sits on the + /// same line as the qualifier, so the qualifier must NEVER repeat the + /// cadence form ("7-day" / "5-hour" / "1-month"). Short nouns ("weekly", + /// "monthly", "additional") are the only forms the caption supports. + /// + /// Kody review 2026-10-21: the forbidden list named "7-day", "5-hour", + /// "1-month" and "Other limit" but left the daily cadence and the compact + /// tokens ("1d", "7d", "5h", "1m", "+") unguarded, so the stated invariant + /// was not actually enforced. The list below is the shared one the + /// vocabulary ships. + func testBarQualifierConstantsAreShortNounsNotCadenceForms() { + XCTAssertEqual(QuotaWindowVocabulary.barQualifierWeekly, "weekly") + XCTAssertEqual(QuotaWindowVocabulary.barQualifierMonthly, "monthly") + XCTAssertEqual(QuotaWindowVocabulary.barQualifierDaily, "daily allowance") + XCTAssertEqual(QuotaWindowVocabulary.barQualifierHourly, "hourly") + XCTAssertEqual(QuotaWindowVocabulary.barQualifierAdditional, "additional") + + for constant in [ + QuotaWindowVocabulary.barQualifierWeekly, + QuotaWindowVocabulary.barQualifierMonthly, + QuotaWindowVocabulary.barQualifierDaily, + QuotaWindowVocabulary.barQualifierHourly, + QuotaWindowVocabulary.barQualifierAdditional + ] { + for forbiddenCadenceForm in QuotaWindowVocabulary.barQualifierForbiddenCadenceForms { + XCTAssertNotEqual(constant, forbiddenCadenceForm, + "qualifier '\(constant)' must not repeat the cadence form '\(forbiddenCadenceForm)'.") + } + } + + XCTAssertTrue(QuotaWindowVocabulary.barQualifierForbiddenCadenceForms.contains(QuotaWindowVocabulary.oneDay), + "the daily cadence form must stay on the forbidden list.") + for compactToken in ["5h", "1d", "7d", "1m", "+"] { + XCTAssertTrue(QuotaWindowVocabulary.barQualifierForbiddenCadenceForms.contains(compactToken), + "the compact cadence token '\(compactToken)' must stay on the forbidden list.") + } + } + + /// Kody review 2026-10-21: the daily qualifier restated the cadence the + /// caption token already showed, so a daily window read "1d daily". The + /// qualifier names the kind of allowance instead. + func testDailyQualifierDoesNotRestateTheCadenceToken() { + // The production label for a daily window is "1-day window": the + // widget's label pipeline rewrites the prose away. + let qualifier = QuotaWindowVocabulary.barQualifier( + forCadence: QuotaWindowVocabulary.oneDay, + label: "1-day window", + sharesCadence: true + ) + XCTAssertEqual(qualifier, "daily allowance") + XCTAssertNotEqual(qualifier, "daily") + + let token = QuotaWindowVocabulary.popoverCaption(windowToken: "1-day", label: "1-day window") + XCTAssertEqual(token, "1d") + let caption = "\(token) \(qualifier)" + XCTAssertEqual(caption, "1d daily allowance") + XCTAssertNotEqual(caption, "1d daily") + } + + /// Kody review 2026-10-21: the qualifier is named from the canonical + /// cadence the caption row prints, not from prose words in the label. + /// + /// The widget builds every window label through + /// `QuotaWindowVocabulary.windowLabel(...)`, which rewrites the label into + /// the hyphenated cadence copy ("7-day window"), so a matcher that looks + /// for "weekly" in the label never matched a label the widget renders and + /// the owner-approved "weekly" / "additional" pair never appeared. + func testBarQualifierIsNamedFromTheCadenceNotTheLabel() { + XCTAssertEqual( + QuotaWindowVocabulary.barQualifier( + forCadence: QuotaWindowVocabulary.sevenDay, + label: "7-day window", + sharesCadence: true + ), + QuotaWindowVocabulary.barQualifierWeekly + ) + XCTAssertEqual( + QuotaWindowVocabulary.barQualifier( + forCadence: QuotaWindowVocabulary.oneMonth, + label: "1-month window", + sharesCadence: true + ), + QuotaWindowVocabulary.barQualifierMonthly + ) + XCTAssertEqual( + QuotaWindowVocabulary.barQualifier( + forCadence: QuotaWindowVocabulary.fiveHour, + label: "5-hour window", + sharesCadence: true + ), + QuotaWindowVocabulary.barQualifierHourly + ) + // A label that still carries prose cannot rename the cadence the row + // already prints. + XCTAssertEqual( + QuotaWindowVocabulary.barQualifier( + forCadence: QuotaWindowVocabulary.sevenDay, + label: "Free weekly limit", + sharesCadence: true + ), + QuotaWindowVocabulary.barQualifierWeekly + ) + // A lone bar needs none. + XCTAssertEqual( + QuotaWindowVocabulary.barQualifier( + forCadence: QuotaWindowVocabulary.sevenDay, + label: "7-day window", + sharesCadence: false + ), + "" + ) + // The extra allowance is the one case the label still names. + XCTAssertEqual( + QuotaWindowVocabulary.barQualifier( + forCadence: QuotaWindowVocabulary.sevenDay, + label: "Additional allowance", + sharesCadence: true + ), + QuotaWindowVocabulary.barQualifierAdditional + ) + } + + /// Kody rule "Avoid Substring Matching for Canonical Limit/Window Labels" + /// (binding, 2026-10-21): the cadence group reads whole lexical tokens, + /// so "nothourly" is not an hourly window. + func testBarCadenceGroupFallbackMatchesExactTokensNotSubstrings() { + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "", label: "nothourly"), + QuotaWindowVocabulary.otherLimit + ) + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "", label: "unhourlyplan"), + QuotaWindowVocabulary.otherLimit + ) + } + + /// A label that merely contains a cadence word named that window. + /// + /// The canonicalizer reads the label as prose, and prose matching is + /// substring matching, so "notweekly" was a weekly bar and "notdaily" was a + /// daily one (Kody rule 40, binding 2026-10-21). The cadence field is + /// now the only thing that may name a group, and the label resolves a + /// group only through whole tokens. + func testBarCadenceGroupDoesNotReadACadenceWordOutOfTheLabel() { + for label in ["notweekly", "unweeklyplan", "notdaily", "notbilling", "notinterval", "notcycle", "not1d"] { + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "", label: label), + QuotaWindowVocabulary.otherLimit, + "the label '\(label)' names no window and must not resolve a group." + ) + } + } + + /// A label that names the period outright still resolves the group, + /// because the fallback matches whole tokens. + func testBarCadenceGroupStillResolvesALabelThatNamesThePeriod() { + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "", label: "Weekly cap"), + QuotaWindowVocabulary.sevenDay + ) + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "", label: "Billing cycle"), + QuotaWindowVocabulary.oneMonth + ) + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "", label: "Daily cap"), + QuotaWindowVocabulary.oneDay + ) + } + + /// The canonical labels the vocabulary produces must resolve back to + /// themselves when they are handed to the cadence lookup as a token. + /// + /// The widget passes a window's canonical cadence back through + /// `standardCadence(windowToken:label:)`, and an extra allowance is looked + /// up with an empty label, so a canonical label that did not resolve would + /// collapse that row to "Other limit" and lose the period it doubles. + func testCanonicalCadenceLabelsRoundTripThroughTheToken() { + for canonical in [ + QuotaWindowVocabulary.fiveHour, + QuotaWindowVocabulary.oneDay, + QuotaWindowVocabulary.sevenDay, + QuotaWindowVocabulary.oneMonth + ] { + XCTAssertEqual( + QuotaWindowVocabulary.standardCadence(windowToken: canonical, label: ""), + canonical, + "the canonical label '\(canonical)' must resolve back to itself." + ) + } + } + + /// Kody review 2026-10-21: a lone bar printing a cadence word says the + /// same thing twice, so the qualifier is only printed when a sibling bar + /// shares the cadence. + func testLoneCadenceBarPrintsNoQualifierAndSharedCadenceDoes() { + let lone = QuotaWindowVocabulary.barQualifiers(forBars: [ + QuotaWindowVocabulary.BarCaption(cadence: "Weekly", label: "Weekly cap") + ]) + XCTAssertEqual(lone, [""]) + + // The Antigravity weekly bar sits beside a 5-hour bar, a different + // cadence, so neither bar needs a qualifier. + let mixedCadences = QuotaWindowVocabulary.barQualifiers(forBars: [ + QuotaWindowVocabulary.BarCaption(cadence: "5h", label: "5-hour window"), + QuotaWindowVocabulary.BarCaption(cadence: "Weekly", label: "Weekly cap") + ]) + XCTAssertEqual(mixedCadences, ["", ""]) + + // Two 7-day bars share one cadence, so they read "weekly" and + // "additional" (owner-approved, 2026-10-21). + let sharedCadence = QuotaWindowVocabulary.barQualifiers(forBars: [ + QuotaWindowVocabulary.BarCaption(cadence: "7d", label: "Free weekly limit"), + QuotaWindowVocabulary.BarCaption(cadence: "7d", label: "Additional allowance") + ]) + XCTAssertEqual(sharedCadence, [ + QuotaWindowVocabulary.barQualifierWeekly, + QuotaWindowVocabulary.barQualifierAdditional + ]) + + // Two daily bars share one cadence and read the allowance word. + let sharedDaily = QuotaWindowVocabulary.barQualifiers(forBars: [ + QuotaWindowVocabulary.BarCaption(cadence: "Daily", label: "Daily cap"), + QuotaWindowVocabulary.BarCaption(cadence: "Daily", label: "Daily allowance cap") + ]) + XCTAssertEqual(sharedDaily, [ + QuotaWindowVocabulary.barQualifierDaily, + QuotaWindowVocabulary.barQualifierDaily + ]) + } + + /// The same rule, driven by the labels the widget actually renders. + /// + /// A window label is built through `QuotaWindowVocabulary.windowLabel(...)`, + /// which rewrites the prose into the hyphenated cadence copy, so the + /// production shape of a 7-day window is "7-day window" and not "Free + /// weekly limit". The pair the owner approved has to come out of that + /// shape too (Kody review 2026-10-21). + func testProductionLabelsNameTheQualifierFromTheCadence() { + let sharedWeek = QuotaWindowVocabulary.barQualifiers(forBars: [ + QuotaWindowVocabulary.BarCaption(cadence: "7-day", label: "7-day window"), + QuotaWindowVocabulary.BarCaption(cadence: "7-day", label: "Additional allowance") + ]) + XCTAssertEqual(sharedWeek, [ + QuotaWindowVocabulary.barQualifierWeekly, + QuotaWindowVocabulary.barQualifierAdditional + ]) + + // Two windows of different periods are not siblings, so a lone 7-day + // bar prints no qualifier at all. + let loneWeek = QuotaWindowVocabulary.barQualifiers(forBars: [ + QuotaWindowVocabulary.BarCaption(cadence: "7-day", label: "7-day window"), + QuotaWindowVocabulary.BarCaption(cadence: "5-hour", label: "5-hour window") + ]) + XCTAssertEqual(loneWeek, ["", ""]) + } + + /// The cadence group is what decides whether two bars share a cadence, so + /// it has to follow the period rather than the wording. + func testBarCadenceGroupFollowsThePeriod() { + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "Weekly", label: "Weekly cap"), + QuotaWindowVocabulary.sevenDay + ) + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "5h", label: "5-hour window"), + QuotaWindowVocabulary.fiveHour + ) + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "Daily", label: "Daily cap"), + QuotaWindowVocabulary.oneDay + ) + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "1m", label: "Billing cycle"), + QuotaWindowVocabulary.oneMonth + ) + // The window's own cadence wins over the label's prose, because that + // is the cadence the caption token beside the bar prints. A group + // that disagreed with the token gave a bar a qualifier that named a + // period its own token contradicted (Kody review 2026-10-21). + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "5h", label: "Weekly cap"), + QuotaWindowVocabulary.fiveHour + ) + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "7-day", label: "7-day window"), + QuotaWindowVocabulary.sevenDay + ) + // An extra allowance is looked up without its label, so it keeps the + // period it doubles instead of collapsing to "Other limit". + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "7-day", label: "Additional allowance"), + QuotaWindowVocabulary.sevenDay + ) + } + + /// A sanitized Muse Assist wire payload, as the reader files it today. + /// + /// The weekly limit arrives with a "weekly" token. The extra allowance + /// arrives with no token at all and with a label the pipeline has already + /// rewritten to the catch-all "Other limit" copy, so nothing in the label + /// says what kind of window it is. The provider and the missing token are + /// the whole of the signal, which is why the identity has to be carried + /// with the window instead of read back from its label. + private struct MuseAssistWireWindow { + let id: String + let label: String + let window: String? + let providerKey: String + + /// The canonical cadence the widget gives this window. + var canonicalCadence: String { + QuotaWindowVocabulary.standardCadence(windowToken: window, label: label) + } + + /// The label the widget renders for this window. + var displayLabel: String { + QuotaWindowVocabulary.windowLabel(windowToken: window, existingLabel: label) + } + + /// The bar facts the widget's qualifier resolution sees. + var barCaption: QuotaWindowVocabulary.BarCaption { + QuotaWindowVocabulary.BarCaption( + cadence: canonicalCadence, + label: displayLabel, + isExtraAllowance: QuotaWindowVocabulary.isExtraAllowance( + providerKey: providerKey, + label: label, + windowToken: window + ) + ) + } + } + + private let museAssistWeekly = MuseAssistWireWindow( + id: "weekly", + label: "weekly window", + window: "weekly", + providerKey: "muse-assist" + ) + + private let museAssistExtra = MuseAssistWireWindow( + id: "extra", + label: "Other limit window", + window: nil, + providerKey: "muse-assist" + ) + + /// The extra-allowance identity comes from the provider and the wire + /// label, because the rendered label cannot answer it. + func testExtraAllowanceIdentityIsReadFromTheWireNotTheDisplayLabel() { + XCTAssertTrue( + QuotaWindowVocabulary.isExtraAllowance( + providerKey: "muse-assist", + label: "Other limit window", + windowToken: nil + ), + "a tokenless Muse Assist window that names no period is the extra allowance." + ) + XCTAssertFalse( + QuotaWindowVocabulary.isExtraAllowance( + providerKey: "muse-assist", + label: "8h window", + windowToken: "8h" + ), + "a Muse Assist window with a real token is a meter, not extra usage." + ) + XCTAssertFalse( + QuotaWindowVocabulary.isExtraAllowance( + providerKey: "anthropic", + label: "Other limit window", + windowToken: nil + ), + "another provider's unknown window is not the Muse Assist extra allowance." + ) + // A label that names the kind is an extra allowance for any provider. + XCTAssertTrue( + QuotaWindowVocabulary.isExtraAllowance( + providerKey: "anthropic", + label: "Additional allowance", + windowToken: "7d" + ) + ) + } + + /// The owner-approved pair, driven by the real Muse Assist wire shapes. + /// + /// The extra allowance names no period of its own, so it cannot join the + /// weekly's group by itself and the pair never appeared: the weekly bar + /// read no qualifier and the extra bar read none either. It is extra + /// capacity of the period it doubles, so it joins the one sibling that has + /// a period, and the two bars read "weekly" and "additional" (owner, + /// 2026-10-21). + func testMuseAssistExtraAllowanceReadsAdditionalBesideTheWeekly() { + let bars = [museAssistWeekly, museAssistExtra] + + // The wire shapes are what the reader files: the weekly carries a + // token, the extra carries none and its label is the catch-all copy. + XCTAssertEqual(museAssistWeekly.canonicalCadence, QuotaWindowVocabulary.sevenDay) + XCTAssertEqual(museAssistWeekly.displayLabel, "7-day window") + XCTAssertEqual(museAssistExtra.canonicalCadence, QuotaWindowVocabulary.otherLimit) + XCTAssertEqual(museAssistExtra.displayLabel, "Other limit window") + + let qualifiers = QuotaWindowVocabulary.barQualifiers(forBars: bars.map(\.barCaption)) + XCTAssertEqual(qualifiers, [ + QuotaWindowVocabulary.barQualifierWeekly, + QuotaWindowVocabulary.barQualifierAdditional + ]) + + // The caption row is the token beside the qualifier, and the extra + // allowance keeps the "+" marker the owner uses for extra quota. + let weeklyToken = QuotaWindowVocabulary.popoverCaption( + windowToken: museAssistWeekly.canonicalCadence, + label: museAssistWeekly.displayLabel + ) + let extraToken = QuotaWindowVocabulary.popoverCaption( + windowToken: museAssistExtra.canonicalCadence, + label: museAssistExtra.displayLabel + ) + XCTAssertEqual(weeklyToken, "7d") + XCTAssertEqual(extraToken, "+") + XCTAssertEqual("\(weeklyToken) \(qualifiers[0])", "7d weekly") + XCTAssertEqual("\(extraToken) \(qualifiers[1])", "+ additional") + } + + /// The sibling has to be unambiguous: with two different periods beside + /// it, an extra allowance cannot know which one it doubles, so it stays on + /// its own and neither bar prints a qualifier. + func testExtraAllowanceDoesNotJoinWhenItsSiblingsDisagree() { + let qualifiers = QuotaWindowVocabulary.barQualifiers(forBars: [ + QuotaWindowVocabulary.BarCaption(cadence: "7-day", label: "7-day window"), + QuotaWindowVocabulary.BarCaption(cadence: "5-hour", label: "5-hour window"), + QuotaWindowVocabulary.BarCaption( + cadence: QuotaWindowVocabulary.otherLimit, + label: "Other limit window", + isExtraAllowance: true + ) + ]) + XCTAssertEqual(qualifiers, ["", "", ""]) + } + + /// An extra allowance on its own still prints no qualifier: there is no + /// sibling to tell it apart from. + func testLoneExtraAllowancePrintsNoQualifier() { + let qualifiers = QuotaWindowVocabulary.barQualifiers(forBars: [ + QuotaWindowVocabulary.BarCaption( + cadence: QuotaWindowVocabulary.otherLimit, + label: "Other limit window", + isExtraAllowance: true + ) + ]) + XCTAssertEqual(qualifiers, [""]) + } + + /// An extra allowance names no period of its own, so its label is left out + /// of the group lookup: a label that happens to name a period must not + /// give the extra allowance a group of its own, or it stops joining the + /// sibling it doubles (Kody review 2026-10-21). + func testExtraAllowanceLabelIsLeftOutOfTheGroupLookup() { + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup( + cadence: QuotaWindowVocabulary.otherLimit, + label: "7-day window", + isExtraAllowance: true + ), + QuotaWindowVocabulary.otherLimit, + "an extra allowance carries no period of its own, so its label does not name one." + ) + // The same label on a bar that is not an extra allowance does name the + // period: the rule is about the extra allowance, not about the label. + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup( + cadence: QuotaWindowVocabulary.otherLimit, + label: "7-day window" + ), + QuotaWindowVocabulary.sevenDay + ) + } + + /// The group a bar belongs to and the caption token beside it have to name + /// the same period: a bar that prints "7d" is grouped as the 7-day group + /// and not as "Other limit", so it is not split from the sibling that + /// prints the same token (Kody review 2026-10-21). + func testTheGroupAndTheCaptionTokenNameTheSamePeriod() { + let cases: [(label: String, group: String, token: String)] = [ + ("7-day window", QuotaWindowVocabulary.sevenDay, "7d"), + ("5-hour window", QuotaWindowVocabulary.fiveHour, "5h"), + ("1-day window", QuotaWindowVocabulary.oneDay, "1d"), + ("1-month window", QuotaWindowVocabulary.oneMonth, "1m") + ] + for testCase in cases { + XCTAssertEqual( + QuotaWindowVocabulary.barCadenceGroup(cadence: "", label: testCase.label), + testCase.group, + "the canonical label '\(testCase.label)' names its period." + ) + XCTAssertEqual( + QuotaWindowVocabulary.popoverCaption(windowToken: "", label: testCase.label), + testCase.token, + "the caption token beside '\(testCase.label)' names the same period." + ) + } + } } diff --git a/ios/CodeCapsCompanion/App/Models/CompanionQuotaModel.swift b/ios/CodeCapsCompanion/App/Models/CompanionQuotaModel.swift index d5c2c64f..a70ccb98 100644 --- a/ios/CodeCapsCompanion/App/Models/CompanionQuotaModel.swift +++ b/ios/CodeCapsCompanion/App/Models/CompanionQuotaModel.swift @@ -3,6 +3,10 @@ import Security import SwiftUI import UserNotifications +#if os(iOS) +import UIKit +#endif + public struct CompanionInAppAlert: Identifiable, Equatable { public let id: String public let title: String @@ -17,6 +21,36 @@ public enum CompanionSourceScope: String, CaseIterable, Identifiable, Sendable { case allSources = "All Sources" public var id: String { rawValue } + + /// The label the source-scope picker shows. + /// + /// The stored raw value still says "This iPhone" because that is what it + /// always was and it is what a saved preference is keyed on. The app + /// ships for iPhone and iPad, and the label on screen has to name the + /// device the reader is actually holding. + public var displayTitle: String { + title(for: Self.currentDeviceFamily) + } + + /// The device word the picker label uses: "iPhone", "iPad" or "Mac". + public static var currentDeviceFamily: String { + #if os(iOS) + return UIDevice.current.userInterfaceIdiom == .pad ? "iPad" : "iPhone" + #else + return "Mac" + #endif + } + + /// The label for a given device family. + /// + /// It is separate from the idiom lookup so the wording can be checked + /// without a device. + public func title(for family: String) -> String { + switch self { + case .thisIPhone: return "This \(family)" + case .allSources: return rawValue + } + } } #if canImport(WidgetKit) import WidgetKit diff --git a/ios/CodeCapsCompanion/App/Views/CompanionContentView.swift b/ios/CodeCapsCompanion/App/Views/CompanionContentView.swift index c1752556..59631064 100644 --- a/ios/CodeCapsCompanion/App/Views/CompanionContentView.swift +++ b/ios/CodeCapsCompanion/App/Views/CompanionContentView.swift @@ -8,7 +8,7 @@ public struct CompanionContentView: View { @Environment(\.scenePhase) private var scenePhase @State private var showingSettings: Bool @State private var expandedIds: Set = [] - @State private var settingsTab: CompanionSettingsTab = .display + @State private var settingsTab: CompanionSettingsTab #if os(iOS) @State private var platformOrderEditMode: EditMode = .inactive #endif @@ -24,7 +24,13 @@ public struct CompanionContentView: View { public init(model: CompanionQuotaModel, showingSettings: Bool = ProcessInfo.processInfo.arguments.contains("-openSettings")) { self.model = model self._showingSettings = State(initialValue: showingSettings) - self._settingsTab = State(initialValue: ProcessInfo.processInfo.arguments.contains("-openAlerts") ? .alarms : .display) + // A launch argument picks the tab the settings sheet opens on, so the + // screenshot capture can reach a tab without a tap. Both spellings + // are accepted: -openAlarms is the capture's, and -openAlerts is + // the one the alert work introduced for the same tab. + let opensAlarms = ProcessInfo.processInfo.arguments.contains("-openAlarms") + || ProcessInfo.processInfo.arguments.contains("-openAlerts") + self._settingsTab = State(initialValue: opensAlarms ? .alarms : .display) #if os(iOS) self._platformOrderEditMode = State( initialValue: ProcessInfo.processInfo.arguments.contains("-reorderPlatforms") ? .active : .inactive @@ -189,7 +195,7 @@ public struct CompanionContentView: View { private var headerCard: some View { Picker("Source scope", selection: $model.sourceScope) { ForEach(CompanionSourceScope.allCases) { scope in - Text(scope.rawValue).tag(scope) + Text(scope.displayTitle).tag(scope) } } .pickerStyle(.segmented) diff --git a/ios/CodeCapsCompanion/CodeCapsCompanion.xcodeproj/project.pbxproj b/ios/CodeCapsCompanion/CodeCapsCompanion.xcodeproj/project.pbxproj index bdd18d8d..52aa215c 100644 --- a/ios/CodeCapsCompanion/CodeCapsCompanion.xcodeproj/project.pbxproj +++ b/ios/CodeCapsCompanion/CodeCapsCompanion.xcodeproj/project.pbxproj @@ -32,9 +32,11 @@ 31CFC3813E00700F4A1D0E2F /* ResetAlarmCadence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B76200EBE871E29BAC8A41B /* ResetAlarmCadence.swift */; }; 33D335245DED8229722048BA /* WidgetPlatformDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F3FDD012180112242E80DF3 /* WidgetPlatformDisplay.swift */; }; 346906A2DDABDF60AF9D4F62 /* alarm-submarine.wav in Resources */ = {isa = PBXBuildFile; fileRef = 5F63EE805947E5707E2A0C3F /* alarm-submarine.wav */; }; + 3AB7A6CDA67C38224B7CBE08 /* QuotaWindowCaption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCA8CD80D52183E869EAE7A /* QuotaWindowCaption.swift */; }; 3C07D53C4EFEE8F91EEDDEC7 /* ResetAlarmCadence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B76200EBE871E29BAC8A41B /* ResetAlarmCadence.swift */; }; 41F6AE401AE48C11EDDA4B80 /* SentryTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C98744F4C98CE0C6227834 /* SentryTelemetry.swift */; }; 4A84751C143F11619F941B8C /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 897AB651A949F8B5454F5C3C /* Sentry */; }; + 4BBB6BFF85C4F547B32A3035 /* QuotaWindowCaption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCA8CD80D52183E869EAE7A /* QuotaWindowCaption.swift */; }; 4D9258D010AD16F059277D93 /* alarm-submarine.wav in Resources */ = {isa = PBXBuildFile; fileRef = 5F63EE805947E5707E2A0C3F /* alarm-submarine.wav */; }; 4F76A47C0A1C84A3525F004C /* CompanionReadTokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E7C693CD5357E0391881F0 /* CompanionReadTokenStore.swift */; }; 500162E6C6D0CFA94D715BD0 /* FleetSourceDisagreement.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A7E538557D6D936242BE41 /* FleetSourceDisagreement.swift */; }; @@ -136,6 +138,7 @@ /* Begin PBXFileReference section */ 0638A6007D4E7A392FD7D595 /* alarm-blow.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = "alarm-blow.wav"; sourceTree = ""; }; 0A40D20353A8D6C1D9E9D349 /* WidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetViews.swift; sourceTree = ""; }; + 0CCA8CD80D52183E869EAE7A /* QuotaWindowCaption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = QuotaWindowCaption.swift; path = ../../Sources/QuotaCore/QuotaWindowCaption.swift; sourceTree = ""; }; 10FF587176549AAC4A616E3B /* InfoMac.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = InfoMac.plist; sourceTree = ""; }; 13B67B47EAC3ED255FDB0018 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 154D17A5A492536F43729222 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; @@ -255,6 +258,7 @@ children = ( D7E7C693CD5357E0391881F0 /* CompanionReadTokenStore.swift */, D4A7E538557D6D936242BE41 /* FleetSourceDisagreement.swift */, + 0CCA8CD80D52183E869EAE7A /* QuotaWindowCaption.swift */, 1B93F39BFE99C5CA6EB141F5 /* QuotaWindowVocabulary.swift */, 6B76200EBE871E29BAC8A41B /* ResetAlarmCadence.swift */, 5ECFD3E9DC2728ACB5FB5488 /* ResetAlarmMessage.swift */, @@ -544,6 +548,7 @@ 170FA7844615930B5A1D4CBD /* CodeCapsWidgetBundle.swift in Sources */, 6AF2CF64E8C9F39D87C4B24C /* CompanionReadTokenStore.swift in Sources */, F4FCB18D25C83B7AAD11C565 /* FleetSourceDisagreement.swift in Sources */, + 3AB7A6CDA67C38224B7CBE08 /* QuotaWindowCaption.swift in Sources */, 28EECA14D0CD8CE69B1250B6 /* QuotaWindowVocabulary.swift in Sources */, 3C07D53C4EFEE8F91EEDDEC7 /* ResetAlarmCadence.swift in Sources */, 0FB40BD33AB5375F6C02ACFC /* WidgetConfigurationIntent.swift in Sources */, @@ -563,6 +568,7 @@ EA984A6E75520CAB2639A8B4 /* CodeCapsWidgetBundle.swift in Sources */, E8DE0A145EA5D76CD51886E8 /* CompanionReadTokenStore.swift in Sources */, 80AFBDA4491ADD0F917DEF11 /* FleetSourceDisagreement.swift in Sources */, + 4BBB6BFF85C4F547B32A3035 /* QuotaWindowCaption.swift in Sources */, 8A473847ACFCA158CD1E9386 /* QuotaWindowVocabulary.swift in Sources */, BBE95186FD35EC37DBF13D51 /* ResetAlarmCadence.swift in Sources */, A90E0EF383B2CDF27263BAF0 /* WidgetConfigurationIntent.swift in Sources */, diff --git a/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift b/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift index dbc63c6b..27797801 100644 --- a/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift +++ b/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift @@ -14,6 +14,27 @@ public struct WidgetWindowItem: Identifiable, Equatable, Sendable { public let isExhausted: Bool public let isMasked: Bool public let observedAt: Date? + /// True when the window is the extra allowance of another window on the + /// same plan. The label cannot answer this: an extra allowance is + /// rewritten into the same "Other limit" copy every unknown window gets, + /// so the identity travels with the window. + public let isExtraAllowance: Bool + /// Short distinguishing label for a bar when two bars share one cadence + /// token (e.g. two "7d" bars). "Free weekly limit" reads "weekly", + /// "Additional tokens" reads "additional". Empty when the row needs no + /// qualifier. + /// + /// Owner 2026-10-21: the qualifier previously returned the hyphenated + /// cadence form ("7-day", "5-hour", "1-month") and sat beside the + /// cadence token in the same caption row, which made a `7d 7-day` + /// duplicate on the Antigravity weekly row. Short nouns that do not + /// repeat the cadence are kept here, so two 7-day bars read "weekly" / + /// "additional" (owner, 2026-10-21). + /// + /// The qualifier is resolved by the platform item that owns the sibling + /// windows, because a lone bar printing "7d weekly" says the same thing + /// twice (owner 2026-10-21). + public let barQualifier: String public init( id: String, @@ -23,7 +44,9 @@ public struct WidgetWindowItem: Identifiable, Equatable, Sendable { resetAt: Date?, isExhausted: Bool = false, isMasked: Bool = false, - observedAt: Date? = nil + observedAt: Date? = nil, + barQualifier: String = "", + isExtraAllowance: Bool = false ) { self.id = id self.label = label @@ -33,6 +56,8 @@ public struct WidgetWindowItem: Identifiable, Equatable, Sendable { self.isExhausted = isExhausted self.isMasked = isMasked self.observedAt = observedAt + self.barQualifier = barQualifier + self.isExtraAllowance = isExtraAllowance } private static let staleAfter: TimeInterval = 30 * 60 @@ -59,18 +84,34 @@ public struct WidgetWindowItem: Identifiable, Equatable, Sendable { WidgetPresentation.shortCadence(cadence: cadence, label: label) } - /// Short distinguishing label for a bar when two bars share one cadence - /// token (e.g. two "7d" bars). "Free weekly limit" reads "weekly", - /// "Additional tokens" reads "additional". Empty when the window label - /// carries no such qualifier. - public var barQualifier: String { - if QuotaWindowVocabulary.isExtraAllowanceLabel(label) { return QuotaWindowVocabulary.otherLimit } - let text = label.lowercased() - if text.contains("weekly") { return QuotaWindowVocabulary.sevenDay } - if text.contains("monthly") || text.contains("billing") { return QuotaWindowVocabulary.oneMonth } - if text.contains("daily") { return "daily" } - if text.contains("hourly") { return QuotaWindowVocabulary.fiveHour } - return "" + /// The caption this window's bar prints. + /// + /// The caption carries the qualifier, so a surface that renders it cannot leave + /// the qualifier out: a caption built from the window and a caption built + /// without it are different values, and only the first one names the sibling. + public var caption: QuotaWindowCaption { + QuotaWindowCaption( + token: cadenceToken, + qualifier: barQualifier, + resetCaption: resetCaption() + ) + } + + /// A copy of this window that prints the given qualifier in its caption + /// row. The platform item calls this once it knows the sibling windows. + func withBarQualifier(_ qualifier: String) -> WidgetWindowItem { + WidgetWindowItem( + id: id, + label: label, + cadence: cadence, + remainingPercent: remainingPercent, + resetAt: resetAt, + isExhausted: isExhausted, + isMasked: isMasked, + observedAt: observedAt, + barQualifier: qualifier, + isExtraAllowance: isExtraAllowance + ) } /// `Resets in 12d 22m`, or an empty string when the provider reports no reset. @@ -136,11 +177,39 @@ public struct WidgetPlatformItem: Identifiable, Equatable, Sendable { self.resetAt = resetAt self.isExhausted = isExhausted self.isMasked = isMasked - self.windows = windows + self.windows = WidgetPlatformItem.windowsResolvingBarQualifiers(windows) self.iconHint = iconHint self.defaultWindowLabel = defaultWindowLabel } + /// Resolve every window's caption qualifier against its siblings. + /// + /// The qualifier is a cadence word, and a cadence word is only worth + /// printing when another bar in the same platform shares the cadence: on + /// a lone bar the widget printed "7d weekly", which says the same thing + /// twice. The resolution lives here, where the sibling windows are + /// known, and not on the single window (owner 2026-10-21). + private static func windowsResolvingBarQualifiers(_ windows: [WidgetWindowItem]) -> [WidgetWindowItem] { + guard windows.count > 1 else { return windows } + let qualifiers = QuotaWindowVocabulary.barQualifiers( + forBars: windows.map { + QuotaWindowVocabulary.BarCaption( + cadence: $0.cadence, + label: $0.label, + isExtraAllowance: $0.isExtraAllowance + ) + } + ) + return windows.indices.map { index in + // A caller that named the qualifier keeps it. The sibling + // resolution only fills in a qualifier the caller left blank, so + // the public `barQualifier:` parameter does not silently vanish on + // a platform with two or more windows (Kody review 2026-10-21). + let qualifier = qualifiers[index].isEmpty ? windows[index].barQualifier : qualifiers[index] + return windows[index].withBarQualifier(qualifier) + } + } + public var displayPercent: String { WidgetPresentation.displayPercent(percent: remainingPercent, isMasked: isMasked) } @@ -583,6 +652,16 @@ public enum WidgetPresentation { existingLabel: w.label.isEmpty ? cadence : w.label, resetAt: parsedReset ) + // The extra-allowance identity is read off the wire label, not + // off the display label: the display copy rewrites an extra + // allowance into the same "Other limit" text every unknown + // window gets, so by the time the qualifier is resolved the + // difference is gone. + let isExtraAllowance = QuotaWindowVocabulary.isExtraAllowance( + providerKey: providerKey, + label: w.label, + windowToken: w.window + ) let cadenceKey = cadence.lowercased() if !seenCadenceKeys.contains(cadenceKey) { @@ -595,7 +674,8 @@ public enum WidgetPresentation { resetAt: parsedReset, isExhausted: exhausted, isMasked: false, - observedAt: observed + observedAt: observed, + isExtraAllowance: isExtraAllowance )) } } diff --git a/ios/CodeCapsCompanion/Widgets/WidgetViews.swift b/ios/CodeCapsCompanion/Widgets/WidgetViews.swift index 5e0f1a94..5aa8547e 100644 --- a/ios/CodeCapsCompanion/Widgets/WidgetViews.swift +++ b/ios/CodeCapsCompanion/Widgets/WidgetViews.swift @@ -547,7 +547,10 @@ struct ProviderFocusView: View { .lineLimit(2) .minimumScaleFactor(0.85) } else { - WindowCaptionRow(token: captionToken, resetCaption: captionReset, font: 9) + WindowCaptionRow( + caption: .withoutQualifier(token: captionToken, resetCaption: captionReset), + font: 9 + ) } } .padding(12) @@ -640,7 +643,10 @@ struct ProviderFocusView: View { percentFont: 11 ) } else if windowsToShow.isEmpty { - WindowCaptionRow(token: captionToken, resetCaption: captionReset, font: 9.5) + WindowCaptionRow( + caption: .withoutQualifier(token: captionToken, resetCaption: captionReset), + font: 9.5 + ) } else { ForEach(windowsToShow) { win in VStack(alignment: .leading, spacing: 2) { @@ -660,8 +666,7 @@ struct ProviderFocusView: View { height: 3.5 ) WindowCaptionRow( - token: win.cadenceToken, - resetCaption: win.resetCaption(), + caption: win.caption, font: 9 ) } @@ -717,8 +722,7 @@ struct AccessoryView: View { .font(.system(size: 12, weight: .bold, design: .rounded)) } WindowCaptionRow( - token: atCap.cadenceToken, - resetCaption: atCap.resetCaption(), + caption: atCap.caption, font: 9 ) } @@ -772,14 +776,12 @@ struct AccessoryView: View { } HStack(spacing: 4) { WindowCaptionRow( - token: pair.window1.cadenceToken, - resetCaption: pair.window1.resetCaption(), + caption: pair.window1.caption, font: 8.5 ) .frame(maxWidth: .infinity) WindowCaptionRow( - token: pair.window2.cadenceToken, - resetCaption: pair.window2.resetCaption(), + caption: pair.window2.caption, font: 8.5 ) .frame(maxWidth: .infinity) @@ -798,9 +800,14 @@ struct AccessoryView: View { } MiniProgressBar(fraction: platform.progressFraction, color: .primary, elapsedFraction: platform.elapsedFraction, height: 4) WindowCaptionRow( - token: platform.controllingWindow(entry.windowPick)?.cadenceToken - ?? WidgetPresentation.shortCadence(cadence: platform.subtitle, label: platform.title), - resetCaption: platform.controllingWindow(entry.windowPick)?.resetCaption() ?? platform.resetCaption(), + caption: platform.controllingWindow(entry.windowPick)?.caption + ?? .withoutQualifier( + token: WidgetPresentation.shortCadence( + cadence: platform.subtitle, + label: platform.title + ), + resetCaption: platform.resetCaption() + ), font: 9 ) } diff --git a/ios/CodeCapsCompanion/Widgets/WidgetWindowRows.swift b/ios/CodeCapsCompanion/Widgets/WidgetWindowRows.swift index 8c92e3a5..7de37b60 100644 --- a/ios/CodeCapsCompanion/Widgets/WidgetWindowRows.swift +++ b/ios/CodeCapsCompanion/Widgets/WidgetWindowRows.swift @@ -61,26 +61,28 @@ struct CodeCapsMarkView: View { /// 3h 12m". Both halves are optional — a window with no reset shows the token /// alone, and one with an unknown cadence shows the reset alone. struct WindowCaptionRow: View { - let token: String - let resetCaption: String + /// What the caption says. + /// + /// It is a value and not three arguments, because the qualifier used to be an + /// argument with a default: a surface that rendered a window's caption could + /// leave it out, and the omission showed up only in a review. A caption now + /// carries its qualifier, and one built from a window carries that window's. + let caption: QuotaWindowCaption var font: CGFloat = 9 - /// Short distinguishing label shown after the token when two bars share - /// one cadence (e.g. "weekly" / "additional" for two "7d" bars). - var qualifier: String = "" var body: some View { HStack(spacing: 4) { - if !token.isEmpty || !qualifier.isEmpty { + if !caption.token.isEmpty || !caption.qualifier.isEmpty { HStack(spacing: 3) { - if !token.isEmpty { - Text(token) + if !caption.token.isEmpty { + Text(caption.token) .font(.system(size: font, weight: .semibold, design: .rounded)) .foregroundColor(.secondary) .lineLimit(1) .fixedSize() } - if !qualifier.isEmpty { - Text(qualifier) + if !caption.qualifier.isEmpty { + Text(caption.qualifier) .font(.system(size: font, weight: .medium)) .foregroundColor(.secondary.opacity(0.8)) .lineLimit(1) @@ -89,8 +91,8 @@ struct WindowCaptionRow: View { } } Spacer(minLength: 0) - if !resetCaption.isEmpty { - Text(resetCaption) + if !caption.resetCaption.isEmpty { + Text(caption.resetCaption) .font(.system(size: font, weight: .medium, design: .monospaced)) .foregroundColor(.secondary) .lineLimit(1) @@ -147,10 +149,8 @@ struct PlanAtCapRow: View { } WindowCaptionRow( - token: captionToken, - resetCaption: window.resetCaption(), - font: captionFont, - qualifier: window.barQualifier + caption: window.caption, + font: captionFont ) } .accessibilityElement(children: .combine) @@ -258,8 +258,7 @@ struct PlanBarRow: View { ) WindowCaptionRow( - token: captionToken, - resetCaption: captionReset, + caption: .withoutQualifier(token: captionToken, resetCaption: captionReset), font: captionFont ) } @@ -352,18 +351,14 @@ struct PlanDualBarRow: View { // Dual captions HStack(spacing: columnSpacing) { WindowCaptionRow( - token: window1.cadenceToken, - resetCaption: window1.resetCaption(), - font: captionFont, - qualifier: window1.barQualifier + caption: window1.caption, + font: captionFont ) .frame(maxWidth: .infinity) WindowCaptionRow( - token: window2.cadenceToken, - resetCaption: window2.resetCaption(), - font: captionFont, - qualifier: window2.barQualifier + caption: window2.caption, + font: captionFont ) .frame(maxWidth: .infinity) } diff --git a/ios/CodeCapsCompanion/project.yml b/ios/CodeCapsCompanion/project.yml index acb49831..5ef23395 100644 --- a/ios/CodeCapsCompanion/project.yml +++ b/ios/CodeCapsCompanion/project.yml @@ -111,6 +111,8 @@ targets: group: Shared - path: ../../Sources/QuotaCore/QuotaWindowVocabulary.swift group: Shared + - path: ../../Sources/QuotaCore/QuotaWindowCaption.swift + group: Shared - path: ../../Sources/QuotaCore/FleetSourceDisagreement.swift group: Shared - path: ../../Sources/QuotaCore/WidgetPlatformDisplay.swift @@ -209,6 +211,8 @@ targets: group: Shared - path: ../../Sources/QuotaCore/QuotaWindowVocabulary.swift group: Shared + - path: ../../Sources/QuotaCore/QuotaWindowCaption.swift + group: Shared - path: ../../Sources/QuotaCore/FleetSourceDisagreement.swift group: Shared - path: ../../Sources/QuotaCore/WidgetPlatformDisplay.swift diff --git a/scripts/ios-enable-apns.mjs b/scripts/ios-enable-apns.mjs index bfae13ef..f62d6ce5 100644 --- a/scripts/ios-enable-apns.mjs +++ b/scripts/ios-enable-apns.mjs @@ -3,6 +3,8 @@ import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { resolve } from 'node:path'; +import { clientEnvironment, normalizePrivateKey } from './ios-fleet/asc-key.mjs'; + export const bundleIdentifier = 'com.simplewithus.codecaps.ios'; export const pushProfileName = 'CodeCaps Companion AppStore'; @@ -166,16 +168,67 @@ function inspectProductionProfile(encoded) { && xml.includes(`.${bundleIdentifier}`); } -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +// The credential the App Store Connect client runs with. +// +// The client resolves its credential from the process environment, from +// fleet-secrets and from the bootstrap Keychain. A path on disk is not an +// approved secret source (Kody rule 42), so this wrapper never reads one: +// ASC_KEY_PATH is not a credential source here, and the key is taken from +// the environment and handed to the child in the child's environment. +// +// The key never reaches a command line, a log, the environment file or a +// temporary file. It does reach the child's environment, where the client +// looks for it, and the environment of a same-user process is readable on +// this platform (`ps eww`, `ps -E`): the handoff keeps the key out of argv, +// out of stdout and out of every log line, and claims nothing more. +// +// A key that is set but is not a PEM private key is refused here, with the +// variable named. A silent drop would leave the child without a credential +// and the operator with a permission message that says nothing about the +// input that was wrong. +export function credentialEnvironment(env = process.env) { + const raw = typeof env.ASC_KEY_P8 === 'string' ? env.ASC_KEY_P8 : ''; + if (!raw.trim()) return {}; + const pem = normalizePrivateKey(raw); + if (!pem) throw new Error('ASC_KEY_P8 is set but does not hold a PEM private key'); + return { ASC_KEY_P8: pem }; +} + +// The child's environment. +// +// It is an allowlist, not a copy of this process's environment: a wholesale +// copy forwards every credential on the operator's machine, including the +// ones that have nothing to do with App Store Connect. +export function childEnvironment(env = process.env) { + return clientEnvironment(env, credentialEnvironment(env)); +} + +async function run() { const client = fileURLToPath(new URL('./ios-fleet/asc-api.mjs', import.meta.url)); + // The child gets the credential and the variables it needs, and nothing + // else. The credential is in the child's environment because that is where + // the client looks for it. + let childEnv; + try { + childEnv = childEnvironment(); + } catch (error) { + console.error(`ios-enable-apns: ${error.message}`); + process.exitCode = 1; + return; + } const request = async (method, path, body) => { const args = [client, method, path]; if (body) args.push(JSON.stringify(body)); try { return JSON.parse(execFileSync(process.execPath, args, { - encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 8 * 1024 * 1024 + encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 8 * 1024 * 1024, env: childEnv })); } catch (error) { + // The client's own diagnostic names what it could not resolve, so it + // is carried over instead of being replaced by a generic permission + // message that points at nothing. + const stderrText = error.stderr?.toString() ?? ''; + const auth = stderrText.split('\n').filter(line => line.startsWith('asc-auth:')).join(' '); let codes = ''; try { const failure = JSON.parse(error.stdout?.toString() ?? '{}'); @@ -185,7 +238,8 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur return `${item.code}: ${item.title}`; }).filter(Boolean).join('; '); } catch { /* Keep profile contents and credential material out of errors. */ } - throw new Error(`Apple provisioning request failed: ${method} ${path.split('?')[0]}.${codes ? ' ' + codes : ' Check ASC credential access and API permissions.'}`); + const reason = auth || codes || 'Check ASC credential access and API permissions.'; + throw new Error(`Apple provisioning request failed: ${method} ${path.split('?')[0]}. ${reason}`); } }; const checkOnly = process.argv.includes('--check'); @@ -194,3 +248,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur else console.log(`CodeCaps Push Notifications enabled; verified profile: ${result.profile}.`); }).catch(error => { console.error(error.message); process.exitCode = 1; }); } + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + run(); +} diff --git a/scripts/ios-enable-apns.test.mjs b/scripts/ios-enable-apns.test.mjs index cdbf5bdb..cc21522c 100644 --- a/scripts/ios-enable-apns.test.mjs +++ b/scripts/ios-enable-apns.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { provisionPush, pushProfileName } from './ios-enable-apns.mjs'; +import { generateKeyPairSync } from 'node:crypto'; +import { childEnvironment, credentialEnvironment, provisionPush, pushProfileName } from './ios-enable-apns.mjs'; function profile(id, { name = pushProfileName, state = 'INVALID', content = 'production-profile' } = {}) { return { id, attributes: { name, profileState: state, profileType: 'IOS_APP_STORE', profileContent: content } }; @@ -144,3 +145,44 @@ test('an invalid replacement profile is rejected rather than accepted as ready', await assert.rejects(provisionPush(request, () => false), /preserve it and do not create another/); assert.equal(f.writes.filter(write => write.path === '/v1/profiles').length, 1); }); + +// The credential the consumer hands to the App Store Connect client. +// +// The private key is a secret, and the approved sources are the process +// environment, a captured `fleet-secrets get NAME` and bootstrap-only +// Keychain credentials. A caller-supplied file is not one of them, so +// nothing here reads a key file. + +test('the credential comes from the environment, and a path is not a source', () => { + // A throwaway key, generated here and never committed. + const key = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }).privateKey + .export({ type: 'pkcs8', format: 'pem' }); + const environment = credentialEnvironment({ ASC_KEY_P8: key }); + assert.deepEqual(Object.keys(environment), ['ASC_KEY_P8']); + // A path on disk is not an approved source, so it is not read. + assert.deepEqual(credentialEnvironment({ ASC_KEY_PATH: '/tmp/whatever.p8' }), {}); + // Nothing in the environment means nothing to hand over: the client + // resolves the credential from fleet-secrets on its own. + assert.deepEqual(credentialEnvironment({}), {}); +}); + +test('a key that is set but is not a key is refused with the variable named', () => { + // A silent drop would leave the child without a credential and the + // operator with a permission message that points at nothing. + assert.throws( + () => credentialEnvironment({ ASC_KEY_P8: 'not a private key at all' }), + /ASC_KEY_P8 is set but does not hold a PEM private key/ + ); +}); + +test('the child environment is an allowlist, not a copy of the parent environment', () => { + const child = childEnvironment({ + PATH: '/usr/bin', + HOME: '/tmp/home', + ASC_KEY_ID: 'SYNTHETICKEYID1', + ASC_ISSUER_ID: 'synthetic-issuer', + UNRELATED_RUNTIME_SECRET: 'unrelated-runtime-secret-4f1c9a' + }); + assert.equal(child.UNRELATED_RUNTIME_SECRET, undefined, 'an unrelated secret reached the child'); + assert.deepEqual(Object.keys(child).sort(), ['ASC_ISSUER_ID', 'ASC_KEY_ID', 'HOME', 'PATH']); +}); diff --git a/scripts/ios-fleet/appstore-connect.env.example b/scripts/ios-fleet/appstore-connect.env.example index 6c1fa653..0babe853 100644 --- a/scripts/ios-fleet/appstore-connect.env.example +++ b/scripts/ios-fleet/appstore-connect.env.example @@ -11,7 +11,12 @@ ASC_KEY_ID=XXXXXXXXXX ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # Path to a 0600 file holding the .p8 (CI stages one in the runner temp dir). +# ship-testflight.sh reads this file and hands the key to asc-api.mjs in +# ASC_KEY_P8: the client never reads a path, because a path on disk is not an +# approved secret source. ASC_KEY_PATH=/path/to/AuthKey_XXXXXXXXXX.p8 +# The .p8 contents themselves. asc-api.mjs takes the key from here. +# ASC_KEY_P8="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" # Optional Apple ID path (only if not using API key): # APPLE_ID=you@example.com diff --git a/scripts/ios-fleet/asc-api.mjs b/scripts/ios-fleet/asc-api.mjs index f5555856..ad2485f5 100644 --- a/scripts/ios-fleet/asc-api.mjs +++ b/scripts/ios-fleet/asc-api.mjs @@ -5,10 +5,17 @@ * * Auth: resolves credentials from the first source that is present * (never prints values): - * 1. ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH in the environment - * 2. APPLE_API_KEY_ID / APPLE_API_ISSUER_ID / APPLE_API_KEY_P8_BASE64 - * (hosted ios-ship / Infisical; p8 is base64, decoded to a 0600 temp - * file and removed on exit) + * 1. APPLE_API_KEY_ID / APPLE_API_ISSUER_ID / APPLE_API_KEY_P8_BASE64 + * (hosted ios-ship / Infisical; p8 is base64) + * 2. ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_P8 in the environment + * 3. Fleet-OPS prod /asc through fleet-secrets + * + * A caller-supplied file is NOT a credential source (Kody rule 42, PR #271): + * the private key is a secret, and the approved sources are the process + * environment, a captured `fleet-secrets get NAME` and bootstrap-only Keychain + * credentials. The shell layer that owns the credential reads a staged key + * file and hands the key over in the environment, so ASC_KEY_PATH never + * reaches this client as a path to read. * * Usage: * node asc-api.mjs GET /v1/apps @@ -26,6 +33,94 @@ import { createHash, createSign } from "node:crypto"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; +import { normalizePrivateKey } from "./asc-key.mjs"; + +// A failure that already knows what to say. +// +// A guard deep in the call stack cannot return from main(), so it throws this +// instead. The handler at the bottom of the file prints the message on its +// own, without a stack trace, and keeps the exit code the failure set. +class NamedFailure extends Error {} + +// The fleet's masked key preview: the first eight and the last four +// characters, so a key can be identified without any of it being published. +// +// This is the owner's rule of 2026-07-16 and the same shape the other fleet +// apps use (maskApiKeyPreview, buildKeyPreview). It exists because there is +// otherwise no way to tell which of several keys is the one in play, and +// pasting a value to find out is never the answer. +// +// The preview is taken from the key material rather than from the PEM +// envelope: every .p8 opens "-----BEGIN" and ends "-----END PRIVATE KEY-----\n", +// so previewing the envelope prints the same eight and four characters for +// every key and identifies none of them. The text is trimmed first, because +// the key as stored ends with a newline and a preview carrying one splits the +// diagnostic into two lines, of which ship-testflight.sh keeps only the first. +function maskKeyPreview(value) { + if (typeof value !== "string") { + return "(too short to preview)"; + } + const material = value.replace(/-----[^-]+-----/g, "").trim(); + if (material.length < 12) { + return "(too short to preview)"; + } + return `${material.slice(0, 8)}…${material.slice(-4)}`; +} + +// The device families a version has to carry a screenshot in. +// +// App Store Connect wants a screenshot for every device family the app runs +// on, and the iOS app runs on iPhone and iPad. The requirement is counted +// per family rather than per display type because either iPhone size and +// either iPad size satisfies it, and a total across both is not enough: three +// iPhone images and an empty iPad set would pass a total of six and still be +// refused. +const REQUIRED_SCREENSHOT_FAMILIES = ["iphone", "ipad"]; + +// The same requirement for a macOS version, which carries desktop +// screenshots and no iPhone or iPad set at all. A macOS submission asked +// for the iOS families would never be ready. +const REQUIRED_SCREENSHOT_FAMILIES_MAC = ["desktop"]; + +// Which device family a screenshot display type belongs to. +const DISPLAY_TYPE_FAMILY = { + APP_IPHONE_67: "iphone", + APP_IPHONE_65: "iphone", + APP_IPAD_PRO_129: "ipad", + APP_IPAD_PRO_3GEN_129: "ipad", + APP_DESKTOP: "desktop" +}; + +// The App Store Connect host. +const PRODUCTION_API_BASE = "https://api.appstoreconnect.apple.com"; + +// Resolve the host every call is made against. +// +// A test points this at a local server so the failure paths can be exercised +// without touching the real service. Only a loopback override is honoured: +// every call carries an Authorization header holding a JWT signed with the +// real ASC private key, so a stray environment variable must not be able to +// redirect that token to another host. +// +// The production host is accepted explicitly, because a job that pins the host +// it would otherwise use is not a redirect anywhere new. The value is used as +// a prefix for paths that already begin with "/", so a trailing slash is +// stripped to keep the separator from doubling. +// +// The check runs inside main(), which has a catch handler, so a bad value is +// reported as a named failure instead of an uncaught exception at module +// scope. +function resolveApiBase() { + const override = process.env.ASC_API_BASE; + if (!override) return PRODUCTION_API_BASE; + const trimmed = override.replace(/\/+$/, ""); + if (trimmed === PRODUCTION_API_BASE) return PRODUCTION_API_BASE; + if (!/^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?(\/|$)/.test(override)) { + console.error("asc-api: ASC_API_BASE must be the production host or a loopback address"); + return null; + } + return trimmed; +} function base64url(input) { return Buffer.from(input).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); @@ -98,40 +193,27 @@ function fleetSecretsGet(name, folder) { } } -// Expands only a leading "~/" (never "~user/"), so a typo is never merged with -// $HOME — the same rule ship-testflight.sh's expand_tilde applies. -function expandTilde(p) { - if (!p) return ""; - if (p === "~") return homedir(); - return p.startsWith("~/") ? join(homedir(), p.slice(2)) : p; -} - -function fromFile(keyId, issuerId, keyPath, source) { - try { - return { keyId: keyId.trim(), issuerId: issuerId.trim(), privateKeyPem: readFileSync(keyPath, "utf8"), source }; - } catch { - return null; - } -} - +// Normalizes a PEM private key and refuses anything that is not one. +// +// The check matters because the key arrives in the environment, and it ends up +// in the masked preview that a refused request prints. A preview is only safe +// when the material behind it really is a private key. The parse check lives +// in asc-key.mjs, which the shell layer uses as well, so both sides of the +// handoff accept exactly the same keys. +// // Returns { keyId, issuerId, privateKeyPem, source } or { error } or { error: null }. +// +// The order is the rule: the environment beats the network, and a file is not +// a source at all. A path on disk is not an approved secret source, so +// ASC_KEY_PATH is never read here; the shell layer that owns the credential +// reads a staged key and hands it over in the environment. function resolveCredential() { - // 1) Already-exported ASC_* (DealDex's workflow does this). const envKeyId = String(process.env.ASC_KEY_ID || "").trim(); const envIssuer = String(process.env.ASC_ISSUER_ID || "").trim(); - const envKeyPath = expandTilde(String(process.env.ASC_KEY_PATH || "").trim()); - if (envKeyId && envIssuer && envKeyPath && existsSync(envKeyPath)) { - const got = fromFile(envKeyId, envIssuer, envKeyPath, "ASC_KEY_* environment"); - if (got) return got; - } - - // No file is read here (AFC-359). Local-Mac operators set ASC_KEY_ID / - // ASC_ISSUER_ID / ASC_KEY_PATH (or the APPLE_API_* names) in their shell, or - // let ship-testflight.sh stage the Infisical key and export ASC_* before - // it invokes this module (step 3 below also reads Infisical directly). The hosted ios-ship runner only has the APPLE_API_* - // form in $GITHUB_ENV. + const envKeyPem = String(process.env.ASC_KEY_P8 || "").trim(); - // 2) The APPLE_API_* trio the ship workflow exports from Infisical. + // 1) The APPLE_API_* trio the ship workflow exports from Infisical. The + // key itself is in the environment, so it wins over every other source. const keyId = String(process.env.APPLE_API_KEY_ID || "").trim(); const issuerId = String(process.env.APPLE_API_ISSUER_ID || "").trim(); if (keyId && issuerId && process.env.APPLE_API_KEY_P8_BASE64) { @@ -141,7 +223,22 @@ function resolveCredential() { return { keyId, issuerId, privateKeyPem: pem, source: "APPLE_API_* environment (base64 p8)" }; } - // 3) Infisical Fleet-OPS /asc: key id and issuer (unless the environment already + // 2) The same secret as a plain environment variable. This is the form + // fleet-secrets hands out, and the form the shell layer uses when it + // hands a staged key to this client. + if (envKeyId && envIssuer && envKeyPem) { + const pem = normalizePrivateKey(envKeyPem); + if (!pem) return { error: "ASC_KEY_P8 is set but is not a PEM private key" }; + return { keyId: envKeyId, issuerId: envIssuer, privateKeyPem: pem, source: "ASC_KEY_P8 environment" }; + } + + // 3) No file is read here (AFC-359, and Kody rule 42 on PR #271). A path on + // disk is not an approved secret source, so ASC_KEY_PATH is not a + // credential source here: the shell layer that owns the credential reads + // a staged key and exports it as ASC_KEY_P8. Step 4 reads Infisical + // directly, so a local operator needs nothing exported at all. + + // 4) Infisical Fleet-OPS /asc: key id and issuer (unless the environment already // has them) and the PEM as ASC_KEY_P8 when the key id is the /asc one; another // key id falls back to AUTHKEY_ in /authkey--p8. // Kept in memory, never written to disk. @@ -657,6 +754,11 @@ async function setWhatToTest({ api, buildId, appId, marketing }) { } async function main() { + const apiBase = resolveApiBase(); + if (!apiBase) { + process.exitCode = 2; + return; + } const cred = resolveCredential(); if (cred.error) { console.error(`asc-auth: ${cred.error}`); @@ -665,13 +767,19 @@ async function main() { if (!cred.privateKeyPem) { console.error( "asc-auth: no App Store Connect credential found. Tried, in order:\n" + - " 1) ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH in the environment\n" + - " 2) APPLE_API_KEY_ID / APPLE_API_ISSUER_ID / APPLE_API_KEY_P8_BASE64 in the environment" + " 1) APPLE_API_KEY_ID / APPLE_API_ISSUER_ID / APPLE_API_KEY_P8_BASE64 in the environment\n" + + " 2) ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_P8 in the environment\n" + + " 3) Fleet-OPS prod /asc through fleet-secrets\n" + + " A path in ASC_KEY_PATH is not a credential source: the shell layer that owns the\n" + + " credential hands the key over in ASC_KEY_P8" ); process.exit(1); } console.error(`asc-auth: using ${cred.source} (key id length ${cred.keyId.length})`); - const token = signJwt({ keyId: cred.keyId, issuerId: cred.issuerId, privateKeyPem: cred.privateKeyPem }); + // The key id and the private key stay in scope for the whole of main(), so + // the diagnostic helper can name the credential that was refused. + const { keyId, issuerId, privateKeyPem } = cred; + const token = signJwt({ keyId, issuerId, privateKeyPem }); const [method, path, body, arg4, arg5] = process.argv.slice(2); if (!method || !path) { @@ -680,67 +788,994 @@ async function main() { console.error(" node asc-api.mjs ensure-appstore-profiles "); console.error(" node asc-api.mjs ensure-standing-testers [buildVersion] (emails from ASC_STANDING_TESTERS)"); console.error(" node asc-api.mjs latest-build-seq [platform] [appleId]"); - process.exit(1); + console.error(" node asc-api.mjs upload-screenshots "); + console.error(" node asc-api.mjs submit-review [reviewSubmissionId] [platform]"); + // The exit code is set rather than the process ended, so a diagnostic + // written to a pipe is flushed instead of dropped. + process.exitCode = 1; + return; + } + + // How long a single App Store Connect call may take. Node's fetch has no + // default timeout, so a call that never answers would hang the script with + // the reservation still pending in App Store Connect. + // + // The value is overridable so a test can drive a real timeout without + // waiting two minutes for one. + const REQUEST_TIMEOUT_MS = (() => { + const raw = process.env.ASC_REQUEST_TIMEOUT_MS; + const n = raw ? Number.parseInt(raw, 10) : Number.NaN; + if (!Number.isFinite(n) || n <= 0) return 120_000; + // AbortSignal.timeout does not accept a delay above 2^31-1: it either + // throws a RangeError or silently rounds the delay down, so an + // unclamped override would abort every request immediately. + return Math.min(n, 2_147_483_647); + })(); + + // Describe a failed response without ever printing what it holds. + // + // The raw body of an App Store Connect error is not printed: it is + // whatever the service chose to say, and it may echo a header or a token. + // Only the titles and details of the JSON API errors are used, and a run of + // token-like characters in one of those is redacted. + // + // A rejected key is reported with the fleet's masked preview of the key in + // play and the key id beside it, so the operator can tell which credential + // was refused without any of the key being published. The preview is taken + // from the key material rather than from the envelope, because every key + // file opens and ends the same way and a preview of the envelope would + // identify none of them. + function describeError(res) { + if (res.networkError) { + // A timeout and a transport failure have different causes, and the + // operator needs the one that is true: one means App Store Connect was + // slow, the other that the request never got there. + return res.timedOut + ? "the request timed out before App Store Connect answered" + : "the request never reached App Store Connect"; + } + if (res.status === 401 || res.status === 403) { + // The key id is a public identifier: it travels in the header of every + // request to App Store Connect. Together with the fleet's masked + // preview of the key in play it says which credential was rejected, + // which is the question an operator actually has, without publishing + // any of the key. + return `App Store Connect rejected the key: check ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_P8 in Fleet-OPS prod /asc (key id ${keyId}, key in play ${maskKeyPreview(privateKeyPem)})`; + } + const parts = []; + const errors = res.parsed && Array.isArray(res.parsed.errors) ? res.parsed.errors : []; + for (const problem of errors.slice(0, 3)) { + // The errors array comes from the service, so an element may be null or + // a bare string. Reading .title off one of those throws, which would + // replace the safe message this function exists to produce with a stack + // trace, so the shape is checked before anything is read. + const title = isPlainObject(problem) && typeof problem.title === "string" ? problem.title : "unknown error"; + const detail = isPlainObject(problem) && typeof problem.detail === "string" ? problem.detail : ""; + parts.push(detail ? `${title}: ${detail}` : title); + } + if (parts.length === 0) { + parts.push("no JSON API error detail was returned"); + } + return parts + .map((part) => part.replace(/[A-Za-z0-9._~+/=-]{24,}/g, "[redacted]")) + .join("; "); + } + + // The blockers App Store Connect names when a version cannot be reviewed. + // + // The refusal on the review submission item carries a `meta.associatedErrors` + // member: a map from the resource that is not ready to the list of state + // errors on it. Each entry carries a code, a title and a detail, and the + // code is the part an operator can act on, so both the resource and the + // code are printed. The shape is checked before anything is read, because + // a refusal is a document from the service and its members are not + // guaranteed. + function describeAssociatedErrors(res) { + const errors = res && res.parsed && Array.isArray(res.parsed.errors) ? res.parsed.errors : []; + const lines = []; + for (const problem of errors) { + if (!isPlainObject(problem)) continue; + const meta = isPlainObject(problem.meta) ? problem.meta : null; + if (!meta || !isPlainObject(meta.associatedErrors)) continue; + for (const [resource, list] of Object.entries(meta.associatedErrors)) { + if (!Array.isArray(list)) continue; + for (const item of list) { + if (!isPlainObject(item)) continue; + const code = typeof item.code === "string" ? item.code : "unknown error"; + const title = typeof item.title === "string" ? item.title : ""; + lines.push(`${resource} -> ${code}${title ? ` (${title})` : ""}`); + } + } + } + return lines; } + // Guard the shape of a response before any of it is read. + // + // The scripts in this directory are dependency-free, so the guard is + // written out by hand rather than pulled in as a schema library. + // + // A guard cannot return from main() on its own, so it throws a named + // failure instead: the caller unwinds to main()'s handler, which prints the + // message and keeps the exit code the guard set. + function isPlainObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); + } + + function requireObject(value, what) { + if (!isPlainObject(value)) { + process.exitCode = 2; + throw new NamedFailure(`${method}: invalid ${what} response`); + } + return value; + } + + function requireArray(value, what) { + if (!Array.isArray(value)) { + process.exitCode = 2; + throw new NamedFailure(`${method}: invalid ${what} response`); + } + return value; + } + + // Every call is aborted after REQUEST_TIMEOUT_MS, so a call that never + // answers rejects with a TimeoutError rather than hanging. That rejection + // is turned into an ordinary non-ok response here rather than at each call + // site: the commit path in particular has to reach its discardReservation() + // on a timeout, and an escaping exception would skip it and leave the + // reservation open in a ten-entry set. async function api(methodName, apiPath, jsonBody) { - const url = apiPath.startsWith("http") ? apiPath : `https://api.appstoreconnect.apple.com${apiPath}`; - const res = await fetch(url, { - method: methodName, - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json" - }, - body: jsonBody ? jsonBody : undefined - }); + const url = apiPath.startsWith("http") ? apiPath : `${apiBase}${apiPath}`; + let res; + try { + res = await fetch(url, { + method: methodName, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json" + }, + body: jsonBody ? jsonBody : undefined, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }); + } catch (error) { + const timedOut = error && error.name === "TimeoutError"; + return { + status: 0, + ok: false, + // The request never reached App Store Connect. A caller that only + // looks at the status would read this as a server verdict, so the + // transport failure is marked and reported as one. + networkError: true, + // A timeout and a refused connection are different causes, and the + // message has to name the one that happened. + timedOut: Boolean(timedOut), + text: "", + parsed: { + errors: [{ + title: timedOut ? "the request timed out" : "the request failed", + detail: error && error.message ? error.message : "unknown error" + }] + } + }; + } const text = await res.text(); let parsed; try { parsed = JSON.parse(text); } catch { parsed = { raw: text }; } return { status: res.status, ok: res.ok, parsed, text }; } - // filter[bundleId] is a PREFIX match on Apple's side (app.botfleet also - // returns app.botfleet.macos), so always exact-match attributes.bundleId. - // When the registry bundle is not registered yet but appleId is known, fall - // back to GET /v1/apps/. - async function resolveAscAppId(bundleId, appleIdHint, logPrefix) { - const hint = String(appleIdHint || process.env.ASC_APPLE_ID || "").trim(); - let appId = null; - - const apps = await api("GET", `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}&limit=50`); - if (apps.ok) { - const exact = (apps.parsed.data || []).find( - (a) => a?.attributes?.bundleId === bundleId + // Drop a reservation that will never be committed. + // + // A screenshot set holds ten entries, so a reservation that is left open + // after a failed upload sits in the set forever and pushes the real + // screenshots out of it. The delete is best effort: the script still + // reports the original failure, but it says whether the entry went away + // so a later run knows to look for it. + // + // Every caller has to await this before it exits the process: a pending + // fetch is cancelled by the exit, and the reservation would survive. + async function discardReservation(screenshotId) { + try { + const dropped = await api("DELETE", `/v1/appScreenshots/${screenshotId}`); + if (dropped.ok) { + console.error(`upload-screenshots: dropped the reservation ${screenshotId}`); + return true; + } + console.error(`upload-screenshots: the reservation ${screenshotId} is still open (HTTP ${dropped.status}): ${describeError(dropped)}`); + return false; + } catch (error) { + const reason = error && error.name === "TimeoutError" + ? "the delete timed out" + : `the delete failed (${error && error.message ? error.message : "unknown error"})`; + console.error(`upload-screenshots: the reservation ${screenshotId} is still open: ${reason}`); + return false; + } + } + + // ------------------------------------------------------------------------- + // Submit an app store version for review + // ------------------------------------------------------------------------- + // Usage: node asc-api.mjs submit-review [reviewSubmissionId] [platform] + // + // Submitting is the one call that cannot be undone, so the things that + // have to be true first are checked here rather than discovered from + // Apple's refusal: + // + // 1. the version's screenshots are uploaded (a version with no + // screenshots is not submittable, and the binding call refuses with + // the list of what is missing) + // 2. a build is attached to the version + // 3. the version's versionString matches that build's marketing version + // + // The version is attached to the review submission here, because App Store + // Connect never attaches it by itself. The items relationship on the + // submission allows GET_RELATED and GET_RELATIONSHIP only, so it can be + // read but never written: the binding is a resource of its own, a review + // submission item. Without that step the command can never work from a + // clean account, because a fresh version is bound to nothing. + // + // When the version is not yet reviewable, the binding call is refused with + // a 409 whose body names every blocker, one entry per resource. That list + // is the most useful thing this command can print, so it is reported + // rather than swallowed. + if (method === "submit-review") { + const versionId = path; + const submissionId = body; + // The platform the operator asked for by name, and the platform in play. + // They are not the same thing: a platform that was not asked for is + // this command's default, and the default must never rewrite a + // submission somebody else prepared for another platform. + const platformRequested = typeof arg4 === "string" && arg4.length > 0; + const platform = arg4 || "IOS"; + if (!versionId) { + console.error("Usage: node asc-api.mjs submit-review [reviewSubmissionId] [platform]"); + process.exitCode = 2; + return; + } + if (!/^(IOS|MAC_OS)$/.test(platform)) { + console.error(`submit-review: ${platform} is not a review platform; use IOS or MAC_OS`); + process.exitCode = 2; + return; + } + + // 1. The screenshots. + // + // The counts are kept per display type, not pooled: App Store Connect + // wants a screenshot for every device family the app runs on, so three + // iPhone images and an empty iPad set would pass a total of six and + // still be refused. A set that could not be read is a failure, not an + // absent set: treating it as absent would let an irreversible + // submission go out on a read that never happened. + const localizations = await api("GET", `/v1/appStoreVersions/${versionId}/appStoreVersionLocalizations`); + if (!localizations.ok) { + console.error(`submit-review: ${describeError(localizations)}`); + process.exitCode = 2; + return; + } + const locs = requireArray(localizations.parsed?.data, "version localization list"); + const byDisplayType = new Map(); + for (const loc of locs) { + if (!isPlainObject(loc) || typeof loc.id !== "string") continue; + const sets = await api("GET", `/v1/appStoreVersionLocalizations/${loc.id}/appScreenshotSets`); + if (!sets.ok) { + console.error(`submit-review: ${describeError(sets)}`); + process.exitCode = 2; + return; + } + for (const set of requireArray(sets.parsed?.data, "screenshot set list")) { + const displayType = isPlainObject(set) ? set.attributes?.screenshotDisplayType : null; + const count = isPlainObject(set) ? set.attributes?.screenshotCount : null; + if (typeof displayType !== "string" || typeof count !== "number") continue; + byDisplayType.set(displayType, (byDisplayType.get(displayType) || 0) + count); + } + } + const breakdown = [...byDisplayType.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([type, count]) => `${type} ${count}`) + .join(", "); + console.error(`submit-review: screenshots by display type: ${breakdown || "none"}`); + // The requirement is counted per device family, and a set that exists + // but holds nothing does not count: an empty iPad set is the case a + // pooled total lets through. + const familyCounts = new Map(); + for (const [type, count] of byDisplayType) { + const family = DISPLAY_TYPE_FAMILY[type]; + if (!family) continue; + familyCounts.set(family, (familyCounts.get(family) || 0) + count); + } + const missing = (platform === "MAC_OS" + ? REQUIRED_SCREENSHOT_FAMILIES_MAC + : REQUIRED_SCREENSHOT_FAMILIES).filter( + (family) => (familyCounts.get(family) || 0) < 1 + ); + if (missing.length > 0) { + console.error( + `submit-review: not submitting, the version has no screenshot for ${missing.join(" and ")}` ); - if (exact) { - appId = exact.id; - } else if ((apps.parsed.data || []).length) { - console.error( - `${logPrefix}: filter[bundleId]=${bundleId} returned ${apps.parsed.data.length} row(s) but no exact match` + - ` (${apps.parsed.data.map((a) => a.attributes?.bundleId).join(", ")})` - ); + process.exitCode = 2; + return; + } + + // 2. The build. + const attached = await api("GET", `/v1/appStoreVersions/${versionId}/build`); + if (!attached.ok) { + console.error(`submit-review: ${describeError(attached)}`); + process.exitCode = 2; + return; + } + const build = attached.parsed?.data; + if (!isPlainObject(build) || typeof build.id !== "string") { + console.error("submit-review: not submitting, no build is attached to the version"); + process.exitCode = 2; + return; + } + + // 3. The version string, and the build's own marketing version. + // + // App Store Connect pairs a build with a version by their marketing + // version, so a version whose versionString is still the one from the + // previous build is refused. That is a cheap mistake to make and an + // expensive one to find out about from Apple, so it is checked here. + const version = await api("GET", `/v1/appStoreVersions/${versionId}`); + if (!version.ok) { + console.error(`submit-review: ${describeError(version)}`); + process.exitCode = 2; + return; + } + const versionString = version.parsed?.data?.attributes?.versionString; + const appId = version.parsed?.data?.relationships?.app?.data?.id; + const buildVersion = build.attributes?.version; + console.error(`submit-review: version ${versionString} | build ${buildVersion} (${build.id})`); + if (typeof versionString !== "string" || versionString.length === 0) { + console.error("submit-review: not submitting, the version has no versionString"); + process.exitCode = 2; + return; + } + + const withPreRelease = await api("GET", `/v1/builds/${build.id}?include=preReleaseVersion`); + if (!withPreRelease.ok) { + // A read that failed is not an absent pre-release version: treating + // it as absent would let an irreversible submission go out on a check + // that never ran, and it would swallow the one failure that matters + // most here, a rejected key. + console.error(`submit-review: ${describeError(withPreRelease)}`); + process.exitCode = 2; + return; + } + const included = withPreRelease.parsed?.included; + const preReleaseId = withPreRelease.parsed?.data?.relationships?.preReleaseVersion?.data?.id; + const hasPreRelease = typeof preReleaseId === "string" && preReleaseId.length > 0; + // A build with no pre-release version carries no include: JSON:API + // leaves the member out rather than sending an empty list, and there is + // nothing to compare in that case. An include that is missing while the + // build names one is a read that could not be understood. + if (hasPreRelease && !Array.isArray(included)) { + console.error( + `submit-review: not submitting, the build names pre-release ${preReleaseId} and the read returns no include for it` + ); + process.exitCode = 2; + return; + } + const preRelease = hasPreRelease + ? included.find((item) => isPlainObject(item) && item.id === preReleaseId) + : null; + // The marketing version lives on the pre-release version as `version`. + // That is not a guess: the live API answers + // GET /v1/builds/?include=preReleaseVersion with + // included[].attributes.version = "1.0.235" and no `versionString` on + // that resource at all. `versionString` is the field of an + // appStoreVersion, which is where the version's own name is read above. + const marketing = preRelease && isPlainObject(preRelease.attributes) + ? preRelease.attributes.version + : null; + // A build that names a pre-release version but whose marketing version + // cannot be read is not a build with nothing to compare: it is a + // comparison that did not happen, and letting the submission through + // would skip the one check that pairs the version with the build. + if (hasPreRelease && (typeof marketing !== "string" || marketing.length === 0)) { + console.error( + `submit-review: not submitting, the build names pre-release ${preReleaseId} but its marketing version could not be read` + ); + process.exitCode = 2; + return; + } + if (typeof marketing === "string" && marketing.length > 0 && marketing !== versionString) { + console.error( + `submit-review: not submitting, the version says ${versionString} and the build says ${marketing}. ` + + "Set the version's versionString to the build's marketing version first." + ); + process.exitCode = 2; + return; + } + + // The review submission. + // + // The version is bound to a submission by a review submission item, a + // resource of its own. The items relationship on the submission allows + // GET_RELATED and GET_RELATIONSHIP only, so it can be read but never + // written, and a version that no item names is bound to nothing. So the + // binding is made here, which is what lets this command work from a + // clean account instead of only against a version somebody else prepared. + // + // A submission already bound to a different version is left alone: one + // submission carries one version, and a second one cannot be added to + // it. A submission bound to nothing is the one that gets this version. + const blockers = []; + let candidates = []; + // The platform every candidate carries, so the platform can be checked + // against the one in play before the irreversible call goes out. + const candidatePlatforms = new Map(); + if (submissionId) { + candidates = [submissionId]; + // A named submission is taken as given, but two things about it still + // have to be checked: a submission that was already sent cannot be + // sent again, and its platform has to be the one asked for, because a + // macOS submission for an iOS version would send the wrong build to + // review. + // + // The platform is NOT set here. The binding is read first, because a + // submission that holds another version is left alone, and a + // submission that is left alone must not be mutated either. + const named = await api("GET", `/v1/reviewSubmissions/${submissionId}`); + if (!named.ok) { + console.error(`submit-review: ${describeError(named)}`); + process.exitCode = 2; + return; + } + const namedAttributes = isPlainObject(named.parsed?.data?.attributes) + ? named.parsed.data.attributes + : null; + if (!namedAttributes) { + console.error("submit-review: the review submission came back without readable attributes"); + process.exitCode = 2; + return; } + if (namedAttributes.submittedDate !== null && namedAttributes.submittedDate !== undefined) { + console.error("submit-review: not submitting, the review submission was already submitted"); + process.exitCode = 2; + return; + } + candidatePlatforms.set( + submissionId, + typeof namedAttributes.platform === "string" ? namedAttributes.platform : null + ); } else { - console.error(`${logPrefix}: apps query failed (HTTP ${apps.status})`); - return { appId: null, appsStatus: apps.status }; + if (typeof appId !== "string" || appId.length === 0) { + console.error("submit-review: not submitting, the version names no app"); + process.exitCode = 2; + return; + } + const listed = await api("GET", `/v1/apps/${appId}/reviewSubmissions`); + if (!listed.ok) { + console.error(`submit-review: ${describeError(listed)}`); + process.exitCode = 2; + return; + } + candidates = requireArray(listed.parsed?.data, "review submission list") + .filter((s) => isPlainObject(s) && typeof s.id === "string") + .filter((s) => { + const attributes = isPlainObject(s.attributes) ? s.attributes : {}; + // A submission that was already sent cannot be sent again. + if (attributes.submittedDate !== null && attributes.submittedDate !== undefined) return false; + // A submission for another platform would put the wrong build in + // review, so it is not a candidate. A submission with no platform + // at all is a candidate: the platform is set on it and read back + // before the irreversible call, so it never goes to review with + // the wrong platform. + return typeof attributes.platform !== "string" || attributes.platform === platform; + }) + .map((s) => { + const attributes = isPlainObject(s.attributes) ? s.attributes : {}; + candidatePlatforms.set( + s.id, + typeof attributes.platform === "string" ? attributes.platform : null + ); + return s.id; + }); + } + if (candidates.length === 0) { + // The app has no draft submission at all, so one is made. The + // platform goes on it at create time, which is the shape App Store + // Connect accepts: items and submitted are both refused on create. + if (typeof appId !== "string" || appId.length === 0) { + console.error("submit-review: not submitting, the version names no app"); + process.exitCode = 2; + return; + } + const created = await api("POST", "/v1/reviewSubmissions", JSON.stringify({ + data: { + type: "reviewSubmissions", + attributes: { platform }, + relationships: { app: { data: { type: "apps", id: appId } } } + } + })); + if (!created.ok) { + console.error(`submit-review: ${describeError(created)}`); + process.exitCode = 2; + return; + } + const createdId = created.parsed?.data?.id; + if (typeof createdId !== "string" || createdId.length === 0) { + console.error("submit-review: the review submission was created but came back without an id"); + process.exitCode = 2; + return; + } + candidates = [createdId]; + // The platform went on it at create time, so it does not have to be + // set again. A response that does not echo a platform is recorded as + // carrying none: recording the platform that was asked for would make + // the platform check pass, and the submission would go to review with a + // platform that was never proven. + const createdPlatform = isPlainObject(created.parsed?.data?.attributes) + ? created.parsed.data.attributes.platform + : null; + candidatePlatforms.set(createdId, typeof createdPlatform === "string" ? createdPlatform : null); } - if (!appId && hint) { - const byId = await api("GET", `/v1/apps/${encodeURIComponent(hint)}`); - if (byId.ok && byId.parsed.data?.id) { - appId = byId.parsed.data.id; - const resolvedBundle = byId.parsed.data.attributes?.bundleId || "(unknown)"; + // Is this submission bound to the version? + // + // The item's own id is the review submission item's id, not the + // version's: the version is named in the item's relationship. Reading + // the item's id as the version's would never match, so the check has to + // follow the relationship. + const boundVersionOf = (item) => { + if (!isPlainObject(item)) return null; + const relationships = isPlainObject(item.relationships) ? item.relationships : null; + if (!relationships) return null; + const related = isPlainObject(relationships.appStoreVersion) ? relationships.appStoreVersion : null; + if (!related) return null; + const data = isPlainObject(related.data) ? related.data : null; + return data && typeof data.id === "string" ? data.id : null; + }; + + let submitted = null; + // Whether the submit was ever attempted. A refusal of the submit and a + // run that never sent one are different failures, and the final report + // has to say which one happened. + let submitAttempted = false; + const boundElsewhere = []; + // The submission this run bound the version to. A binding cannot be + // undone from this tool, and one version lives on one submission, so + // every path that leaves the version bound and the submission unsent + // says so and stops the run. + let boundSubmission = null; + const reportBound = () => { + console.error( + `submit-review: the version ${versionId} is bound to review submission ${boundSubmission}, ` + + "which was not submitted. The binding cannot be undone from here: clear it in App Store Connect " + + "before the next run." + ); + }; + for (const candidate of candidates) { + const items = await api("GET", `/v1/reviewSubmissions/${candidate}/items`); + if (!items.ok) { + console.error(`submit-review: ${candidate}: ${describeError(items)}`); + continue; + } + const list = requireArray(items.parsed?.data, "review submission item list"); + const boundTo = list.map(boundVersionOf).filter(Boolean); + if (boundTo.length > 0 && !boundTo.includes(versionId)) { + boundElsewhere.push(`${candidate} -> ${boundTo.join(", ")}`); + console.error( + `submit-review: ${candidate} is bound to ${boundTo.join(", ")}; skipping it` + ); + continue; + } + // The platform is checked before anything is written: a submission + // that carries another platform is never rewritten to one the operator + // did not ask for, and this refusal mutates nothing. The platform is + // set and read back below, before the binding, so that a platform + // that cannot be proven stops the run with the version still unbound. + const candidatePlatform = candidatePlatforms.has(candidate) + ? candidatePlatforms.get(candidate) + : null; + if (candidatePlatform !== platform && !platformRequested && typeof candidatePlatform === "string") { console.error( - `${logPrefix}: bundle ${bundleId} not in ASC; using appleId ${hint} (${resolvedBundle})` + `submit-review: not submitting, the review submission ${candidate} is for ${candidatePlatform} ` + + "and no platform was requested. Name the platform to submit it." ); + process.exitCode = 2; + return; + } + // The platform is set and read back BEFORE the binding, so the + // platform is proven before any write that cannot be undone. A + // platform that cannot be set, or that is not there when it is read + // back, stops the run with the version still unbound, so there is + // nothing for the operator to clear by hand. + if (candidatePlatform !== platform) { + const setPlatform = await api("PATCH", `/v1/reviewSubmissions/${candidate}`, JSON.stringify({ + data: { + type: "reviewSubmissions", + id: candidate, + attributes: { platform } + } + })); + if (!setPlatform.ok) { + console.error(`submit-review: the platform could not be set: ${describeError(setPlatform)}`); + // The binding has not happened yet, so the version is on no + // submission and nothing has to be cleared by hand. + console.error("submit-review: the version was not bound and nothing was submitted"); + process.exitCode = 2; + return; + } + // The platform is read back, so a change that was ignored or only + // half applied cannot slip into the call that cannot be undone. + const readBack = await api("GET", `/v1/reviewSubmissions/${candidate}`); + const applied = isPlainObject(readBack.parsed?.data?.attributes) + ? readBack.parsed.data.attributes.platform + : null; + if (applied !== platform) { + console.error( + `submit-review: the platform on ${candidate} is ` + + `${typeof applied === "string" ? applied : "unreadable"}, ` + + `not ${platform}; not submitting` + ); + // The binding has not happened yet, so the version is on no + // submission and nothing has to be cleared by hand. + console.error("submit-review: the version was not bound and nothing was submitted"); + process.exitCode = 2; + return; + } + } + if (boundTo.length === 0) { + // The submission is bound to nothing, so this version goes on it. + const bound = await api("POST", "/v1/reviewSubmissionItems", JSON.stringify({ + data: { + type: "reviewSubmissionItems", + relationships: { + reviewSubmission: { data: { type: "reviewSubmissions", id: candidate } }, + appStoreVersion: { data: { type: "appStoreVersions", id: versionId } } + } + } + })); + if (!bound.ok) { + for (const line of describeAssociatedErrors(bound)) blockers.push(line); + console.error(`submit-review: ${candidate} could not be bound to ${versionId}: ${describeError(bound)}`); + // A rejected credential is not a recoverable error: the calls + // that follow this one include the submit, which cannot be undone, + // so the run stops here instead of issuing them with a credential + // App Store Connect has already refused. + if (bound.status === 401 || bound.status === 403) { + process.exitCode = 2; + return; + } + continue; + } + // The version is bound to this submission now, and the binding + // cannot be undone from here. + boundSubmission = candidate; + console.error(`submit-review: bound ${versionId} to review submission ${candidate}`); + } + // The one call that cannot be undone carries the one attribute that + // has been proven in production, and nothing else. The platform was + // set above, on a call that can be undone. + submitAttempted = true; + const result = await api("PATCH", `/v1/reviewSubmissions/${candidate}`, JSON.stringify({ + data: { + type: "reviewSubmissions", + id: candidate, + attributes: { submitted: true } + } + })); + if (!result.ok) { + // The refusal on the submit carries the same blockers the binding + // refusal carries, and they are the most useful thing this command + // can print. + for (const line of describeAssociatedErrors(result)) blockers.push(line); + console.error(`submit-review: ${candidate} refused: ${describeError(result)}`); + // A rejected credential stops the run here as well: the next + // candidate would be submitted with a credential that has already + // been refused. + if (result.status === 401 || result.status === 403) { + if (boundSubmission) reportBound(); + process.exitCode = 2; + return; + } + // This run bound the version, so the run stops here. A refused + // submit leaves the version attached to a submission, and a second + // candidate would be bound to the same version: one version lives + // on one submission, and the binding cannot be undone from here. + if (boundSubmission) { + reportBound(); + break; + } + continue; + } + submitted = candidate; + break; + } + if (!submitted) { + if (submitAttempted) { + // The run got as far as the submit and App Store Connect refused + // it. Saying that no submission is bound would be false: this run + // bound the version itself. + console.error("submit-review: not submitting, App Store Connect refused the submission"); } else { + // The run never got as far as the submit, so no submission carries + // this version. Saying that App Store Connect refused a submission + // would be false: none was sent. + console.error("submit-review: not submitting, the version was never bound to a submission"); + } + if (blockers.length > 0) { + console.error("submit-review: App Store Connect says the version is not ready:"); + for (const line of blockers) console.error(` ${line}`); + } else if (!submitAttempted && boundElsewhere.length > 0) { console.error( - `${logPrefix}: appleId fallback ${hint} failed (HTTP ${byId.status})` + "submit-review: the app's review submission holds another version " + + `(${boundElsewhere.join("; ")}). One submission carries one version, so it has to be ` + + "cleared in App Store Connect before this one can go to review." ); } + process.exitCode = 2; + return; + } + const after = await api("GET", `/v1/reviewSubmissions/${submitted}`); + if (!after.ok) { + console.error(`submit-review: ${describeError(after)}`); + process.exitCode = 2; + return; + } + const attributes = after.parsed?.data?.attributes; + const state = isPlainObject(attributes) ? attributes.state : null; + const submittedDate = isPlainObject(attributes) ? attributes.submittedDate : null; + if (state !== null && typeof state !== "string") { + console.error("submit-review: the review submission came back with an unreadable state"); + process.exitCode = 2; + return; + } + if (submittedDate !== null && typeof submittedDate !== "string") { + console.error("submit-review: the review submission came back with an unreadable submittedDate"); + process.exitCode = 2; + return; + } + // Only the report goes to stdout, so a wrapper that parses it as JSON + // cannot be broken by a progress line. + console.log(JSON.stringify({ + ok: true, + reviewSubmission: submitted, + state, + submittedDate, + versionString, + build: build.id, + platform + })); + return; + } + + // ------------------------------------------------------------------------- + // App Store screenshot upload + // ------------------------------------------------------------------------- + // Usage: node asc-api.mjs upload-screenshots + // + // App Store Connect has no single-call screenshot upload. The bytes go to + // a pre-signed URL Apple hands out with the reservation, and the reservation + // is only committed once the bytes are in, so it takes three calls: + // + // 1. POST /v1/appScreenshots reserve, get the upload operations + // 2. PUT send the bytes + // 3. PATCH /v1/appScreenshots/ uploaded=true, then read + // assetDeliveryState + // + // The screenshot set (one per display size) is created on demand and reused, + // so running this twice does not create a second set. The display type is + // the App Store Connect enum, e.g. APP_IPHONE_67 for the 6.9-inch iPhone + // set and APP_IPAD_PRO_129 for the 13-inch iPad set. + if (method === "upload-screenshots") { + const localizationId = path; + let displayType = body; + const files = process.argv.slice(5); + if (!localizationId || !displayType || files.length === 0) { + console.error("Usage: node asc-api.mjs upload-screenshots "); + process.exitCode = 2; + return; + } + + // "auto" reads the pixel size out of the file and picks the screenshot set + // that size belongs to, so the display type cannot be mis-specified. + // App Store Connect has no APP_IPHONE_69 or APP_IPAD_PRO_13: the 6.9-inch + // iPhone size lives in APP_IPHONE_67 and the 13-inch iPad size in + // APP_IPAD_PRO_129. The Mac sizes are Apple's own list, all 16:10, and + // they belong to the desktop set. The sizes are Apple's list, at + // developer.apple.com/help/app-store-connect/reference/screenshot-specifications + const sizeToDisplayType = { + "1320x2868": "APP_IPHONE_67", + "1290x2796": "APP_IPHONE_67", + "1284x2778": "APP_IPHONE_65", + "1242x2688": "APP_IPHONE_65", + "2064x2752": "APP_IPAD_PRO_129", + "2048x2732": "APP_IPAD_PRO_3GEN_129", + "1280x800": "APP_DESKTOP", + "1440x900": "APP_DESKTOP", + "2560x1600": "APP_DESKTOP", + "2880x1800": "APP_DESKTOP" + }; + // Every file has to belong to the same screenshot set. A capture run + // produces iPhone and iPad files together and they belong in different + // sets, so a mixed list is refused here rather than sending the second + // family's files to the first family's set. + const fileSizes = new Map(); + for (const file of files) { + if (!existsSync(file)) { + console.error(`upload-screenshots: no such file: ${file}`); + process.exitCode = 2; + return; + } + // The width and height sit at offsets 16 and 20, so anything shorter + // than a signature plus an IHDR header cannot be measured. Reading + // them anyway raises a RangeError with a stack trace instead of the + // named failure every other bad input gets here. + const bytes = readFileSync(file); + if (bytes.length < 24) { + console.error(`upload-screenshots: ${file} is ${bytes.length} bytes, too small to be a PNG`); + process.exitCode = 2; + return; + } + const header = bytes.subarray(0, 24); + const size = `${header.readUInt32BE(16)}x${header.readUInt32BE(20)}`; + const type = sizeToDisplayType[size]; + if (!type) { + console.error(`upload-screenshots: ${size} is not a size App Store Connect accepts`); + process.exitCode = 2; + return; + } + fileSizes.set(file, { size, type }); + } + if (displayType === "auto") { + displayType = fileSizes.get(files[0]).type; + } + for (const file of files) { + const { size, type } = fileSizes.get(file); + if (type !== displayType) { + console.error(`upload-screenshots: ${file} is ${size}, which belongs to ${type}, not ${displayType}`); + console.error(`upload-screenshots: run the ${type} files on their own`); + process.exitCode = 2; + return; + } } + console.error(`upload-screenshots: ${fileSizes.get(files[0]).size} -> ${displayType}`); - return { appId, appsStatus: apps.status }; + const sets = await api("GET", `/v1/appStoreVersionLocalizations/${localizationId}/appScreenshotSets`); + if (!sets.ok) { + console.error(`upload-screenshots: could not list screenshot sets (HTTP ${sets.status}): ${describeError(sets)}`); + process.exitCode = 2; + return; + } + const setList = requireArray(sets.parsed?.data, "screenshot-set list"); + let set = setList.find( + (entry) => isPlainObject(entry) && typeof entry.id === "string" + && entry.attributes?.screenshotDisplayType === displayType + ); + if (!set) { + const created = await api("POST", "/v1/appScreenshotSets", JSON.stringify({ + data: { + type: "appScreenshotSets", + attributes: { screenshotDisplayType: displayType }, + relationships: { + appStoreVersionLocalization: { + data: { type: "appStoreVersionLocalizations", id: localizationId } + } + } + } + })); + if (!created.ok) { + console.error(`upload-screenshots: could not create the ${displayType} set (HTTP ${created.status}): ${describeError(created)}`); + process.exitCode = 2; + return; + } + set = requireObject(created.parsed?.data, "screenshot set"); + console.error(`upload-screenshots: created the ${displayType} set ${set.id}`); + } else { + console.error(`upload-screenshots: reusing the ${displayType} set ${set.id}`); + } + + for (const file of files) { + if (!existsSync(file)) { + console.error(`upload-screenshots: no such file: ${file}`); + process.exitCode = 2; + return; + } + const bytes = readFileSync(file); + const fileName = file.split("/").pop(); + + const reserved = await api("POST", "/v1/appScreenshots", JSON.stringify({ + data: { + type: "appScreenshots", + attributes: { fileName, fileSize: bytes.length }, + relationships: { + appScreenshotSet: { data: { type: "appScreenshotSets", id: set.id } } + } + } + })); + if (!reserved.ok) { + console.error(`upload-screenshots: could not reserve ${fileName} (HTTP ${reserved.status}): ${describeError(reserved)}`); + process.exitCode = 2; + return; + } + // The reservation now exists in a set that holds ten entries, so it has + // to be dropped on every path that stops here. When the response shape + // gives back no usable id there is nothing to delete by, and the message + // says so rather than letting the run end quietly with a slot consumed. + const reservation = reserved.parsed?.data; + if (!isPlainObject(reservation)) { + console.error(`upload-screenshots: invalid screenshot reservation for ${fileName}; a reservation may be open in set ${set.id}`); + process.exitCode = 2; + return; + } + const screenshot = reservation; + if (typeof screenshot.id !== "string" || screenshot.id.length === 0) { + console.error(`upload-screenshots: invalid screenshot reservation for ${fileName}; a reservation may be open in set ${set.id}`); + if (screenshot.id) await discardReservation(screenshot.id); + process.exitCode = 2; + return; + } + const operations = Array.isArray(screenshot.attributes?.uploadOperations) + ? screenshot.attributes.uploadOperations + : []; + if (operations.length === 0) { + console.error(`upload-screenshots: Apple returned no upload operation for ${fileName}`); + await discardReservation(screenshot.id); + process.exitCode = 2; + return; + } + + for (const operation of operations) { + const headers = {}; + for (const header of operation.requestHeaders || []) headers[header.name] = header.value; + const offset = operation.offset || 0; + const length = operation.length || bytes.length; + const chunk = bytes.subarray(offset, offset + length); + let put; + try { + put = await fetch(operation.url, { + method: operation.method || "PUT", + headers, + body: chunk, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }); + } catch (error) { + // The reservation is still open in App Store Connect, and a set + // holds ten entries, so it is dropped before the script leaves. + await discardReservation(screenshot.id); + const reason = error && error.name === "TimeoutError" + ? "the upload timed out" + : `the upload failed (${error && error.message ? error.message : "unknown error"})`; + console.error(`upload-screenshots: upload of ${fileName} failed: ${reason}`); + process.exitCode = 2; + return; + } + if (!put.ok) { + console.error(`upload-screenshots: upload of ${fileName} failed (HTTP ${put.status})`); + await discardReservation(screenshot.id); + process.exitCode = 2; + return; + } + } + + const committed = await api("PATCH", `/v1/appScreenshots/${screenshot.id}`, JSON.stringify({ + data: { type: "appScreenshots", id: screenshot.id, attributes: { uploaded: true } } + })); + if (!committed.ok) { + console.error(`upload-screenshots: could not commit ${fileName} (HTTP ${committed.status}): ${describeError(committed)}`); + await discardReservation(screenshot.id); + process.exitCode = 2; + return; + } + const committedScreenshot = requireObject(committed.parsed?.data, "screenshot commit"); + const state = committedScreenshot.attributes?.assetDeliveryState; + const stateName = state?.state || "UNKNOWN"; + console.error(`upload-screenshots: ${fileName} -> ${screenshot.id} (${stateName})`); + if (state?.errors?.length) { + console.error(`upload-screenshots: Apple rejected ${fileName}:`); + for (const problem of state.errors) { + console.error(` ${problem.code}: ${problem.title} ${problem.detail || ""}`); + } + process.exitCode = 3; + return; + } + } + console.log(JSON.stringify({ ok: true, setId: set.id, displayType, files })); + process.exitCode = 0; + return; } // Highest N already used by the ".N" MARKETING version train @@ -773,15 +1808,35 @@ async function main() { const bundleId = path; const prefix = body; if (!bundleId || !prefix) { - console.error("Usage: node asc-api.mjs latest-build-seq [platform] [appleId]"); - process.exit(2); + console.error("Usage: node asc-api.mjs latest-build-seq "); + process.exitCode = 2; + return; } - - const appleIdHint = String(arg5 || process.env.ASC_APPLE_ID || "").trim(); - const { appId, appsStatus } = await resolveAscAppId(bundleId, appleIdHint, "latest-build-seq"); - if (!appId) { - console.error(`latest-build-seq: app not found for bundle (HTTP ${appsStatus})`); - process.exit(2); + const apps = await api("GET", `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}&limit=1`); + if (!apps.ok) { + // Every failure the service reports goes through describeError, so a + // rejected key is never read as a bundle id that does not exist. The + // status stays in the message: ship-testflight.sh logs these lines + // because the status is the only thing that explains a failed lookup, + // and describeError alone cannot tell a rate limit from a bad request. + const status = apps.networkError ? "" : ` (HTTP ${apps.status})`; + console.error(`latest-build-seq: ${describeError(apps)}${status}`); + process.exitCode = 2; + return; + } + if (!isPlainObject(apps.parsed?.data?.[0])) { + console.error(`latest-build-seq: no app in App Store Connect has the bundle id ${bundleId}`); + process.exitCode = 2; + return; + } + const appId = apps.parsed.data[0].id; + // The id is what every query below filters on, so an app record without + // one would send filter[app]=undefined and produce an HTTP 400 that reads + // as a query the client built wrong. + if (typeof appId !== "string" || appId.length === 0) { + console.error(`latest-build-seq: the app record for ${bundleId} has no usable id`); + process.exitCode = 2; + return; } // Escape the prefix so "1.0" cannot match "120" via the regex dot. @@ -806,7 +1861,8 @@ async function main() { const res = await api("GET", url); if (!res.ok) { console.error(`latest-build-seq: preReleaseVersions query failed (HTTP ${res.status})`); - process.exit(2); + process.exitCode = 2; + return; } for (const v of res.parsed.data || []) { // If a specific platform is requested, filter by it. Default to IOS if unspecified. @@ -826,7 +1882,8 @@ async function main() { const res = await api("GET", url); if (!res.ok) { console.error(`latest-build-seq: builds query failed (HTTP ${res.status})`); - process.exit(2); + process.exitCode = 2; + return; } for (const b of res.parsed.data || []) { seenBuilds++; @@ -839,7 +1896,8 @@ async function main() { `latest-build-seq: ${seenVersions} marketing version(s) + ${seenBuilds} build(s) inspected; highest ${prefix}.N is N=${best}` ); console.log(String(best)); - process.exit(0); + process.exitCode = 0; + return; } // After upload: find THE BUILD THIS RUN JUST UPLOADED, declare export @@ -1202,7 +2260,8 @@ async function main() { console.error(" this run uploaded. It is required: without it there is no way to tell the"); console.error(" new build from the previous ship, and ASC has no record of the new build"); console.error(" for the first minutes after upload."); - process.exit(2); + process.exitCode = 2; + return; } const totalTimeoutSec = (() => { @@ -1214,10 +2273,29 @@ async function main() { const POLL_MS = 15000; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - const { appId, appsStatus } = await resolveAscAppId(bundleId, appleIdHint, "ensure-tf-ready"); - if (!appId) { - console.error(`ensure-tf-ready: app not found for bundle (HTTP ${appsStatus})`); - process.exit(2); + const apps = await api("GET", `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}&limit=1`); + if (!apps.ok) { + // Every failure the service reports goes through describeError, so a + // rejected key is never read as a bundle id that does not exist. The + // status stays in the message for the same reason as latest-build-seq. + const status = apps.networkError ? "" : ` (HTTP ${apps.status})`; + console.error(`ensure-tf-ready: ${describeError(apps)}${status}`); + process.exitCode = 2; + return; + } + if (!isPlainObject(apps.parsed?.data?.[0])) { + console.error(`ensure-tf-ready: no app in App Store Connect has the bundle id ${bundleId}`); + process.exitCode = 2; + return; + } + const appId = apps.parsed.data[0].id; + // The id is what every query below filters on, so an app record without + // one would send filter[app]=undefined and produce an HTTP 400 that reads + // as a query the client built wrong. + if (typeof appId !== "string" || appId.length === 0) { + console.error(`ensure-tf-ready: the app record for ${bundleId} has no usable id`); + process.exitCode = 2; + return; } // ---- Phase A: discovery. Poll until the uploaded build has an ASC record. @@ -1245,12 +2323,14 @@ async function main() { continue; } console.error(`ensure-tf-ready: discovery query failed (HTTP ${res.status})`); - process.exit(2); + process.exitCode = 2; + return; } const rows = res.parsed.data || []; if (rows.length > 1) { console.error(`ensure-tf-ready: ${rows.length} builds match version ${wantBuildVersion} -- ambiguous, refusing to guess`); - process.exit(2); + process.exitCode = 2; + return; } if (rows.length === 1) { build = rows[0]; @@ -1267,7 +2347,30 @@ async function main() { let newest = "unknown"; try { const probe = await api("GET", `/v1/builds?filter[app]=${appId}&sort=-uploadedDate&limit=1`); - newest = probe.parsed?.data?.[0]?.attributes?.version || "none"; + if (probe.ok) { + // The response is the service's, so its shape is checked before any + // of it is read. Three states stay distinct: a version is the + // actual newest build, "none" is the service saying there is no + // build at all, and "unknown" is an answer that could not be read. + // A malformed body must not fold into "none". + const rows = Array.isArray(probe.parsed?.data) ? probe.parsed.data : null; + if (rows === null) { + newest = "unknown"; + } else if (rows.length === 0) { + newest = "none"; + } else { + const first = rows[0]; + newest = isPlainObject(first) && isPlainObject(first.attributes) + && typeof first.attributes.version === "string" + ? first.attributes.version + : "unknown"; + } + } else { + // The probe did not answer, so what is present is not known. Saying + // "none" would assert that the app has no builds at all, which is a + // different and wrong claim. + console.error(`ensure-tf-ready: the newest-build probe failed: ${describeError(probe)}`); + } } catch { /* diagnostic only */ } console.error( `ensure-tf-ready: build version ${wantBuildVersion} never appeared for ${bundleId} within ${totalTimeoutSec}s; ` + @@ -1278,7 +2381,8 @@ async function main() { ok: false, wanted: wantBuildVersion, newestPresent: newest, discoveryTimedOut: true, transientErrors })); - process.exit(4); + process.exitCode = 4; + return; } const buildId = build.id; @@ -1294,7 +2398,8 @@ async function main() { })); if (!patch.ok) { console.error(`ensure-tf-ready: compliance patch failed HTTP ${patch.status}`); - process.exit(2); + process.exitCode = 2; + return; } console.error("ensure-tf-ready: declared usesNonExemptEncryption=false"); } @@ -1345,28 +2450,37 @@ async function main() { } console.error("ensure-tf-ready: timed out waiting for IN_BETA_TESTING (upload may still be processing)"); console.log(JSON.stringify({ ok: false, buildId, version, timedOut: true })); - process.exit(3); + process.exitCode = 3; + return; } - const url = path.startsWith("http") ? path : `https://api.appstoreconnect.apple.com${path}`; + const url = path.startsWith("http") ? path : `${apiBase}${path}`; const res = await fetch(url, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, - body: body ? body : undefined + body: body ? body : undefined, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); const text = await res.text(); console.error(`HTTP ${res.status}`); console.log(text); - if (!res.ok) process.exit(2); + if (!res.ok) { process.exitCode = 2; return; } } // Run only when invoked as a script, so a test can import the helpers above. if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) { main().catch((err) => { - console.error(String(err && err.stack ? err.stack : err)); - process.exit(1); + if (err instanceof NamedFailure) { + // The failure named itself, so a stack trace would only be noise. + console.error(err.message); + } else { + console.error(String(err && err.stack ? err.stack : err)); + } + // A failure that already chose its exit code keeps it. + if (!process.exitCode) process.exitCode = 1; + return; }); } diff --git a/scripts/ios-fleet/asc-key.mjs b/scripts/ios-fleet/asc-key.mjs new file mode 100644 index 00000000..b16e2517 --- /dev/null +++ b/scripts/ios-fleet/asc-key.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node +/** + * The App Store Connect private key: how it is validated, and how it is + * staged for the Apple tools that need a key file. + * + * WHY THIS IS ITS OWN MODULE (2026-10-21, Kody rule 42 on PR #271): + * The private key is a secret, and the fleet's approved secret sources are the + * process environment, a captured `fleet-secrets get NAME`, and bootstrap-only + * Keychain credentials. A caller-supplied local file is not one of them, so + * nothing here reads a key file: the only approved sources of the key are + * process.env.ASC_KEY_P8 and `fleet-secrets get ASC_KEY_P8`. + * + * ASC_KEY_PATH IS NOT A CREDENTIAL SOURCE (2026-10-21, Kody rule 42 on PR + * #271): not as a fallback, not as a lower priority, not at all. A named + * path is ignored wherever it appears, so a stale or foreign path in an + * operator's shell can never decide which credential signs a request. + * + * The command line never carries key material: + * validate check that a key on stdin holds a usable key + * write read a key on stdin and write it to a mode-600 file + * + * WHY NOTHING PRINTS THE KEY (2026-10-21, Kody rule 42 on PR #271): + * A key on stdout is a key in a log, in a CI transcript and in whatever + * captures that output next. So `validate` reports the STATUS of a key and + * never the key itself, and the key reaches a consumer through the process + * environment: never in an argument, never on stdout, never in a log line. + * + * WHAT THE ENVIRONMENT IS NOT (2026-10-21, audit on PR #271): + * The key is not in argv, not on stdout and not in a log line, and a child + * is given an allowlist of variables instead of a copy of the parent's + * environment. The environment of a process is still readable by another + * process of the same user on this platform: `ps eww` and `ps -E` show it, + * and the PEM envelope is in the listing of the key-bearing child. So the + * handoff keeps the key out of the places a process listing is read for (the + * command line) and out of every log, and it does not claim more than that. + */ +import { writeFileSync } from "node:fs"; +import { createPrivateKey } from "node:crypto"; + +// Normalizes a PEM private key and refuses anything that is not one. +// +// The envelope alone is not enough: a truncated key, an armored block or a +// mangled export carries the envelope and still fails to parse, and an +// unparsable key that reaches the signer comes back as a raw stack trace +// instead of a credential error. So the key is parsed before it is accepted. +// +// Returns the normalized PEM, or null when the value is not a private key. +export function normalizePrivateKey(value) { + const pem = String(value || "").replace(/\r\n/g, "\n").trim(); + if (!pem) return null; + if (!/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/.test(pem)) return null; + try { + createPrivateKey(pem); + } catch { + return null; + } + return pem.endsWith("\n") ? pem : `${pem}\n`; +} + +// Writes a key to a file only its owner can read. +// +// The key arrives on stdin, so it never becomes a command argument, and the +// file is created with mode 600 whatever the umask of the caller says. The +// file is a staging copy for the Apple tools that need a key on disk, and it +// is never read back as a credential: the credential comes from the +// environment or from fleet-secrets. +export function writePrivateKeyFile(keyPath, value) { + const pem = normalizePrivateKey(value); + if (!pem) return { error: "the value is not a PEM private key" }; + try { + writeFileSync(keyPath, pem, { mode: 0o600 }); + } catch { + return { error: `the key file ${keyPath} could not be written` }; + } + return { pem }; +} + +// The variables the App Store Connect client needs from the environment. +// +// The child's environment is an allowlist, never a copy of the parent's: a +// wholesale copy hands every credential on the operator's machine to the +// child, including the ones that have nothing to do with App Store Connect. +// These are the variables the client reads (see resolveCredential in +// asc-api.mjs), plus PATH and HOME, which it needs to find fleet-secrets and +// to reach the bootstrap Keychain. +export const CLIENT_ENV_ALLOWLIST = [ + "PATH", + "HOME", + "TMPDIR", + "LANG", + "LC_ALL", + "ASC_API_BASE", + "ASC_REQUEST_TIMEOUT_MS", + "ASC_KEY_ID", + "ASC_ISSUER_ID", + "APPLE_API_KEY_ID", + "APPLE_API_ISSUER_ID", + "APPLE_API_KEY_P8_BASE64", + "FLEET_SECRETS_BIN", + "FLEET_SECRETS" +]; + +// Builds the environment a child of the key handoff runs with. +// +// Only the allowlisted variables cross over, and the key is added +// separately: it goes in as ASC_KEY_P8 and in no other variable. +export function clientEnvironment(env = process.env, extra = {}) { + const child = {}; + for (const name of CLIENT_ENV_ALLOWLIST) { + const value = env[name]; + if (typeof value === "string" && value.length > 0) child[name] = value; + } + return { ...child, ...extra }; +} + +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + return Buffer.concat(chunks).toString("utf8"); +} + +async function main() { + const [command, target] = process.argv.slice(2); + if (command === "validate") { + const pem = normalizePrivateKey(await readStdin()); + if (!pem) { + console.error("asc-key: the value is not a PEM private key"); + return 1; + } + // The key is not printed here either: see the module header. + console.log("asc-key: the value is a PEM private key"); + return 0; + } + if (command === "write") { + if (!target) { + console.error("asc-key: usage: asc-key.mjs write "); + return 1; + } + const written = writePrivateKeyFile(target, await readStdin()); + if (written.error) { + console.error(`asc-key: ${written.error}`); + return 1; + } + return 0; + } + console.error("asc-key: usage: asc-key.mjs validate | write "); + return 1; +} + +if (process.argv[1] && process.argv[1].endsWith("asc-key.mjs")) { + main().then((code) => { process.exitCode = code; }); +} diff --git a/scripts/ios-fleet/capture-appstore-screenshots.sh b/scripts/ios-fleet/capture-appstore-screenshots.sh new file mode 100755 index 00000000..6258192d --- /dev/null +++ b/scripts/ios-fleet/capture-appstore-screenshots.sh @@ -0,0 +1,472 @@ +#!/usr/bin/env bash +# +# Capture App Store screenshots for the CodeCaps iOS companion. +# +# Runs on a macOS runner that has an iOS simulator runtime. Builds the +# companion for one iPhone and one iPad simulator, seeds each simulator with a +# realistic quota snapshot, and writes one PNG per screen at the exact pixel +# size App Store Connect accepts. +# +# The companion reads its readings from +# /Library/Application Support/CodeCaps/quota-windows.json +# (see CompanionQuotaModel.loadLocalFallback), so seeding that file is enough +# to put real-looking quota cards on screen without a live sync endpoint. +# +# The pixel size is measured from the capture rather than assumed, and every +# file has to land on a size App Store Connect accepts for its display class. +# A simulator whose native size Apple does not accept fails the run here +# instead of producing a file that is rejected at upload time. +# +# The app needs several seconds to draw its first frame on a cold simulator, +# so the script polls the screen and keeps the first frame that holds more +# than the status bar. A fixed sleep captured a blank window, and a blank +# window is a screenshot of nothing at all. +# +# Usage: scripts/ios-fleet/capture-appstore-screenshots.sh [OUTPUT_DIR] +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$REPO_ROOT/ios/CodeCapsCompanion" +SCHEME="CodeCapsCompanion" +# Where the PNGs are written. The argument wins, then the environment, then +# the default. The environment comes second so a caller with nowhere else to +# write can say so, and so a script that sources this one to reuse its +# functions does not scribble into the repository. +OUT_DIR="${1:-${OUT_DIR:-${REPO_ROOT}/.build/appstore-screenshots}}" +DERIVED_ROOT="${DERIVED_ROOT:-${RUNNER_TEMP:-/tmp}/codecaps-appstore-derived}" +# Where the ink measurement writes its error, so a failure can be reported +# instead of being swallowed by the poll loop. +INK_ERROR_FILE="$DERIVED_ROOT/ink-error.txt" + +# How long to wait for the app to draw a screen, and how often to look. A +# cold simulator took about eighteen seconds to show its first frame and a +# warm one about nine, so the budget is generous on purpose: a slow frame is +# cheap, a blank one is a wasted run. +RENDER_TIMEOUT_SEC="${RENDER_TIMEOUT_SEC:-180}" +RENDER_POLL_SEC="${RENDER_POLL_SEC:-2}" +# The share of non-background pixels a frame has to hold before it counts as +# a drawn screen. A window that has drawn nothing but its status bar measures +# about 0.3 percent, a frame that has drawn the navigation bar and no content +# about 4, and the finished quota list between 40 and 55. Ten percent is +# clear of both ends: a half-drawn screen is never mistaken for a finished one. +RENDER_MIN_PERCENT="${RENDER_MIN_PERCENT:-10.0}" +# The two settings screens are not the quota list. They are a navigation bar +# and a short form, so they cover far less of the screen than the quota cards +# do, and the same ten percent would be the wrong bar for them. On an iPad +# the same content covers a smaller share of a bigger screen, so the settings +# bar is lower than the quota bar. +RENDER_MIN_PERCENT_SETTINGS="${RENDER_MIN_PERCENT_SETTINGS:-5.0}" +# The share of the content region that has to hold ink. The content region is +# everything below the status bar and the navigation bar, so a frame that has +# drawn only its navigation bar measures zero here however much of the frame +# it covers. This is the measurement that tells a finished screen from a +# half-drawn one, and it is the same bar for every screen. +RENDER_MIN_CONTENT_PERCENT="${RENDER_MIN_CONTENT_PERCENT:-0.5}" +# How close two consecutive measurements have to be to count as unchanged, +# and how many polls have to agree before the frame is kept. Holding still is +# an extra condition on top of the thresholds, never a way around them. +RENDER_STABLE_DELTA="${RENDER_STABLE_DELTA:-0.2}" +RENDER_STABLE_POLLS="${RENDER_STABLE_POLLS:-2}" + +mkdir -p "$OUT_DIR" +# The ink measurement writes its error here, so the directory has to exist +# before the first frame is measured. +mkdir -p "$DERIVED_ROOT" + +# App Store Connect accepts these display sizes. The list is Apple's own: +# developer.apple.com/help/app-store-connect/reference/screenshot-specifications +# +# iPhone 6.9" 1320 x 2868 (iPhone 16 Pro Max and friends) +# iPhone 6.7" 1290 x 2796 (iPhone 16 Plus and friends) +# iPhone 6.5" 1284 x 2778 (iPhone 13 Pro Max and friends) +# iPhone 6.5" 1242 x 2688 (iPhone 12 Pro Max and friends) +# iPad 13" 2064 x 2752 (iPad Pro 13-inch, M4 and M5) +# iPad 12.9" 2048 x 2732 (iPad Pro 12.9-inch, 6th generation) +# +# The iPad Pro 12.9-inch of the 2nd generation captures at 2048 x 2732 like +# the other 12.9-inch Pro generations. There is no 2000 x 2664 device. +# +# The first entry for each display class is the one the App Store Connect +# screenshot set expects, and the upload step maps a captured file to its set +# from these numbers. +IPHONE_PREFERRED="iPhone 17 Pro Max|iPhone 16 Pro Max|iPhone 15 Pro Max|iPhone 14 Pro Max|iPhone 17 Plus|iPhone 16 Plus|iPhone 15 Plus|iPhone 14 Plus|iPhone 13 Pro Max|iPhone 12 Pro Max" +IPAD_PREFERRED="iPad Pro 13-inch (M5)|iPad Pro 13-inch (M4)|iPad Pro 12.9-inch (6th generation)|iPad Pro 12.9-inch (5th generation)|iPad Pro 12.9-inch (3rd generation)" + +# log() writes to stderr on purpose: it is called from inside command +# substitution in some places, and anything on stdout would be read back as +# part of a captured value. +log() { echo "capture: $*" >&2; } +fail() { echo "::error::capture: $*" >&2; exit 1; } + +# Print the UDID of the first available simulator whose display name matches +# one of the pipe-separated preferred names, in order. +# +# The picker matches on a prefix, because Xcode names some iPads with their +# storage variant ("iPad Pro 13-inch (M5) (16GB)"), and an exact match would +# find no iPad at all on a current runner. Its tests live next to it in +# scripts/ios-fleet/test_pick_simulator.py. +pick_simulator() { + python3 "$SCRIPT_DIR/pick_simulator.py" "$1" +} + +# Print "x" for an image, or nothing if it cannot be read. +image_size() { + sips -g pixelWidth -g pixelHeight "$1" 2>/dev/null \ + | awk '/pixelWidth/ {w = $2} /pixelHeight/ {h = $2} END {if (w && h) print w "x" h}' +} + +# Print the App Store Connect display class and the device family for a pixel +# size, separated by a pipe, or nothing if the size is not one Apple accepts. +# +# 1320x2868 -> APP_IPHONE_67|iphone +# 2064x2752 -> APP_IPAD_PRO_129|ipad +# +# App Store Connect has no APP_IPHONE_69 and no APP_IPAD_PRO_13: the +# 6.9-inch iPhone size lives in APP_IPHONE_67 and the 13-inch iPad size in +# APP_IPAD_PRO_129. +class_for_size() { + case "$1" in + 1320x2868|1290x2796) echo "APP_IPHONE_67|iphone" ;; + 1284x2778|1242x2688) echo "APP_IPHONE_65|iphone" ;; + 2064x2752) echo "APP_IPAD_PRO_129|ipad" ;; + 2048x2732) echo "APP_IPAD_PRO_3GEN_129|ipad" ;; + esac +} + +# Write a realistic quota snapshot to the file named by the first argument. +seed_snapshot() { + local target="$1" + python3 - "$target" <<'PY' +import datetime, json, os, sys + +target = sys.argv[1] +os.makedirs(os.path.dirname(target), exist_ok=True) + +now = datetime.datetime.now(datetime.timezone.utc) +observed = now.isoformat(timespec="seconds").replace("+00:00", "Z") + + +def reset_in(hours, minutes=0): + when = now + datetime.timedelta(hours=hours, minutes=minutes) + return when.isoformat(timespec="seconds").replace("+00:00", "Z") + + +# (id, provider, providerKey, providerLabel, label, window token, percent, reset offset) +# +# The window tokens are the ones QuotaWindowVocabulary.cadenceFromToken +# recognises ("5h", "7d", "1d", "1m"), so each card shows a clean cadence +# caption. Two windows of the same provider must have different cadences: +# a second window of the same cadence is folded in as a duplicate source +# rather than shown as its own row. Antigravity is left out on purpose, as +# the app pools its windows into per-model sections and it would not read as +# one platform card. +providers = [ + ("anthropic-5h", "anthropic", "anthropic", "Claude Code", "5-hour window", "5h", 72.0, (3, 40)), + ("anthropic-7d", "anthropic", "anthropic", "Claude Code", "7-day window", "7d", 78.0, (86, 0)), + ("openai-weekly", "openai", "openai", "Codex", "7-day window", "7d", 61.0, (41, 15)), + ("cursor-monthly", "cursor", "cursor", "Cursor", "1-month window", "1m", 34.0, (313, 0)), + ("xai-monthly", "xai", "xai", "xAI", "1-month window", "1m", 55.0, (212, 0)), + ("minimax-5h", "minimax", "minimax", "MiniMax", "5-hour window", "5h", 38.0, (2, 20)), +] + +windows = [] +for (wid, provider, key, _label, label, token, percent, (hours, minutes)) in providers: + windows.append({ + "id": wid, + "provider": provider, + "providerKey": key, + "label": label, + "window": token, + "remainingPercent": percent, + "isExhausted": False, + "resetAt": reset_in(hours, minutes), + "occurredAt": observed, + "source": "studio-mac", + "via": "oauth", + }) + +groups = [] +for index, (_wid, provider, key, provider_label, _label, _token, _percent, _offset) in enumerate(providers): + if any(group["provider"] == provider for group in groups): + continue + groups.append({ + "provider": provider, + "providerKey": key, + "providerLabel": provider_label, + "via": "oauth", + "sortOrder": index, + }) + +with open(target, "w", encoding="utf-8") as snapshot: + json.dump({"windows": windows, "providerGroups": groups}, snapshot, indent=2) +print(f"seeded {len(windows)} windows across {len(groups)} platforms -> {target}") +PY +} + +# Wait until the app has drawn its first frame, then leave that frame at the +# path given by the second argument. The third argument is the share of the +# frame the screen has to hold before it counts as drawn. +# +# The companion takes seconds to render on a cold simulator, and a fixed sleep +# captured a blank window more than once. This polls instead: take a frame, +# measure how much of it is not background, and stop as soon as the screen +# holds enough of it. The frame has to hold its content and to hold still: +# a screen that is still filling in has not finished drawing even when it has +# already cleared the thresholds. +# +# Print the share of the whole frame that is not background, and the share of +# the part below the header, as two bare numbers. +# +# The measurement is the only thing that tells a drawn screen from a blank +# one, so it is worth the seconds it costs, and it is why the background is +# found from a strided sample rather than from every byte of the frame. +measure_ink() { + # screenshot_ink.py exits 1 when the frame holds less than its own default + # minimum, which is exactly the blank-frame case the poll loop below has to + # survive. It exits 2 when the measurement itself failed, which is not a + # blank frame: swallowing that would spend the whole render budget on every + # screen and then report the wrong reason, so it is reported and the run + # stops there. + local status=0 + python3 "$SCRIPT_DIR/screenshot_ink.py" "$1" 2>"$INK_ERROR_FILE" || status=$? + if [ "$status" -ge 2 ]; then + cat "$INK_ERROR_FILE" >&2 + return "$status" + fi + return 0 +} + +# Decide whether a frame holds enough to be a finished screen. +# +# It has to hold its content, and the content has to be below the header: a +# frame that has drawn only its navigation bar covers a few percent of the +# frame, so the whole-frame share on its own cannot tell it from a finished +# screen. The content share can, and it is the same bar for every screen. +frame_clears_thresholds() { + local overall="$1" content="$2" min_overall="$3" min_content="$4" + awk -v p="$overall" -v m="$min_overall" -v c="$content" -v k="$min_content" \ + 'BEGIN { exit !(p >= m && c >= k) }' +} + +# Decide whether two readings are the same, within the allowed difference. +readings_match() { + local a="$1" b="$2" + awk -v p="$a" -v q="$b" -v d="$RENDER_STABLE_DELTA" \ + 'BEGIN { exit !((p - q < 0 ? q - p : p - q) <= d) }' +} + +wait_for_render() { + local udid="$1" frame="$2" min_overall="$3" min_content="$4" + local deadline=$((SECONDS + RENDER_TIMEOUT_SEC)) + local previous_overall="" previous_content="" stable=0 + local overall="" content="" + while [ "$SECONDS" -lt "$deadline" ]; do + xcrun simctl io "$udid" screenshot --type=jpeg "$frame" >/dev/null 2>&1 || true + if [ -s "$frame" ]; then + local status=0 reading="" + reading="$(measure_ink "$frame")" || status=$? + if [ "$status" -ge 2 ]; then + fail "could not measure the screen on $udid" + fi + if [ -n "$reading" ]; then + overall="${reading%% *}" + content="${reading##* }" + # Holding still is a second condition on top of the thresholds, never + # a way around them: a frame that is still filling in has not + # finished drawing even when it has already cleared them. + if frame_clears_thresholds "$overall" "$content" "$min_overall" "$min_content"; then + if [ -n "$previous_overall" ] \ + && readings_match "$overall" "$previous_overall" \ + && readings_match "$content" "$previous_content"; then + stable=$((stable + 1)) + if [ "$stable" -ge "$RENDER_STABLE_POLLS" ]; then + log "screen settled at ${overall}% overall and ${content}% below the header" + return 0 + fi + else + stable=0 + fi + else + stable=0 + fi + previous_overall="$overall" + previous_content="$content" + fi + fi + sleep "$RENDER_POLL_SEC" + done + fail "the app drew no screen within ${RENDER_TIMEOUT_SEC}s on $udid (last reading ${overall:-none}% overall, ${content:-none}% below the header)" +} + +# Check a captured file against the size App Store Connect accepts and +# against the size the other captures on this device have. The file to keep +# is left in CHECKED_FILE: the check must not delete anything, because a +# command substitution would swallow whatever the delete printed. +CHECKED_FILE="" +check_capture() { + local file="$1" want="$2" + local size alpha + size="$(image_size "$file")" + if [ -z "$size" ]; then + fail "$(basename "$file") could not be read" + fi + if [ "$size" != "$want" ]; then + fail "$(basename "$file") is $size, but the other captures are $want" + fi + if [ -z "$(class_for_size "$size")" ]; then + fail "$(basename "$file") is $size, which App Store Connect does not accept for any display class" + fi + alpha="$(sips -g hasAlpha "$file" 2>/dev/null | awk '/hasAlpha/ {print $2}')" + if [ "$alpha" = "yes" ]; then + fail "$(basename "$file") still has an alpha channel, which App Store Connect rejects" + fi + CHECKED_FILE="$file" +} + +# The app bundle the capture installs on both simulators. +# +# Both simulators run the same iOS runtime and the same Debug-iphonesimulator +# product, so one build serves both. Building per device compiled the +# companion and the shared QuotaCore files from scratch a second time, which +# added minutes to the job and reported any compile failure twice. +APP_PATH="$DERIVED_ROOT/app/Build/Products/Debug-iphonesimulator/CodeCapsCompanion.app" + +# Build the companion once, against the first simulator. +build_app() { + local udid="$1" + log "building the companion" + ( cd "$PROJECT_DIR" && xcodebuild -project CodeCapsCompanion.xcodeproj \ + -scheme "$SCHEME" \ + -destination "platform=iOS Simulator,id=$udid" \ + -derivedDataPath "$DERIVED_ROOT/app" \ + CODE_SIGNING_ALLOWED=NO CODE_SIGN_IDENTITY="" \ + build >/dev/null ) + [ -d "$APP_PATH" ] || fail "no app built at $APP_PATH" +} + +# Install, seed, launch and capture on one simulator. +capture_on() { + local udid="$1" label="$2" kind="$3" + local derived="$DERIVED_ROOT/$label" + local container group_container bundle_id + + # The single build writes to "$DERIVED_ROOT/app", so the per-device capture + # directory is no longer created as a side effect of building. Without this + # the raw frame has nowhere to land and every poll reads an empty file. + mkdir -p "$derived" + + log "booting $label ($udid)" + xcrun simctl boot "$udid" 2>/dev/null || true + xcrun simctl bootstatus "$udid" -b >/dev/null + + bundle_id="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP_PATH/Info.plist")" + log "installing $bundle_id" + xcrun simctl install "$udid" "$APP_PATH" + + container="$(xcrun simctl get_app_container "$udid" "$bundle_id" data)" + # The companion reads the App Group container when the build is signed and + # its own sandbox when it is not, so seed whichever ones exist. + group_container="$(xcrun simctl get_app_container "$udid" "$bundle_id" groups 2>/dev/null | head -1)" + + # Three screens per device: the quota list, the platform-order editor, and + # the reset-alarm settings. The last two are tabs of the settings sheet, so + # each one needs the sheet opened as well as its own argument. The app is + # launched before every capture, so no shot is ever the home screen. + local name + for name in fleet-status platform-order alarms; do + local args=() + # The quota list fills the screen and the settings sheets do not, so each + # one is measured against a bar that fits the screen it shows. + local min="$RENDER_MIN_PERCENT" + case "$name" in + platform-order) + args=(-openSettings -reorderPlatforms) + min="$RENDER_MIN_PERCENT_SETTINGS" + ;; + alarms) + args=(-openSettings -openAlarms) + min="$RENDER_MIN_PERCENT_SETTINGS" + ;; + esac + xcrun simctl terminate "$udid" "$bundle_id" 2>/dev/null || true + # Re-seed before each launch so the "Updated" time on the cards is the + # time of the shot rather than the time the run started. + seed_snapshot "$container/Library/Application Support/CodeCaps/quota-windows.json" >/dev/null + if [ -n "$group_container" ]; then + seed_snapshot "$group_container/quota-windows.json" >/dev/null + fi + # ${args[@]:+...} rather than "${args[@]}": the first capture passes no + # arguments, and Bash 3.2 (the runner's system Bash) reads an empty array + # under `set -u` as an unbound variable and exits. + xcrun simctl launch --terminate-running-process "$udid" "$bundle_id" \ + ${args[@]:+"${args[@]}"} >/dev/null + + # The simulator writes JPEG, which cannot carry an alpha channel, and + # App Store Connect rejects a screenshot that has one. It is converted + # back to PNG so the upload step can read the size out of the header. + local raw="$derived/$label-$name.jpg" + wait_for_render "$udid" "$raw" "$min" "$RENDER_MIN_CONTENT_PERCENT" + sips -s format png "$raw" --out "$OUT_DIR/$label-$name.png" >/dev/null + log "captured $label $name" + done + xcrun simctl terminate "$udid" "$bundle_id" 2>/dev/null || true + + # The captured size decides the display class and the file name, so the + # output says what the pixels actually are. + local size class_name family + size="$(image_size "$OUT_DIR/$label-fleet-status.png")" + class_name="$(class_for_size "$size")" + [ -n "$class_name" ] || fail "$label rendered at $size, which App Store Connect does not accept" + IFS='|' read -r display_type family <<<"$class_name" + # The family we booted has to be the family the pixels are, or the two + # captures would be uploaded into the wrong screenshot sets. + [ "$family" = "$kind" ] \ + || fail "$label rendered at $size, which is a $family capture, not a $kind one" + + # The file is named after the display set it belongs to, so the two + # families cannot be mixed up when the files are handed to the upload. + local stem + stem="$(echo "$display_type" | tr '[:upper:]' '[:lower:]')" + for name in fleet-status platform-order alarms; do + check_capture "$OUT_DIR/$label-$name.png" "$size" + mv "$CHECKED_FILE" "$OUT_DIR/$stem-$name.png" + log "$display_type $name ($size)" + done +} + +main() { + command -v xcodegen >/dev/null 2>&1 || fail "xcodegen is not installed" + # Always regenerate. The checked-in project is stale: it is missing shared + # QuotaCore files the companion compiles against, and a checkout gives + # project.yml and the project file the same mtime, so comparing them would + # skip the regeneration and build a project that does not compile. + log "regenerating the companion project" + ( cd "$PROJECT_DIR" && xcodegen generate ) + + local iphone_udid ipad_udid + if ! iphone_udid="$(pick_simulator "$IPHONE_PREFERRED")"; then + fail "no preferred iPhone simulator on this runner" + fi + if ! ipad_udid="$(pick_simulator "$IPAD_PREFERRED")"; then + fail "no preferred iPad simulator on this runner" + fi + + log "iPhone simulator $iphone_udid" + log "iPad simulator $ipad_udid" + + build_app "$iphone_udid" + + capture_on "$iphone_udid" "iphone" "iphone" + capture_on "$ipad_udid" "ipad" "ipad" + + log "captured:" + ls -1 "$OUT_DIR" +} + +# The capture is run as a script, and its decisions are also exercised by the +# contract test, which sources it. The guard is what makes the second +# possible. +if [ "${CAPTURE_SOURCE_ONLY:-}" != "1" ]; then + main "$@" +fi \ No newline at end of file diff --git a/scripts/ios-fleet/pick_simulator.py b/scripts/ios-fleet/pick_simulator.py new file mode 100644 index 00000000..8e04b885 --- /dev/null +++ b/scripts/ios-fleet/pick_simulator.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Print the UDID of the first available iOS simulator matching a preferred name. + +The capture script needs one iPhone simulator and one iPad simulator, and it +needs them to be the models whose screen size App Store Connect accepts. The +preferred names are given as a pipe-separated list, most wanted first, and the +first device whose name starts with one of them wins. + +The match is a prefix rather than an equality because Xcode names some iPads +with their storage variant ("iPad Pro 13-inch (M5) (16GB)"). An exact match +finds no iPad at all on a runner that only has the current runtime, and the +capture then fails after an hour of runner time. +""" +from __future__ import annotations + +import json +import subprocess +import sys + + +def select_device(devices, preferred): + """Return the first available device whose name starts with a preferred name. + + `devices` is the flat list simctl reports; `preferred` is a list of names + in order of preference. Returns the device dict, or None when nothing + matches. + """ + available = [device for device in devices if device.get("isAvailable")] + for want in preferred: + for device in available: + if device.get("name", "").startswith(want): + return device + return None + + +def list_devices(): + """Return every simulator device simctl reports, available or not.""" + raw = subprocess.run( + ["xcrun", "simctl", "list", "devices", "available", "-j"], + capture_output=True, + text=True, + check=True, + ).stdout + data = json.loads(raw) + return [device for group in data.get("devices", {}).values() for device in group] + + +def main(argv): + if len(argv) != 2: + print( + "usage: pick_simulator.py ", + file=sys.stderr, + ) + return 2 + device = select_device(list_devices(), argv[1].split("|")) + if device is None: + return 1 + print(device["udid"]) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/ios-fleet/screenshot_ink.py b/scripts/ios-fleet/screenshot_ink.py new file mode 100644 index 00000000..2a08289c --- /dev/null +++ b/scripts/ios-fleet/screenshot_ink.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Measure how much of a simulator screenshot is not background. + +The companion app needs several seconds to draw its first frame on a cold +simulator, and a fixed sleep captured a blank window more than once. The +capture script polls instead: it takes a frame and asks this module whether +the screen holds more than the status bar. + +The measurement is the share of pixels that differ from the frame's own +background colour. A blank app window is the background plus the status bar, +which measures about 0.2 percent; a drawn screen measures tens of percent. +The threshold sits far from both, so a slow frame is never mistaken for a +blank one and a blank one is never mistaken for a slow frame. +""" +from __future__ import annotations + +import argparse +import os +import struct +import subprocess +import sys +import tempfile +from collections import Counter + +# The share of non-background pixels a drawn screen has to reach before the +# capture script keeps the frame. A blank window measures about 0.2 percent +# and a drawn one about 48 percent. +DEFAULT_MIN_PERCENT = 1.0 + +# The modal byte value is taken from a strided sample rather than from every +# byte of the channel. A full Counter over the 3.8 million bytes of one +# channel of a 1320x2868 frame took about 0.96 seconds, and the capture script +# measures every frame it throws away, so that cost was paid several times per +# screen. A sample of every sixteenth byte names the same background in +# about 0.02 seconds, and the count that follows it is still exact because it +# runs over the whole channel. +MODE_SAMPLE_STRIDE = 16 + +# A frame whose sampled background does not hold at least this share of the +# channel has no dominant background, so the sample is not trustworthy and the +# exact mode is computed instead. A simulator frame is a page on a flat +# background and never reaches this, so the slow path is the exception. +DOMINANT_BACKGROUND_MINIMUM = 0.10 + +# The share of the frame's height that is header: the status bar and the +# navigation bar. Both are drawn before the content, so a frame that has drawn +# only its header has no ink at all below this line. The content region is +# what tells a finished screen from a half-drawn one, because a threshold on +# the whole frame cannot separate "a navigation bar and nothing else" from "a +# screen with a little on it". +HEADER_SHARE = 0.15 + + +def background_value(channel): + """Return the most common byte value in `channel`. + + The value is found from a strided sample and then confirmed against the + whole channel, so the answer is exact for any frame that has a dominant + background. A frame without one falls back to counting every value. + """ + candidate = Counter(channel[::MODE_SAMPLE_STRIDE]).most_common(1)[0][0] + if channel.count(candidate) >= len(channel) * DOMINANT_BACKGROUND_MINIMUM: + return candidate + return Counter(channel).most_common(1)[0][0] + + +def ink_percent(channels): + """Return the share of pixels that differ from the frame's background. + + `channels` is a sequence of byte strings, one per colour channel, each + holding one byte per pixel. The background is the most common value of a + channel, and a pixel counts as ink when its byte differs from that + channel's background. The largest per-channel count wins: it over-counts + a pixel that differs in more than one channel and never under-counts one + that differs in any. + """ + if not channels: + return 0.0 + worst = 0 + for channel in channels: + if not channel: + continue + background = background_value(channel) + differing = len(channel) - channel.count(background) + worst = max(worst, differing) + return worst * 100.0 / len(channels[0]) + + +def read_bmp_frame(path): + """Return the colour channels of a BMP file, with its size. + + Both 24-bit and 32-bit BMPs are read, in either row order, because that is + what `sips` writes depending on whether the source had an alpha channel. + The channel order inside a pixel does not matter: the measurement only + compares each channel against its own background. + + The rows come back in image order, so the first row of a channel is the + top of the frame and the last is the bottom. + """ + with open(path, "rb") as handle: + data = handle.read() + if data[:2] != b"BM": + raise ValueError(f"{path} is not a BMP file") + # The header fields read below end at byte 30. A file shorter than that + # cannot carry them, and unpacking them anyway raises struct.error, which + # is not a ValueError and used to escape as an unhandled traceback. + if len(data) < 30: + raise ValueError(f"{path} is {len(data)} bytes, too short to be a BMP header") + offset = struct.unpack_from("= args.min_percent else 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/ios-fleet/ship-testflight.sh b/scripts/ios-fleet/ship-testflight.sh index 43254fae..36485624 100755 --- a/scripts/ios-fleet/ship-testflight.sh +++ b/scripts/ios-fleet/ship-testflight.sh @@ -81,9 +81,15 @@ # fleet runners). # # Secrets (never printed): -# ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH (env first, e.g. CI; else Infisical via +# ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8 (env first, e.g. CI; else Infisical via # `fleet-secrets get` from Fleet-OPS /asc: ASC_KEY_ID, ASC_ISSUER_ID and the .p8 in ASC_KEY_P8, # written to a 0600 temp file and deleted on exit) +# A named ASC_KEY_PATH is not a credential source at all: it is ignored, so +# a stale or foreign path in an operator's shell can never decide which +# credential signs a request. The credential comes from the environment or +# from Infisical, and the key is handed to the node client in the client's +# environment (see asc_client). altool and Xcode are given the path of the +# staged copy this script writes for them. # or Xcode-signed-in session for destination=upload export # SENTRY_AUTH_TOKEN (dSYM + Size Analysis upload; env or `fleet-secrets get`) # SENTRY_DSN (injected into xcodebuild so Cocoa plist $(SENTRY_DSN) is set) @@ -204,56 +210,117 @@ fs_get() { # Load ASC credentials into the CURRENT shell (must not run inside $(...) or the # exported ASC_* vars are discarded with the subshell). Sets AUTH_MODE to -# "api_key" or "none". Never prints secret values. Order: an explicit -# ASC_KEY_PATH/ASC_KEY_ID/ASC_ISSUER_ID in the environment, else Infisical. +# "api_key" or "none". Never prints secret values. +# +# WHY THE KEY IS HANDED OVER IN THE ENVIRONMENT (2026-10-21, Kody rule 42): +# The private key is a secret, and the approved secret sources are the process +# environment, a captured `fleet-secrets get NAME` and bootstrap-only Keychain +# credentials. A caller-supplied file is not one of them, so the node client +# never reads a key file. This shell layer already owns the credential for +# altool and Xcode, so it hands the key to the client in the environment, +# which is where the client looks for it. +# +# THE CREDENTIAL IS RESOLVED FROM TWO PLACES (2026-10-21, Kody rule 42 on +# PR #271): +# 1. ASC_KEY_P8, when it holds a PEM private key and comes with its key id +# 2. Infisical Fleet-OPS /asc (or /authkey--p8) +# A disk credential is not a source at all: a named ASC_KEY_PATH is ignored, +# so a stale or foreign path in an operator's shell can never sign the +# upload with the wrong key, and App Store Connect would answer 401 without +# saying which input was wrong. The key material never enters a command +# argument and never reaches a log line. load_secrets() { AUTH_MODE="none" - if [[ -n "${ASC_KEY_ID:-}" && -n "${ASC_ISSUER_ID:-}" && -n "${ASC_KEY_PATH:-}" ]]; then - if [[ -f "$(expand_tilde "$ASC_KEY_PATH")" ]]; then - AUTH_MODE="api_key" - return 0 + local kid="${ASC_KEY_ID:-}" iss="${ASC_ISSUER_ID:-}" p8="" fkid="" lower + local key_pem="" key_path="" rc=0 + # A named ASC_KEY_PATH is not a credential source. It is remembered only + # so the run can say that it ignored it. + local named_path="${ASC_KEY_PATH:-}" + + # 1. An ambient ASC_KEY_P8. It is only usable when it really is a PEM + # private key and comes with the key id and issuer it belongs to. It is + # resolved first, so nothing can override it. + if [[ -n "${ASC_KEY_P8:-}" ]]; then + if [[ -z "$kid" || -z "$iss" ]]; then + log "ASC_KEY_P8 is set without ASC_KEY_ID and ASC_ISSUER_ID; ignoring it" + unset ASC_KEY_P8 + else + rc=0 + node "${FLEET_DIR}/asc-key.mjs" validate >/dev/null 2>&1 <<< "$ASC_KEY_P8" || rc=$? + if [[ "$rc" -ne 0 ]]; then + log "ASC_KEY_P8 is set but does not hold a PEM private key; ignoring it" + unset ASC_KEY_P8 + else + key_pem="$ASC_KEY_P8" + fi fi - log "ASC_KEY_PATH set but file missing (not printing path contents)" fi - local kid="${ASC_KEY_ID:-}" iss="${ASC_ISSUER_ID:-}" p8="" fkid="" lower - # The key id, issuer and private key must belong together: with nothing named in the + + # 2. The key id, issuer and private key must belong together: with nothing named in the # environment all three come from /asc; an env key id that is not the /asc one gets its # own .p8 from /authkey--p8, never the /asc key. - fkid="$(fs_get ASC_KEY_ID /asc)" || fkid="" - if [[ -z "$kid" ]]; then - kid="$fkid" - iss="$(fs_get ASC_ISSUER_ID /asc)" || iss="" - elif [[ -z "$iss" && "$kid" == "$fkid" ]]; then - # env named the /asc key itself: its issuer is the /asc one - iss="$(fs_get ASC_ISSUER_ID /asc)" || iss="" - fi - if [[ -z "$kid" || -z "$iss" ]]; then - log "ASC credentials missing: set ASC_KEY_ID/ASC_ISSUER_ID/ASC_KEY_PATH or add ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_P8 to Infisical /asc" - return 0 - fi - if [[ "$kid" == "$fkid" ]]; then - p8="$(fs_get ASC_KEY_P8 /asc)" || p8="" - else - lower="$(printf '%s' "$kid" | tr 'A-Z' 'a-z')" - p8="$(fs_get "AUTHKEY_${kid}" "/authkey-${lower}-p8")" || p8="" - fi - if [[ -z "$p8" ]]; then - log "missing the .p8 for key ${kid}: Infisical /asc ASC_KEY_P8 (the /asc key) or /authkey--p8 AUTHKEY_" - return 0 + if [[ -z "$key_pem" ]]; then + fkid="$(fs_get ASC_KEY_ID /asc)" || fkid="" + if [[ -z "$kid" ]]; then + kid="$fkid" + iss="$(fs_get ASC_ISSUER_ID /asc)" || iss="" + elif [[ -z "$iss" && "$kid" == "$fkid" ]]; then + # env named the /asc key itself: its issuer is the /asc one + iss="$(fs_get ASC_ISSUER_ID /asc)" || iss="" + fi + if [[ -z "$kid" || -z "$iss" ]]; then + log "ASC credentials missing: set ASC_KEY_ID/ASC_ISSUER_ID/ASC_KEY_P8 or add ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_P8 to Infisical /asc" + return 0 + fi + if [[ "$kid" == "$fkid" ]]; then + p8="$(fs_get ASC_KEY_P8 /asc)" || p8="" + else + lower="$(printf '%s' "$kid" | tr 'A-Z' 'a-z')" + p8="$(fs_get "AUTHKEY_${kid}" "/authkey-${lower}-p8")" || p8="" + fi + if [[ -z "$p8" ]]; then + log "missing the .p8 for key ${kid}: Infisical /asc ASC_KEY_P8 (the /asc key) or /authkey--p8 AUTHKEY_" + return 0 + fi + key_pem="$p8" + p8="" fi + + # The key has to be in a mode-600 file for altool and Xcode, and in the + # environment for the node client, which never reads a file. The file is + # written from the credential that was just resolved, so it is never read + # back as a credential. if [[ -z "$ASC_TMP_DIR" ]]; then ASC_TMP_DIR="$(umask 077; mktemp -d "${TMPDIR:-/tmp}/asc-key.XXXXXX")" fi - ( umask 077; printf '%s\n' "$p8" > "${ASC_TMP_DIR}/AuthKey_${kid}.p8" ) - chmod 600 "${ASC_TMP_DIR}/AuthKey_${kid}.p8" - p8="" - export ASC_KEY_ID="$kid" ASC_ISSUER_ID="$iss" ASC_KEY_PATH="${ASC_TMP_DIR}/AuthKey_${kid}.p8" + key_path="${ASC_TMP_DIR}/AuthKey_${kid}.p8" + if ! node "${FLEET_DIR}/asc-key.mjs" write "$key_path" <<< "$key_pem"; then + log "could not write the ASC key file; the credential is not usable" + return 0 + fi # altool searches API_PRIVATE_KEYS_DIR first, so no symlink into ~/.appstoreconnect is needed. export API_PRIVATE_KEYS_DIR="$ASC_TMP_DIR" + export ASC_KEY_ID="$kid" ASC_ISSUER_ID="$iss" ASC_KEY_PATH="$key_path" + # The client takes the key from the environment: a path is not an approved + # secret source, so it never reads one. + export ASC_KEY_P8="$key_pem" + key_pem="" + if [[ -n "$named_path" && "$named_path" != "$key_path" ]]; then + log "ASC_KEY_PATH is not a credential source; the credential came from the environment or from Infisical" + fi AUTH_MODE="api_key" return 0 } +# Run the App Store Connect client. +# +# The client takes the key from its environment and never reads a file (Kody +# rule 42). The key is never an argument, never on this script's stdout, and +# never in a log line. +asc_client() { + node "${FLEET_DIR}/asc-api.mjs" "$@" +} + # Map ship APP_KEY to Sentry project slug in org simple-with-us. sentry_project_for_app() { case "${APP_KEY}" in @@ -445,7 +512,7 @@ asc_latest_seq() { # stdout, and this function's stdout IS the sequence number. local errf rc errf="$(mktemp "${TMPDIR:-/tmp}/asc-seq-err.XXXXXX")" - out=$(node "${FLEET_DIR}/asc-api.mjs" latest-build-seq "$BUNDLE_ID" "$prefix" "$plat" 2>"$errf") + out=$(asc_client latest-build-seq "$BUNDLE_ID" "$prefix" "$plat" 2>"$errf") rc=$? set -e if [[ -s "$errf" ]]; then @@ -1024,7 +1091,7 @@ fi if [[ -n "$UPLOAD_ONLY_IPA" ]]; then [[ -f "$UPLOAD_ONLY_IPA" ]] || die "package not found: $UPLOAD_ONLY_IPA" - [[ "$AUTH_MODE" == "api_key" ]] || die "upload-only requires ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH (env) or Infisical /asc (ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8)" + [[ "$AUTH_MODE" == "api_key" ]] || die "upload-only requires ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_P8 (env) or Infisical /asc (ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8)" link_private_key "$ASC_KEY_PATH" "$ASC_KEY_ID" log "uploading existing package via altool (api key)" set +e @@ -1113,7 +1180,7 @@ ensure_tf_ready() { IOS_TF_NOTES_PREV_SHA="$PREV_SHIP_SHA" \ IOS_TF_NOTES_APP="$DISPLAY_NAME" \ IOS_TF_NOTES_IOS_PREFIX="$IOS_PATH_PREFIX" \ - node "${FLEET_DIR}/asc-api.mjs" ensure-tf-ready "$BUNDLE_ID" "$BUILD_NUM" "$MARKETING" \ + asc_client ensure-tf-ready "$BUNDLE_ID" "$BUILD_NUM" "$MARKETING" \ >"${LOG_DIR}/ensure-tf-ready.json" 2>"${LOG_DIR}/ensure-tf-ready.err" local rc=$? set -e @@ -1354,7 +1421,7 @@ if [[ "$AUTH_MODE" != "api_key" ]]; then log " 1) Create ASC API key (App Manager+) in App Store Connect" log " 2) Store ASC_KEY_ID and ASC_ISSUER_ID in Infisical Fleet-OPS /asc" log " 3) Store the .p8 contents as ASC_KEY_P8 in the same Infisical folder" - log " 4) Or export ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH for this run" + log " 4) Or export ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_P8 for this run" log " 5) Re-run: bash $0 $APP_KEY --upload-only \"$PACKAGE\"" log "Also ensure App Store Connect has an app record for ${BUNDLE_ID}." exit 3 diff --git a/scripts/ios-fleet/test-asc-client.mjs b/scripts/ios-fleet/test-asc-client.mjs new file mode 100644 index 00000000..eed3b677 --- /dev/null +++ b/scripts/ios-fleet/test-asc-client.mjs @@ -0,0 +1,378 @@ +// The App Store Connect client reports its own failures correctly. +// +// Two things go wrong easily here and both send the operator in the wrong +// direction. A host override that is not the production host has to be +// refused without a stack trace, because the stack trace names a module scope +// rather than the thing the operator has to change. And a request that never +// reached App Store Connect is not a verdict about the app, so it must not be +// reported as "app not found". +// +// These tests run the real client against a local server, or against a closed +// port, with a synthetic key. No network, no fleet-secrets, no real secret. +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { chmodSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { generateKeyPairSync } from "node:crypto"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const here = dirname(fileURLToPath(import.meta.url)); +const script = join(here, "asc-api.mjs"); +const root = mkdtempSync(join(tmpdir(), "asc-client-test-")); + +const keyPath = join(root, "synthetic.p8"); +const keyPem = generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey + .export({ type: "pkcs8", format: "pem" }); +writeFileSync(keyPath, keyPem); +// The staged key that ios-stage-asc-key.sh publishes is mode 600, and the +// shell layer refuses a key file other users can read. +chmodSync(keyPath, 0o600); + +const PROD = "https://api.appstoreconnect.apple.com"; + +function run(args, env = {}) { + return new Promise((resolve) => { + const child = spawn("node", [script, ...args], { + env: { + ...process.env, + ASC_KEY_ID: "SYNTHETICKEYID1", + ASC_ISSUER_ID: "synthetic-issuer", + // The client takes the key from the environment: a path on disk is + // not an approved secret source (Kody rule 42), so the shell layer + // hands the key over in ASC_KEY_P8. + ASC_KEY_P8: keyPem, + // The credential sources are cleared so an ambient ASC_KEY_PATH or + // APPLE_API_* on the machine running the suite cannot decide which + // credential a test gets. + ASC_KEY_PATH: "", + APPLE_API_KEY_ID: "", + APPLE_API_ISSUER_ID: "", + APPLE_API_KEY_P8_BASE64: "", + ...env + }, + stdio: ["ignore", "pipe", "pipe"] + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d; }); + child.stderr.on("data", (d) => { stderr += d; }); + child.on("close", (code) => resolve({ code, stdout, stderr })); + }); +} + +test("the production host is accepted when it is pinned explicitly", async () => { + // No arguments, so the run stops at the usage message and never touches + // the network. What matters is that the host was accepted at all: the + // refusal would exit 2 with a different message. + const { code, stderr } = await run([], { ASC_API_BASE: PROD }); + assert.equal(code, 1, `expected the usage exit, got ${code}: ${stderr}`); + assert.ok( + !/loopback/.test(stderr), + `pinning the production host was refused:\n${stderr}` + ); + assert.ok(/Usage: node asc-api.mjs/.test(stderr), "the usage message was not printed"); +}); + +test("a host that is neither the production host nor loopback is refused by name", async () => { + const { code, stderr } = await run([], { ASC_API_BASE: "https://not-the-real-host.example.com" }); + assert.equal(code, 2, `expected a named refusal, got ${code}: ${stderr}`); + assert.ok( + /must be the production host or a loopback address/.test(stderr), + `the refusal did not say what to change:\n${stderr}` + ); + assert.ok( + !/at Object\.|at Module\.|at file:/.test(stderr), + `the refusal came with a stack trace:\n${stderr}` + ); +}); + +test("a trailing slash on the override does not double the path separator", async () => { + const paths = []; + const server = createServer((req, res) => { + paths.push(req.url); + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("no"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${server.address().port}/`; + try { + await run(["GET", "/v1/apps"], { ASC_API_BASE: base }); + } finally { + server.close(); + } + assert.ok(paths.length > 0, "the server never received a request"); + for (const path of paths) { + assert.ok( + !path.includes("//"), + `the request path has a doubled separator: ${path}` + ); + } +}); + +test("a request that never reached App Store Connect is not reported as app not found", async () => { + // Port 1 on loopback has nothing listening, so the connection is refused + // and the request never leaves the machine. + const { code, stderr } = await run( + ["latest-build-seq", "com.example.nothing", "1.0"], + { ASC_API_BASE: "http://127.0.0.1:1" } + ); + assert.equal(code, 2, `expected a failed lookup, got ${code}: ${stderr}`); + assert.ok( + /never reached App Store Connect/.test(stderr), + `a transport failure was reported as a verdict about the app:\n${stderr}` + ); + assert.ok( + !/app not found/.test(stderr), + `a transport failure was reported as "app not found":\n${stderr}` + ); +}); + +test("the message that explains a failure is not lost on the way out", async () => { + // stderr is a pipe here, and a pending write to a pipe is not flushed by + // process.exit(), so the diagnostic could be dropped entirely. + const { code, stderr } = await run([], { ASC_API_BASE: "https://not-the-real-host.example.com" }); + assert.equal(code, 2); + assert.ok( + stderr.trim().length > 0, + "the failure left no message behind: the exit discarded the write" + ); +}); + +test("a call that never answers is reported as a timeout", async () => { + // The server accepts the connection and then says nothing, so the call + // has to be aborted. A timeout and a transport failure have different + // causes: one means App Store Connect was slow, the other that the + // request never got there, and the operator needs the one that is true. + const sockets = []; + const server = createServer((req, res) => { + // Never answer. + }); + server.on("connection", (socket) => sockets.push(socket)); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + let result; + try { + result = await run(["latest-build-seq", "com.example.nothing", "1.0"], { + ASC_API_BASE: base, + ASC_REQUEST_TIMEOUT_MS: "400" + }); + } finally { + for (const socket of sockets) socket.destroy(); + server.close(); + } + assert.equal(result.code, 2, `expected a failed lookup, got ${result.code}: ${result.stderr}`); + assert.ok( + /timed out/.test(result.stderr), + `a timeout was not reported as one:\n${result.stderr}` + ); + assert.ok( + !/never reached App Store Connect/.test(result.stderr), + `a timeout was reported as a transport failure:\n${result.stderr}` + ); +}); + +test("a rejected key is reported as a rejected key, not as a missing app", async () => { + // The credential is what failed, so saying the bundle id is unknown sends + // the operator after the wrong thing entirely. + const server = createServer((req, res) => { + res.writeHead(401, { "Content-Type": "text/plain" }); + res.end("unauthorized"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + let result; + try { + result = await run(["latest-build-seq", "com.example.nothing", "1.0"], { ASC_API_BASE: base }); + } finally { + server.close(); + } + assert.equal(result.code, 2, `expected a failed lookup, got ${result.code}: ${result.stderr}`); + assert.ok( + /rejected the key/.test(result.stderr), + `a rejected key was not named:\n${result.stderr}` + ); + assert.ok( + !/app not found/.test(result.stderr), + `a rejected key was reported as a missing app:\n${result.stderr}` + ); + // The fleet's masked preview, so the key in play can be identified without + // any of it being published. It is taken from the key material rather + // than from the PEM envelope: the envelope is the same for every key, so + // a preview of it names none of them. + const pem = keyPem; + const material = pem.replace(/-----[^-]+-----/g, "").trim(); + const expectedPreview = `${material.slice(0, 8)}…${material.slice(-4)}`; + assert.ok( + result.stderr.includes(expectedPreview), + `the failure did not carry the masked key preview ${expectedPreview}:\n${result.stderr}` + ); + // A preview with a newline in it splits the diagnostic into two lines, and + // ship-testflight.sh forwards only the lines starting with "latest-build-seq:", + // so the second half — which is where the HTTP status is — is lost. + const diagnostic = result.stderr.split("\n").find((line) => line.startsWith("latest-build-seq:")); + assert.ok(diagnostic, `no forwarded diagnostic line:\n${result.stderr}`); + assert.ok( + /HTTP 401/.test(diagnostic), + `the HTTP status was split off the forwarded line:\n${result.stderr}` + ); + // The key id is public: it travels in every request to App Store + // Connect, and it is the part that says which credential was refused. + assert.ok( + result.stderr.includes("SYNTHETICKEYID1"), + `the failure did not name the key id:\n${result.stderr}` + ); +}); + +test("two different keys do not get the same masked preview", async () => { + // The preview exists to say which credential was rejected. Recomputing it + // from the same file in the test above cannot catch a preview that is + // identical for every key, so the two keys have to be compared with each + // other. + const otherKeyPem = generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey + .export({ type: "pkcs8", format: "pem" }); + const otherKeyPath = join(root, "other-synthetic.p8"); + writeFileSync(otherKeyPath, otherKeyPem); + chmodSync(otherKeyPath, 0o600); + + const server = createServer((req, res) => { + res.writeHead(401, { "Content-Type": "text/plain" }); + res.end("unauthorized"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + const previews = []; + try { + for (const pem of [keyPem, otherKeyPem]) { + const result = await run(["latest-build-seq", "com.example.nothing", "1.0"], { + ASC_API_BASE: base, + ASC_KEY_P8: pem + }); + const diagnostic = result.stderr.split("\n").find((line) => line.startsWith("latest-build-seq:")); + const preview = diagnostic && diagnostic.match(/key in play (\S+)/); + assert.ok(preview, `no key preview in the diagnostic:\n${result.stderr}`); + previews.push(preview[1]); + } + } finally { + server.close(); + } + assert.notEqual( + previews[0], + previews[1], + "two different keys produced the same masked preview, so it identifies neither" + ); +}); + +test("a large payload is not truncated on the way out", async () => { + // The failure body is deliberately bigger than a pipe buffer, so a write + // that is still queued when the process ends is visibly cut short. This + // is the shape of the bug: the caller gets half an answer and no idea that + // anything is missing. + const body = "x".repeat(1024 * 1024); + const server = createServer((req, res) => { + res.writeHead(500, { "Content-Type": "text/plain" }); + res.end(body); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + let result; + try { + result = await run(["GET", "/v1/apps"], { ASC_API_BASE: base }); + } finally { + server.close(); + } + assert.equal(result.code, 2, "the failed call has to exit non-zero"); + assert.ok( + result.stdout.length >= body.length, + `the payload was cut short: ${result.stdout.length} of ${body.length} bytes arrived` + ); +}); + +test("a named key file is not a credential source", async () => { + // A path on disk is not an approved secret source, so the client never + // reads one: the shell layer that owns the credential reads the staged + // key and hands it over in the environment. The file below holds a real + // private key, so a client that still read paths would sign the request + // with it and name the file as the credential in play. + const server = createServer((req, res) => { + res.writeHead(401, { "Content-Type": "text/plain" }); + res.end("unauthorized"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + let result; + try { + result = await run(["GET", "/v1/apps"], { + ASC_API_BASE: base, + ASC_KEY_PATH: keyPath + }); + } finally { + server.close(); + } + assert.match( + result.stderr, + /asc-auth: using ASC_KEY_P8 environment/, + `the environment was not the credential in play:\n${result.stderr}` + ); + assert.doesNotMatch(result.stderr, /using ASC_KEY_PATH environment/); +}); + +test("a path that names the wrong file is not read, and its content stays out of the log", async () => { + // The path names a real file. A client that read paths would treat it as + // key material and print a masked preview of it when App Store Connect + // refused the credential, so the file's content must never reach a log + // line and the request has to be signed with the environment credential. + const notAKey = join(root, "not-a-key.txt"); + writeFileSync(notAKey, "hunter2 this is not a private key\n"); + chmodSync(notAKey, 0o600); + let requests = 0; + const server = createServer((req, res) => { + requests += 1; + res.writeHead(401, { "Content-Type": "text/plain" }); + res.end("unauthorized"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + let result; + try { + result = await run(["GET", "/v1/apps"], { ASC_API_BASE: base, ASC_KEY_PATH: notAKey }); + } finally { + server.close(); + } + assert.match(result.stderr, /asc-auth: using ASC_KEY_P8 environment/); + assert.doesNotMatch(result.stderr, /hunter2/, "the named file's content reached the log"); + assert.equal(requests, 1, "the request has to be signed with the environment credential"); +}); + +test("a malformed PEM in the environment is a credential error, not a stack trace", async () => { + // An envelope is not a key: a truncated key, an armored block or a + // mangled export carries the envelope and still fails to parse. The + // refusal has to name the credential, because the signer would fail with a + // stack trace that says nothing about what to change. + const malformed = "-----BEGIN PRIVATE KEY-----\nnot base64 at all\n-----END PRIVATE KEY-----\n"; // gitleaks:allow + const result = await run(["GET", "/v1/apps"], { ASC_KEY_P8: malformed }); + assert.equal(result.code, 1, `expected the refusal exit, got ${result.code}: ${result.stderr}`); + assert.match(result.stderr, /ASC_KEY_P8 is set but is not a PEM private key/); + assert.ok( + !/at Object\.|at Module\.|at file:/.test(result.stderr), + `the refusal came with a stack trace:\n${result.stderr}` + ); +}); + +// The two refusals below belong to the staged key file, which the shell +// layer reads, not to the client. They are covered in test-asc-key.test.mjs: +// a file other users can read is refused, and a symlink is not followed. + +test("a symlinked key path is never followed", async () => { + // A symlink is followed by every ordinary file read, so it would let a + // path that looks like a key file resolve to something else entirely. The + // client reads no path at all, and the staged key reader that does read + // files refuses a symlink as well (test-asc-key.test.mjs). + const link = join(root, "linked.p8"); + symlinkSync(keyPath, link); + const result = await run(["GET", "/v1/apps"], { ASC_KEY_PATH: link }); + assert.match(result.stderr, /asc-auth: using ASC_KEY_P8 environment/); + assert.doesNotMatch(result.stderr, /is not a regular file/); +}); \ No newline at end of file diff --git a/scripts/ios-fleet/test-asc-credentials.mjs b/scripts/ios-fleet/test-asc-credentials.mjs index e1b788c9..25c66ac4 100644 --- a/scripts/ios-fleet/test-asc-credentials.mjs +++ b/scripts/ios-fleet/test-asc-credentials.mjs @@ -12,6 +12,11 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; +// The staged key reader has its own test file. The import is what keeps +// those tests in CI: the contract workflow runs this file, and it cannot +// know about a new one. +import "./test-asc-key.test.mjs"; + const here = dirname(fileURLToPath(import.meta.url)); const script = join(here, "asc-api.mjs"); const pem = () => generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey @@ -99,23 +104,42 @@ test("an env key id with no key material anywhere fails closed", async () => { assert.equal(r.kid, null); }); -test("a complete environment (id, issuer, key file) wins and never calls fleet-secrets", async () => { - const keyFile = join(root, "env-key.p8"); - writeFileSync(keyFile, pem()); - const r = await run({ ASC_KEY_ID: "ENVKEY0001", ASC_ISSUER_ID: "env-issuer", ASC_KEY_PATH: keyFile, FLEET_SECRETS: join(root, "does-not-exist") }); +test("a complete environment (id, issuer, key) wins and never calls fleet-secrets", async () => { + const r = await run({ ASC_KEY_ID: "ENVKEY0001", ASC_ISSUER_ID: "env-issuer", ASC_KEY_P8: pem(), FLEET_SECRETS: join(root, "does-not-exist") }); assert.equal(r.code, 0); assert.equal(r.kid, "ENVKEY0001"); assert.equal(r.iss, "env-issuer"); }); -test("only a leading ~/ is expanded in ASC_KEY_PATH", async () => { - writeFileSync(join(home, "tilde-key.p8"), pem()); - const gone = join(root, "does-not-exist"); - const ok = await run({ ASC_KEY_ID: "TILDEKEY01", ASC_ISSUER_ID: "tilde-issuer", ASC_KEY_PATH: "~/tilde-key.p8", FLEET_SECRETS: gone }); - assert.equal(ok.code, 0); - assert.equal(ok.kid, "TILDEKEY01"); - const bad = await run({ ASC_KEY_ID: "TILDEKEY01", ASC_ISSUER_ID: "tilde-issuer", ASC_KEY_PATH: "~other/tilde-key.p8", FLEET_SECRETS: gone }); - assert.notEqual(bad.code, 0); +test("a named key file is not a credential source", async () => { + // A path on disk is not an approved secret source, so the client never + // reads one: the shell layer that owns the credential reads the staged + // key and hands it over in the environment. The path below names a file + // that is not a private key, so a client that still consulted it would + // refuse the credential instead of signing the request. + const notAKey = join(root, "not-a-key.p8"); + writeFileSync(notAKey, "this is not a private key\n"); + chmodSync(notAKey, 0o600); + const r = await run({ + ASC_KEY_ID: "ENVKEY0002", + ASC_ISSUER_ID: "env-issuer", + ASC_KEY_P8: pem(), + ASC_KEY_PATH: notAKey + }); + assert.equal(r.code, 0); + assert.equal(r.kid, "ENVKEY0002"); +}); + +test("a malformed PEM in the environment fails closed, before any request", async () => { + // The envelope is not a key, so the credential has to be refused with a + // credential error rather than reaching the signer. + const r = await run({ + ASC_KEY_ID: "ENVKEY0003", + ASC_ISSUER_ID: "env-issuer", + ASC_KEY_P8: "-----BEGIN PRIVATE KEY-----\nnot base64 at all\n-----END PRIVATE KEY-----\n" // gitleaks:allow + }); + assert.notEqual(r.code, 0); + assert.equal(r.kid, null); }); test.after(() => rmSync(root, { recursive: true, force: true })); diff --git a/scripts/ios-fleet/test-asc-credentials.sh b/scripts/ios-fleet/test-asc-credentials.sh index ade9f0c4..e54d151a 100755 --- a/scripts/ios-fleet/test-asc-credentials.sh +++ b/scripts/ios-fleet/test-asc-credentials.sh @@ -1,20 +1,38 @@ #!/usr/bin/env bash -# AFC-359: ship-testflight.sh resolves App Store Connect credentials from the environment -# first, then from Infisical through fleet-secrets, and never from the old secrets folder. -# This test extracts fs_get/load_secrets/cleanup_asc_tmp from the script and runs them -# against a FAKE fleet-secrets binary under a throwaway HOME. No network, no real secrets. +# AFC-359: ship-testflight.sh resolves App Store Connect credentials from the +# environment first, then from Infisical through fleet-secrets, and never from +# the old secrets folder. +# +# 2026-10-21 (Kody rule 42 on PR #271): the private key is handed over in the +# environment, never as a path the node client reads. The shell layer owns the +# credential, so this test drives load_secrets with a FAKE fleet-secrets binary +# and throwaway keys generated here at test time. No network, no real +# secrets, and no committed key. set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" script="${here}/ship-testflight.sh" tmp="$(mktemp -d)" -trap 'rm -rf "$tmp"' EXIT +# The throwaway HOME lives inside the temp dir, so the cleanup has to run with +# the real HOME in place: a trash helper that protects the home directory +# refuses to remove a parent of it. +real_home="${HOME}" +trap 'HOME="$real_home" rm -rf "$tmp"' EXIT export HOME="${tmp}/home" export TMPDIR="${tmp}/tmp" mkdir -p "$HOME" "$TMPDIR" fail() { echo "FAIL: $*" >&2; exit 1; } +# A throwaway key pair, generated here and never committed. The client parses +# a PEM before it accepts it, so a fake envelope is not a key. +gen_pem() { + node -e 'process.stdout.write(require("crypto").generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey.export({ type: "pkcs8", format: "pem" }))' +} +gen_pem > "${tmp}/asc.p8" +gen_pem > "${tmp}/other.p8" +chmod 600 "${tmp}/asc.p8" "${tmp}/other.p8" + # Fake fleet-secrets: prints synthetic values, records the folders it was asked for. cat > "${tmp}/fleet-secrets" <<'FAKE' #!/usr/bin/env bash @@ -24,13 +42,15 @@ echo "${name} ${folder}" >> "${FAKE_LOG}" case "${folder}:${name}" in /asc:ASC_KEY_ID) printf 'ABCDE12345\n' ;; /asc:ASC_ISSUER_ID) printf 'issuer-synthetic\n' ;; - /asc:ASC_KEY_P8) printf -- '-----BEGIN PRIVATE KEY-----\nSYNTHETIC\n-----END PRIVATE KEY-----\n' ;; - /authkey-otherkey01-p8:AUTHKEY_OTHERKEY01) printf -- '-----BEGIN PRIVATE KEY-----\nOTHERKEYMATERIAL\n-----END PRIVATE KEY-----\n' ;; + /asc:ASC_KEY_P8) cat "${FAKE_ASC_PEM}" ;; + /authkey-otherkey01-p8:AUTHKEY_OTHERKEY01) cat "${FAKE_OTHER_PEM}" ;; *) exit 3 ;; esac FAKE chmod +x "${tmp}/fleet-secrets" export FAKE_LOG="${tmp}/fake.log" +export FAKE_ASC_PEM="${tmp}/asc.p8" +export FAKE_OTHER_PEM="${tmp}/other.p8" : > "$FAKE_LOG" extract() { @@ -40,18 +60,54 @@ extract() { grab && /^\}/ { grab = 0 } ' "$script" } + +# The comparison tool. It answers through the exit code only, so a +# credential is never expanded into the shell, where a trace would show it. +# +# same the value in VALUE_TO_COMPARE equals the file +# different the value in VALUE_TO_COMPARE differs from the file +# samefile the two files hold the same key +# differentfile the two files hold different keys +cat > "${tmp}/compare.cjs" <<'CJS' +const fs = require("fs"); +const mode = process.argv[2]; +const first = process.argv[3]; +const second = process.argv[4]; +const value = process.env.VALUE_TO_COMPARE || ""; +const read = (path) => fs.readFileSync(path, "utf8").trim(); +const equal = second ? read(first) === read(second) : value.trim() === read(first); +const wantsDifferent = mode === "different" || mode === "differentfile"; +process.exit(wantsDifferent ? (equal ? 1 : 0) : (equal ? 0 : 1)); +CJS + funcs="${tmp}/funcs.sh" { echo 'log() { :; }' echo 'ASC_TMP_DIR=""' echo "FLEET_SECRETS_BIN=\"${tmp}/fleet-secrets\"" + echo "FLEET_DIR=\"${here}\"" + echo "ASC_COMPARE_TOOL=\"${tmp}/compare.cjs\"" + cat <<'FUNCS' +same_as_file() { + VALUE_TO_COMPARE="$1" node "$ASC_COMPARE_TOOL" same "$2" +} +different_from_file() { + VALUE_TO_COMPARE="$1" node "$ASC_COMPARE_TOOL" different "$2" +} +same_file_as_file() { + node "$ASC_COMPARE_TOOL" samefile "$1" "$2" +} +different_file_from_file() { + node "$ASC_COMPARE_TOOL" differentfile "$1" "$2" +} +FUNCS extract } > "$funcs" grep -q '^load_secrets()' "$funcs" || fail "load_secrets not found in ship-testflight.sh" grep -q '^fs_get()' "$funcs" || fail "fs_get not found in ship-testflight.sh" # 1. Nothing in the environment: credentials come from Infisical, the key is a 0600 temp file. -out="$(env -u ASC_KEY_ID -u ASC_ISSUER_ID -u ASC_KEY_PATH bash -c ' +out="$(env -u ASC_KEY_ID -u ASC_ISSUER_ID -u ASC_KEY_PATH -u ASC_KEY_P8 bash -c ' set -euo pipefail source "'"$funcs"'" load_secrets @@ -61,7 +117,10 @@ out="$(env -u ASC_KEY_ID -u ASC_ISSUER_ID -u ASC_KEY_PATH bash -c ' mode="$(stat -c %a "$ASC_KEY_PATH" 2>/dev/null || stat -f %Lp "$ASC_KEY_PATH")" [[ "$mode" == 600 ]] [[ "$API_PRIVATE_KEYS_DIR" == "$ASC_TMP_DIR" ]] - grep -q SYNTHETIC "$ASC_KEY_PATH" + # The staged file holds the /asc key, and the client takes the key from the + # environment. Neither comparison prints a value. + same_file_as_file "$ASC_KEY_PATH" "'"$tmp"'/asc.p8" + same_as_file "$ASC_KEY_P8" "'"$tmp"'/asc.p8" echo "$ASC_KEY_PATH" cleanup_asc_tmp [[ ! -e "$ASC_KEY_PATH" ]] @@ -70,37 +129,171 @@ out="$(env -u ASC_KEY_ID -u ASC_ISSUER_ID -u ASC_KEY_PATH bash -c ' grep -q '^ASC_KEY_ID /asc$' "$FAKE_LOG" || fail "did not ask Infisical /asc for the key id" grep -q '^ASC_KEY_P8 /asc$' "$FAKE_LOG" || fail "did not ask Infisical /asc for the key" -# 2. An explicit ASC_* environment wins and fleet-secrets is not called. +# 2. The environment wins over a named key file. A disk credential never +# overrides the environment (Kody rule 42 on PR #271): a stale or foreign +# value in an operator's shell would sign the upload with the wrong key. : > "$FAKE_LOG" -printf 'synthetic' > "${tmp}/env-key.p8" -env ASC_KEY_ID=ENVKEY0001 ASC_ISSUER_ID=env-issuer ASC_KEY_PATH="${tmp}/env-key.p8" bash -c ' +cp "${tmp}/asc.p8" "${tmp}/named-key.p8" +chmod 600 "${tmp}/named-key.p8" +ASC_KEY_ID=ENVKEY0001 ASC_ISSUER_ID=env-issuer \ + ASC_KEY_P8="$(<"${tmp}/other.p8")" ASC_KEY_PATH="${tmp}/named-key.p8" bash -c ' set -euo pipefail source "'"$funcs"'" load_secrets - [[ "$AUTH_MODE" == api_key && "$ASC_KEY_ID" == ENVKEY0001 && -z "$ASC_TMP_DIR" ]] -' || fail "environment credentials were not honored" + [[ "$AUTH_MODE" == api_key && "$ASC_KEY_ID" == ENVKEY0001 ]] + # The environment key is the credential in play, and the named file is not. + same_as_file "$ASC_KEY_P8" "'"$tmp"'/other.p8" + different_from_file "$ASC_KEY_P8" "'"$tmp"'/named-key.p8" +' || fail "a named key file overrode the environment" [[ ! -s "$FAKE_LOG" ]] || fail "fleet-secrets was called although the environment was complete" -# 2b. An env key id that is NOT the /asc one never gets the /asc key: its own .p8 comes from +# 2a. A named ASC_KEY_PATH is not a credential source at all: it is ignored, +# and the credential comes from Infisical. A file somebody names can never +# decide which key signs the upload (Kody rule 42 on PR #271). +: > "$FAKE_LOG" +cp "${tmp}/other.p8" "${tmp}/named-file.p8" +chmod 600 "${tmp}/named-file.p8" +env -u ASC_KEY_P8 ASC_KEY_ID=ABCDE12345 ASC_ISSUER_ID=issuer-synthetic ASC_KEY_PATH="${tmp}/named-file.p8" bash -c ' + set -euo pipefail + source "'"$funcs"'" + load_secrets + [[ "$AUTH_MODE" == api_key && "$ASC_KEY_ID" == ABCDE12345 ]] + # The credential is the /asc one from Infisical, never the named file. + same_as_file "$ASC_KEY_P8" "'"$tmp"'/asc.p8" + different_from_file "$ASC_KEY_P8" "'"$tmp"'/named-file.p8" + cleanup_asc_tmp +' || fail "a named key file was used as the credential" +grep -q '^ASC_KEY_P8 /asc$' "$FAKE_LOG" || fail "fleet-secrets was not consulted for the credential" + +# The scanner that finds a command substitution which runs the key reader. +# +# It walks the file and matches the closing parenthesis of every +# substitution, so a capture is found whatever shape it has: with or without +# a `||` after it, written on one line or across several, and with a nested +# substitution inside it. The scan is exact, so the script as it stands is +# not flagged by a shape it does not have. +cat > "${tmp}/find-key-capture.cjs" <<'SCAN' +const fs = require("fs"); +const text = fs.readFileSync(process.argv[2], "utf8"); +const offenders = []; +const lineOf = (index) => text.slice(0, index).split("\n").length; +let depth = 0; +let start = 0; +let quote = null; +for (let i = 0; i < text.length; i += 1) { + const c = text[i]; + if (quote === "'") { + // Single quotes are literal in bash, so nothing inside them is a + // substitution. + if (c === quote) quote = null; + continue; + } + if (c === "\\") { i += 1; continue; } + if (c === "`") { + const end = text.indexOf("`", i + 1); + if (end === -1) break; + if (text.slice(i, end + 1).includes("asc-key.mjs")) offenders.push(lineOf(i)); + i = end; + continue; + } + if (quote === "\"") { + if (c === quote) { quote = null; continue; } + if (c === "$" && text[i + 1] === "{") { const end = text.indexOf("}", i); if (end !== -1) { i = end; continue; } } + if (c === "$" && text[i + 1] === "(") { depth += 1; if (depth === 1) start = i; i += 1; continue; } + if (c === ")" && depth > 0) { + depth -= 1; + if (depth === 0 && text.slice(start, i + 1).includes("asc-key.mjs")) offenders.push(lineOf(start)); + } + continue; + } + if (c === "$" && text[i + 1] === "{") { const end = text.indexOf("}", i); if (end !== -1) { i = end; continue; } } + if (c === "#") { + // A comment runs to the end of the line, and an apostrophe in it is an + // apostrophe, not the start of a quoted string. + const end = text.indexOf("\n", i); + i = end === -1 ? text.length : end; + continue; + } + if (c === "\"" || c === "'") { quote = c; continue; } + if (c === "$" && text[i + 1] === "(") { depth += 1; if (depth === 1) start = i; i += 1; continue; } + if (c === ")" && depth > 0) { + depth -= 1; + if (depth === 0 && text.slice(start, i + 1).includes("asc-key.mjs")) offenders.push(lineOf(start)); + } +} +if (offenders.length) { + console.error("the key reader is captured into a variable at line(s) " + offenders.join(", ")); + process.exit(1); +} +SCAN + +# 2b. A staged key file is never read into a variable: the shell that owns it +# only ever holds its path. +if ! node "${tmp}/find-key-capture.cjs" "$script"; then + fail "ship-testflight.sh reads the key material into a variable" +fi + +# 2b. The guard has teeth: a capture in every shape is caught, and the ship +# script as it stands is not flagged. +for shape in plain plain-with-or split-across-lines nested; do + cp "$script" "${tmp}/mutated.sh" + case "$shape" in + plain) + printf '%s\n' 'key="$(node "${FLEET_DIR}/asc-key.mjs" read "$ASC_KEY_PATH")"' >> "${tmp}/mutated.sh" ;; + plain-with-or) + printf '%s\n' 'key="$(node "${FLEET_DIR}/asc-key.mjs" read "$ASC_KEY_PATH")" || rc=$?' >> "${tmp}/mutated.sh" ;; + split-across-lines) + printf '%s\n%s\n' 'key="$(node "${FLEET_DIR}/asc-key.mjs" \' ' read "$ASC_KEY_PATH")"' >> "${tmp}/mutated.sh" ;; + nested) + printf '%s\n' 'key="$(cd "$(dirname "$0")"; node "${FLEET_DIR}/asc-key.mjs" read "$ASC_KEY_PATH")"' >> "${tmp}/mutated.sh" ;; + esac + if node "${tmp}/find-key-capture.cjs" "${tmp}/mutated.sh" >/dev/null 2>&1; then + fail "the capture guard did not catch a ${shape} capture" + fi +done + +# 2b. An ambient ASC_KEY_P8 that is not a PEM private key is ignored, and the +# run fails closed rather than signing with something that is not a key. +: > "$FAKE_LOG" +ASC_KEY_ID=NOSUCHKEY0 ASC_ISSUER_ID=any-issuer ASC_KEY_P8="not a private key at all" bash -c ' + set -euo pipefail + source "'"$funcs"'" + load_secrets + [[ "$AUTH_MODE" == none && -z "$ASC_TMP_DIR" ]] + [[ -z "${ASC_KEY_P8:-}" ]] +' || fail "an ASC_KEY_P8 that is not a key was adopted" + +# 2c. An ambient ASC_KEY_P8 without a key id is ignored: the key id, the +# issuer and the key belong together. +: > "$FAKE_LOG" +ASC_KEY_ID="" ASC_ISSUER_ID="" ASC_KEY_P8="$(<"${tmp}/other.p8")" bash -c ' + set -euo pipefail + source "'"$funcs"'" + load_secrets + [[ "$AUTH_MODE" == api_key && "$ASC_KEY_ID" == ABCDE12345 ]] + same_as_file "$ASC_KEY_P8" "'"$tmp"'/asc.p8" +' || fail "an ASC_KEY_P8 without a key id was adopted" + +# 2d. An env key id that is NOT the /asc one never gets the /asc key: its own .p8 comes from # /authkey--p8, and /asc ASC_KEY_P8 is not requested. : > "$FAKE_LOG" -env -u ASC_KEY_PATH ASC_KEY_ID=OTHERKEY01 ASC_ISSUER_ID=issuer-other bash -c ' +env -u ASC_KEY_PATH -u ASC_KEY_P8 ASC_KEY_ID=OTHERKEY01 ASC_ISSUER_ID=issuer-other bash -c ' set -euo pipefail source "'"$funcs"'" load_secrets [[ "$AUTH_MODE" == api_key && "$ASC_KEY_ID" == OTHERKEY01 ]] [[ "$ASC_ISSUER_ID" == issuer-other ]] - grep -q OTHERKEYMATERIAL "$ASC_KEY_PATH" - ! grep -q SYNTHETIC$ "$ASC_KEY_PATH" + same_file_as_file "$ASC_KEY_PATH" "'"$tmp"'/other.p8" + different_file_from_file "$ASC_KEY_PATH" "'"$tmp"'/asc.p8" cleanup_asc_tmp ' || fail "env key id different from /asc was not given its own key" grep -q '^AUTHKEY_OTHERKEY01 /authkey-otherkey01-p8$' "$FAKE_LOG" || fail "did not ask the per-key folder" if grep -q '^ASC_KEY_P8 ' "$FAKE_LOG"; then fail "mixed an env key id with the /asc key"; fi -# 2b2. An env key id that is not the /asc one with NO env issuer fails closed: the /asc issuer +# 2e. An env key id that is not the /asc one with NO env issuer fails closed: the /asc issuer # is never paired with another key's .p8. : > "$FAKE_LOG" -env -u ASC_KEY_PATH -u ASC_ISSUER_ID ASC_KEY_ID=OTHERKEY01 bash -c ' +env -u ASC_KEY_PATH -u ASC_ISSUER_ID -u ASC_KEY_P8 ASC_KEY_ID=OTHERKEY01 bash -c ' set -euo pipefail source "'"$funcs"'" load_secrets @@ -108,29 +301,42 @@ env -u ASC_KEY_PATH -u ASC_ISSUER_ID ASC_KEY_ID=OTHERKEY01 bash -c ' ' || fail "mixed the /asc issuer with another key" if grep -q '^ASC_ISSUER_ID ' "$FAKE_LOG"; then fail "asked /asc for an issuer for a different key id"; fi -# 2c. An env key id equal to the /asc one uses the /asc key. +# 2f. An env key id equal to the /asc one uses the /asc key. : > "$FAKE_LOG" -env -u ASC_KEY_PATH -u ASC_ISSUER_ID ASC_KEY_ID=ABCDE12345 bash -c ' +env -u ASC_KEY_PATH -u ASC_ISSUER_ID -u ASC_KEY_P8 ASC_KEY_ID=ABCDE12345 bash -c ' set -euo pipefail source "'"$funcs"'" load_secrets [[ "$AUTH_MODE" == api_key && "$ASC_KEY_ID" == ABCDE12345 ]] - grep -q SYNTHETIC "$ASC_KEY_PATH" + same_file_as_file "$ASC_KEY_PATH" "'"$tmp"'/asc.p8" cleanup_asc_tmp ' || fail "env key id equal to /asc was not served from /asc" grep -q '^ASC_KEY_P8 /asc$' "$FAKE_LOG" || fail "did not ask /asc ASC_KEY_P8" -# 2d. An env key id with no key material anywhere fails closed (no api_key mode). +# 2g. An env key id with no key material anywhere fails closed (no api_key mode). : > "$FAKE_LOG" -env -u ASC_KEY_PATH ASC_KEY_ID=NOSUCHKEY0 ASC_ISSUER_ID=any-issuer bash -c ' +env -u ASC_KEY_PATH -u ASC_KEY_P8 ASC_KEY_ID=NOSUCHKEY0 ASC_ISSUER_ID=any-issuer bash -c ' set -euo pipefail source "'"$funcs"'" load_secrets [[ "$AUTH_MODE" == none && -z "$ASC_TMP_DIR" ]] ' || fail "missing key material did not fail closed" -# 2e. Only a leading "~/" is expanded: "~user/x" must not be merged with $HOME. -env ASC_KEY_ID=TILDE00001 ASC_ISSUER_ID=tilde-issuer bash -c ' +# 2h. A named ASC_KEY_PATH that names nothing is ignored the same way: the +# credential comes from Infisical, and the named path is never read. +: > "$FAKE_LOG" +env -u ASC_KEY_P8 ASC_KEY_ID=ABCDE12345 ASC_ISSUER_ID=issuer-synthetic ASC_KEY_PATH="${tmp}/no-such-key.p8" bash -c ' + set -euo pipefail + source "'"$funcs"'" + load_secrets + [[ "$AUTH_MODE" == api_key && "$ASC_KEY_ID" == ABCDE12345 ]] + same_as_file "$ASC_KEY_P8" "'"$tmp"'/asc.p8" + cleanup_asc_tmp +' || fail "a named key file that names nothing broke the run" +grep -q '^ASC_KEY_P8 /asc$' "$FAKE_LOG" || fail "fleet-secrets was not consulted for the credential" + +# 2i. Only a leading "~/" is expanded: "~user/x" must not be merged with $HOME. +env -u ASC_KEY_P8 ASC_KEY_ID=TILDE00001 ASC_ISSUER_ID=tilde-issuer bash -c ' set -euo pipefail source "'"$funcs"'" [[ "$(expand_tilde "~/k.p8")" == "$HOME/k.p8" ]] @@ -138,13 +344,30 @@ env ASC_KEY_ID=TILDE00001 ASC_ISSUER_ID=tilde-issuer bash -c ' [[ "$(expand_tilde "/abs/k.p8")" == "/abs/k.p8" ]] ' || fail "expand_tilde" -# 3. Nothing is created under the old secrets folder or ~/.appstoreconnect. +# 3. The credential never travels in a command argument. The secret reaches +# the child through the environment, so its own argv must not carry it. +argv="$(ASC_KEY_ID=ENVKEY0005 ASC_ISSUER_ID=env-issuer ASC_KEY_P8="$(<"${tmp}/other.p8")" bash -c ' + set -euo pipefail + ps -o args= -p $$ 2>/dev/null || true +')" +pem="$(<"${tmp}/other.p8")" +[[ "$argv" != *"$pem"* ]] || fail "the credential reached the child in its command arguments" + +# 4. Neither this test nor the ship script passes a credential to `env`, which +# would put it in the process arguments of every child. +for f in "$0" "$script"; do + if grep -nE '(^|[[:space:]])env [^|]*ASC_KEY_P8=' "$f" >/dev/null; then + fail "$(basename "$f") passes a credential through env arguments" + fi +done + +# 5. Nothing is created under the old secrets folder or ~/.appstoreconnect. [[ ! -e "${HOME}/.secrets" ]] || fail "created the old secrets folder" [[ ! -e "${HOME}/.appstoreconnect" ]] || fail "created ~/.appstoreconnect" -# 4. The script text itself no longer names the old folder as a source. +# 6. The script text itself no longer names the old folder as a source. if grep -nE '(~|\$\{?HOME\}?)/\.secrets' "$script" >/dev/null; then fail "ship-testflight.sh still names the old secrets folder" fi -echo "ok: ASC credential resolution (env first, then Infisical, temp key removed)" +echo "ok: ASC credential resolution (environment first, then Infisical, a named path ignored, temp key removed)" diff --git a/scripts/ios-fleet/test-asc-key.test.mjs b/scripts/ios-fleet/test-asc-key.test.mjs new file mode 100644 index 00000000..38812e55 --- /dev/null +++ b/scripts/ios-fleet/test-asc-key.test.mjs @@ -0,0 +1,488 @@ +// The App Store Connect private key: how it is validated, how it is staged, +// and how it reaches the client. +// +// The private key is a secret, and the approved secret sources are the process +// environment, a captured `fleet-secrets get NAME` and bootstrap-only Keychain +// credentials. A caller-supplied file is not one of them, so nothing reads a +// key file: the key travels in the environment, and the file this module +// writes is a staging copy for the Apple tools that need a key on disk. +// +// Everything here is synthetic: the keys are generated at test time and are +// never committed, and nothing reaches the network. +import assert from "node:assert/strict"; +import { execFileSync, spawn } from "node:child_process"; +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { generateKeyPairSync } from "node:crypto"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import test from "node:test"; + +import { CLIENT_ENV_ALLOWLIST, clientEnvironment, normalizePrivateKey } from "./asc-key.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const helper = join(here, "asc-key.mjs"); +const client = join(here, "asc-api.mjs"); +const repoRoot = join(here, "..", ".."); +const apnsScript = join(repoRoot, "scripts", "ios-enable-apns.mjs"); +const profilesScript = join(repoRoot, "scripts", "ios-install-appstore-profiles.sh"); +const root = mkdtempSync(join(tmpdir(), "asc-key-test-")); +// A PATH with node and nothing else: the fleet reader lives in the operator's +// own bin directory, and a test must never reach it or Infisical. +const safePath = [dirname(process.execPath), "/usr/bin", "/bin"].join(":"); +// A secret that has nothing to do with App Store Connect. It is in the +// environment of the parent on purpose, so the tests can show that it does +// not reach the client. +const unrelatedSecret = "unrelated-runtime-secret-4f1c9a"; + +const keyPem = generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey + .export({ type: "pkcs8", format: "pem" }); + +function staged(name, content = keyPem, mode = 0o600) { + const path = join(root, name); + writeFileSync(path, content); + chmodSync(path, mode); + return path; +} + +function run(args, stdin = "", env = undefined) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [helper, ...args], { + ...(env ? { env } : {}), + stdio: ["pipe", "pipe", "pipe"] + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d; }); + child.stderr.on("data", (d) => { stderr += d; }); + child.on("close", (code) => resolve({ code, stdout, stderr })); + child.stdin.end(stdin); + }); +} + +test("a generated key is accepted and comes back with a trailing newline", () => { + const normalized = normalizePrivateKey(keyPem); + assert.ok(normalized, "a real key was refused"); + assert.ok(normalized.endsWith("\n"), "the key has to end with a newline"); + assert.equal(normalized.trim(), keyPem.trim()); +}); + +test("a malformed PEM is refused, because the envelope is not a key", () => { + // A truncated key, an armored block and a mangled export all carry the + // envelope and still fail to parse. Accepting them would hand the signer + // something it cannot use, and the failure would come back as a stack + // trace instead of a credential error. + assert.equal(normalizePrivateKey("-----BEGIN PRIVATE KEY-----\nnot base64 at all\n-----END PRIVATE KEY-----\n"), null); // gitleaks:allow + assert.equal(normalizePrivateKey("-----BEGIN PRIVATE KEY-----\n-----END PRIVATE KEY-----\n"), null); // gitleaks:allow + assert.equal(normalizePrivateKey(""), null); + assert.equal(normalizePrivateKey(undefined), null); + assert.equal(normalizePrivateKey("hunter2"), null); +}); + +test("the key never travels in the command arguments", async () => { + // The key goes in on stdin, and the command reports the status of it + // without printing it, so it is never an argument of any process and never + // on stdout. + const result = await run(["validate"], keyPem); + assert.equal(result.code, 0); + assert.match(result.stdout, /is a PEM private key/, `the status was not reported:\n${result.stdout}`); + assert.doesNotMatch(result.stdout, /PRIVATE KEY/, "the key reached stdout"); + assert.ok(!result.stderr.includes("PRIVATE KEY"), "the key envelope reached a log line"); +}); + +test("the command line refuses a value that is not a key", async () => { + const result = await run(["validate"], "not a private key"); + assert.notEqual(result.code, 0); + assert.match(result.stderr, /not a PEM private key/); +}); + +test("the command line has no way to read a key file", async () => { + // A path is not an approved secret source, so the command line has no + // command that reads one: the key comes from the environment or from + // fleet-secrets, and nowhere else. + const path = staged("no-reader.p8"); + const read = await run(["read", path]); + assert.notEqual(read.code, 0, "the command line read a key file"); + assert.match(read.stderr, /usage/); + assert.equal(read.stdout, "", "a refused command must not put anything on stdout"); + const exec = await run(["exec", path, "--", process.execPath, "-e", "process.exit(0)"]); + assert.notEqual(exec.code, 0, "the command line handed a key file to a child"); + assert.match(exec.stderr, /usage/); +}); + +test("the command line writes a key to a file only its owner can read", async () => { + const path = join(root, "written.p8"); + const result = await run(["write", path], keyPem); + assert.equal(result.code, 0, `the write failed: ${result.stderr}`); + assert.equal(statSync(path).mode & 0o777, 0o600); + assert.equal(readFileSync(path, "utf8").trim(), keyPem.trim()); +}); + +test("the command line refuses to write a value that is not a key", async () => { + const path = join(root, "refused.p8"); + const result = await run(["write", path], "not a private key"); + assert.notEqual(result.code, 0); + assert.match(result.stderr, /not a PEM private key/); +}); + +test("the child's environment is an allowlist, not a copy of the parent's", () => { + // A wholesale copy of the parent's environment hands every credential on + // the operator's machine to the child, including the ones that have + // nothing to do with App Store Connect. + const child = clientEnvironment({ + PATH: "/usr/bin", + HOME: "/tmp/home", + ASC_KEY_ID: "SYNTHETICKEYID1", + ASC_ISSUER_ID: "synthetic-issuer", + UNRELATED_RUNTIME_SECRET: unrelatedSecret + }, { ASC_KEY_P8: keyPem }); + assert.equal(child.UNRELATED_RUNTIME_SECRET, undefined, "an unrelated secret reached the child"); + assert.equal(child.ASC_KEY_P8.trim(), keyPem.trim(), "the key was not handed over"); + assert.equal(child.ASC_KEY_ID, "SYNTHETICKEYID1"); + assert.deepEqual(Object.keys(child).sort(), ["ASC_ISSUER_ID", "ASC_KEY_ID", "ASC_KEY_P8", "HOME", "PATH"]); + // Every variable the child gets is on the allowlist. + for (const name of Object.keys(child)) { + assert.ok(CLIENT_ENV_ALLOWLIST.includes(name) || name === "ASC_KEY_P8", + `${name} crossed over and is not on the allowlist`); + } +}); + +test("the credential comes from the environment, and a path is not a source", async () => { + const { credentialEnvironment } = await import(pathToFileURL(apnsScript).href); + // The environment is an approved source. + const env = credentialEnvironment({ ASC_KEY_P8: keyPem }); + assert.equal(env.ASC_KEY_P8.trim(), keyPem.trim(), "the key was not handed over"); + assert.equal(Object.keys(env).length, 1, "the child environment carries nothing but the key"); + // A path on disk is not an approved source, so it is not read. + assert.deepEqual(credentialEnvironment({ ASC_KEY_PATH: staged("path-only.p8") }), {}); + // Nothing in the environment means nothing to hand over: the client + // resolves the credential from fleet-secrets on its own. + assert.deepEqual(credentialEnvironment({}), {}); + // A key that is set but is not a key is refused with the variable named, + // instead of being dropped and leaving the child with no credential and + // the operator with a permission message that points at nothing. + assert.throws( + () => credentialEnvironment({ ASC_KEY_P8: "not a private key" }), + /ASC_KEY_P8 is set but does not hold a PEM private key/ + ); +}); + +// The consumers that call the client as a child process. +// +// The client has no path source (Kody rule 42), so the consumers hand it the +// key in the child's environment and give the child an allowlist of +// variables. These tests drive the two consumers that do that, and prove the +// key stays out of argv, out of the environment file and out of every log +// line. + +// A server that answers the endpoints the consumers walk. +// +// Every request is held open until the test releases it, so the consumer is +// still alive, with its credential in its environment, while the process +// table is read. The `firstRequest` promise is the readiness signal: it +// settles when a request has arrived, which is after the consumer has built +// its environment and sent it. +function localServer({ hold = false } = {}) { + const seen = []; + const pending = []; + let holding = hold; + let signal = null; + const firstRequest = new Promise((resolve) => { signal = resolve; }); + const respond = (req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + if (req.url.startsWith("/v1/bundleIds?")) { + res.end(JSON.stringify({ data: [{ type: "bundleIds", id: "BUNDLE1", + attributes: { identifier: "com.simplewithus.codecaps.ios" } }] })); + return; + } + res.end(JSON.stringify({ data: [] })); + }; + const server = createServer((req, res) => { + seen.push({ url: req.url, authorization: req.headers.authorization || "" }); + signal(); + if (holding) { + pending.push(() => respond(req, res)); + return; + } + respond(req, res); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve({ + base: `http://127.0.0.1:${server.address().port}`, + seen, + firstRequest, + // Releasing answers every request that is held and stops holding the + // ones that come after it, so a consumer that asks for more than once + // can finish. + releaseAll: () => { + holding = false; + while (pending.length > 0) pending.shift()(); + }, + close: () => server.close() + })); + }); +} + +function spawnNode(args, env) { + return new Promise((resolve) => { + const child = spawn(process.execPath, args, { env, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d; }); + child.stderr.on("data", (d) => { stderr += d; }); + child.on("close", (code) => resolve({ code, stdout, stderr })); + }); +} + +function spawnBash(args, env) { + return new Promise((resolve) => { + const child = spawn("bash", args, { env, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d; }); + child.stderr.on("data", (d) => { stderr += d; }); + child.on("close", (code) => resolve({ code, stdout, stderr })); + }); +} + +// Spawns a consumer and hands back its pid, so the process table can be read +// while it is waiting on App Store Connect. +function spawnTracked(command, args, env) { + const child = spawn(command, args, { env, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d; }); + child.stderr.on("data", (d) => { stderr += d; }); + const done = new Promise((resolve) => { + child.on("close", (code) => resolve({ code, stdout, stderr })); + }); + return { + pid: child.pid, + done, + kill: () => { try { child.kill("SIGKILL"); } catch { /* already gone */ } } + }; +} + +// The command line of one process. +function processArgs(pid) { + return execFileSync("ps", ["-p", String(pid), "-o", "args="], { encoding: "utf8" }); +} + +// The environment of one process, the way another process of the same user +// can read it on this platform. +function processEnvironment(pid) { + if (process.platform === "darwin") { + // macOS's ps prints the environment with -E. + return execFileSync("ps", ["-E", "-p", String(pid), "-o", "args="], { encoding: "utf8" }); + } + // Linux's ps has no such flag and keeps the environment in a file the owner + // of the process can read. The contract job runs on Linux, so the probe + // has to work there too. + return readFileSync(`/proc/${pid}/environ`, "utf8").replace(/\0/g, "\n"); +} + +// The children of one process. +function childPids(pid) { + try { + const listed = execFileSync("pgrep", ["-P", String(pid)], { encoding: "utf8" }); + return listed.split("\n").map((line) => line.trim()).filter(Boolean).map(Number); + } catch { + return []; + } +} + +// The client processes below one consumer. +function clientPidsBelow(pid) { + const found = []; + const walk = (parent) => { + for (const child of childPids(parent)) { + found.push(child); + walk(child); + } + }; + walk(pid); + return found.filter((child) => { + try { + return processArgs(child).includes("asc-api.mjs"); + } catch { + return false; + } + }); +} + +// Waits for the client of every consumer to be running. +// +// The wait is for the process itself and not for a fixed number of +// milliseconds: a fixed sleep is a race, and the process table is only +// worth reading while the process is there. +async function waitForClients(pids, timeoutMs = 10000) { + const deadline = Date.now() + timeoutMs; + for (;;) { + const found = pids.map((pid) => clientPidsBelow(pid)).filter((list) => list.length > 0); + if (found.length === pids.length) return found.flat(); + if (Date.now() > deadline) { + throw new Error(`only ${found.length} of ${pids.length} clients started`); + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } +} + +function jwtKeyId(authorization) { + const token = String(authorization || "").replace(/^Bearer /, ""); + if (!token) return null; + return JSON.parse(Buffer.from(token.split(".")[0], "base64url").toString()).kid; +} + +test("the child client reports the ASC_KEY_P8 environment source", async () => { + const server = await localServer(); + try { + const result = await spawnNode([client, "GET", `${server.base}/v1/apps`], { + PATH: safePath, + HOME: join(root, "home-client"), + ASC_API_BASE: server.base, + ASC_KEY_ID: "SYNTHETICKEYID1", + ASC_ISSUER_ID: "synthetic-issuer", + ASC_KEY_P8: keyPem + }); + assert.match(result.stderr, /asc-auth: using ASC_KEY_P8 environment/, + `the client did not report the environment source:\n${result.stderr}`); + assert.equal(jwtKeyId(server.seen[0].authorization), "SYNTHETICKEYID1", + "the request was not signed with the key from the environment"); + } finally { + server.close(); + } +}); + +test("with no key in the environment and no fleet secret the client reports that it has no credential", async () => { + const result = await spawnNode([client, "GET", "http://127.0.0.1:1/v1/apps"], { + // A bare PATH and a throwaway HOME keep the fleet reader out of the way, + // so the client really has no source left. + PATH: "/usr/bin:/bin", + HOME: join(root, "home-empty"), + ASC_API_BASE: "http://127.0.0.1:1", + ASC_KEY_ID: "SYNTHETICKEYID1", + ASC_ISSUER_ID: "synthetic-issuer" + }); + assert.equal(result.code, 1, `expected the refusal exit, got ${result.code}: ${result.stderr}`); + assert.match(result.stderr, /no App Store Connect credential found/); +}); + +test("the APNs consumer signs its requests with the key from the environment", async () => { + const server = await localServer(); + try { + const home = join(root, "home-apns"); + mkdirSync(home, { recursive: true }); + const result = await spawnNode([apnsScript], { + PATH: safePath, + HOME: home, + ASC_API_BASE: server.base, + ASC_KEY_ID: "SYNTHETICKEYID1", + ASC_ISSUER_ID: "synthetic-issuer", + ASC_KEY_P8: keyPem + }); + assert.ok(server.seen.length > 0, "the consumer never reached App Store Connect"); + assert.equal(jwtKeyId(server.seen[0].authorization), "SYNTHETICKEYID1", + "the request was not signed with the key from the environment"); + assert.doesNotMatch(result.stdout + result.stderr, /BEGIN PRIVATE KEY/, + "the key reached the consumer's output"); + } finally { + server.close(); + } +}); + +test("the key never reaches argv, and the child's environment carries it", async () => { + const needle = keyPem.split("\n").find((line) => line.length > 32); + const envFile = join(root, "github-env"); + writeFileSync(envFile, ""); + const home = join(root, "home-argv"); + mkdirSync(home, { recursive: true }); + const server = await localServer({ hold: true }); + const env = { + PATH: safePath, + HOME: home, + ASC_API_BASE: server.base, + ASC_KEY_ID: "SYNTHETICKEYID1", + ASC_ISSUER_ID: "synthetic-issuer", + ASC_KEY_P8: keyPem, + GITHUB_ENV: envFile + }; + const apns = spawnTracked(process.execPath, [apnsScript], env); + const profiles = spawnTracked("bash", [profilesScript], env); + try { + // The request arriving is the readiness signal: the consumer has built + // its environment and sent it, so it is alive with the credential in it. + await server.firstRequest; + const clientPids = await waitForClients([apns.pid, profiles.pid]); + // While the consumers are waiting on App Store Connect, no process may + // carry the key in its command line. + const table = execFileSync("ps", ["-A", "-o", "args="], { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }); + assert.ok(!table.includes(needle), "the key reached a command line"); + // The environment of a same-user process is readable on this platform, + // and the key is in the environment of the key-bearing child by design. + // The check can see it there, so it fails when the handoff breaks. + for (const pid of clientPids) { + assert.ok(processEnvironment(pid).includes(needle), + "the key did not reach the environment of the client"); + } + server.releaseAll(); + const results = await Promise.all([apns.done, profiles.done]); + // Both consumers signed their requests with the key, so the handoff is + // what put the credential in front of the client. + const signed = server.seen.filter((entry) => jwtKeyId(entry.authorization) === "SYNTHETICKEYID1"); + assert.ok(signed.length >= 2, `only ${signed.length} of the requests carried the key`); + assert.ok(server.seen.some((entry) => entry.url.startsWith("/v1/profiles?")), + "the profile consumer never reached App Store Connect"); + assert.ok(server.seen.some((entry) => entry.url.startsWith("/v1/bundleIds?")), + "the APNs consumer never reached App Store Connect"); + for (const result of results) { + assert.doesNotMatch(result.stdout + result.stderr, /BEGIN PRIVATE KEY/, + "the key reached a consumer's output"); + } + assert.ok(!readFileSync(envFile, "utf8").includes(needle), "the key reached the environment file"); + } finally { + server.releaseAll(); + apns.kill(); + profiles.kill(); + server.close(); + } +}); + +test("an unrelated secret in the environment does not reach the client", async () => { + // The child's environment is an allowlist: a wholesale copy of the + // parent's environment would hand every credential on the operator's + // machine to the client. + const home = join(root, "home-unrelated"); + mkdirSync(home, { recursive: true }); + const server = await localServer({ hold: true }); + const env = { + PATH: safePath, + HOME: home, + ASC_API_BASE: server.base, + ASC_KEY_ID: "SYNTHETICKEYID1", + ASC_ISSUER_ID: "synthetic-issuer", + ASC_KEY_P8: keyPem, + UNRELATED_RUNTIME_SECRET: unrelatedSecret + }; + const apns = spawnTracked(process.execPath, [apnsScript], env); + try { + await server.firstRequest; + const clientPids = await waitForClients([apns.pid]); + assert.ok(clientPids.length > 0, "the client never started"); + for (const pid of clientPids) { + const childEnvironment = processEnvironment(pid); + assert.ok(!childEnvironment.includes("UNRELATED_RUNTIME_SECRET"), + "an unrelated secret reached the client"); + assert.ok(!childEnvironment.includes(unrelatedSecret), + "the value of an unrelated secret reached the client"); + } + // The parent had it, so the check is not passing because the secret was + // never there. + assert.ok(processEnvironment(apns.pid).includes("UNRELATED_RUNTIME_SECRET"), + "the parent did not carry the unrelated secret"); + } finally { + server.releaseAll(); + apns.kill(); + server.close(); + } +}); diff --git a/scripts/ios-fleet/test-asc-submit.mjs b/scripts/ios-fleet/test-asc-submit.mjs new file mode 100644 index 00000000..992ed24f --- /dev/null +++ b/scripts/ios-fleet/test-asc-submit.mjs @@ -0,0 +1,1100 @@ +// The submit-review command refuses to submit a version that is not ready. +// +// Submitting is the one call in this client that cannot be undone, so the +// things that have to be true first are checked rather than discovered from +// Apple's refusal. These tests drive the real command against a local +// server: no network, no fleet-secrets, no real secret. +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { generateKeyPairSync } from "node:crypto"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const here = dirname(fileURLToPath(import.meta.url)); +const script = join(here, "asc-api.mjs"); +const root = mkdtempSync(join(tmpdir(), "asc-submit-test-")); + +// A synthetic EC key. The client takes it from the environment, because a +// path on disk is not an approved secret source: the shell layer that owns +// the credential hands it over in ASC_KEY_P8. +const keyPem = generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey + .export({ type: "pkcs8", format: "pem" }); + +const VERSION_ID = "version-1"; +const APP_ID = "app-1"; +const SUBMISSION_ID = "sub-1"; + +// A server that answers the endpoints the command walks. +// +// `options` says what the server should report: +// iphoneScreens / ipadScreens: the count in each display type's set +// desktopScreens: the count in the macOS display type's set; the set is +// only served when this is given, so a server that does not name it is a +// version that carries no desktop screenshot at all +// setsFail: the screenshot-set read answers 401 +// preReleaseFails: the build read answers 401 +// preReleaseMissing: the build read names no pre-release version, so the +// document carries no `included` member +// boundTo: the version the review submission's item names; left out, the +// submission has no items at all, which is the state a fresh account is +// in and the one the command has to bind +// bindFails: the binding call answers 409 and names the blockers +// bindAuthFailsOnce: the first binding call answers 401, the way a rejected +// credential does, and the calls after it succeed +// submitFails: the submit answers 409 and names the blockers +// noSubmissions: the app has no review submission at all +// secondSubmission: the app has a second draft submission, so a run that +// does not stop at a rejected credential has somewhere to go +// submissionHasNoPlatform: the draft submission carries no platform at all +// submissionPlatform: the platform the named submission answers with +// platformPatchFails: the platform change is refused, which happens after +// the version was already bound +// platformPatchIgnored: the platform change is accepted but the read still +// answers with the platform the submission started with, so the change +// did not take +// platformReadBackMissing: the read of a draft answers with no platform at +// all +// createHidesPlatform: the created submission comes back with no platform +// bindFailsPlain: the binding is refused and names no blockers +// itemsFail: the read of the submission's items fails +// platform: the platform the submission answers with +function serve(options) { + const patched = []; + const bound = []; + const created = []; + // The order the writes came in, so a test can see which one came first. + const order = []; + // The platform each submission carries. A platform that was set on it is + // remembered, so the read after the change answers with it. + const platforms = new Map(); + // The platform a read of a submission answers with. A server that + // ignores a platform patch keeps answering with the platform the + // submission started with, and a server that has no platform at all + // answers with none: both are states the client's read-back guard has to + // catch, so the mock can play them. + const platformOf = (id) => { + if (options.platformReadBackMissing) return null; + if (options.platformPatchIgnored) return options.submissionPlatform || "IOS"; + return platforms.get(id) || options.submissionPlatform || "IOS"; + }; + // A submission that has been marked submitted reads back as one, and a + // draft reads back as a draft. The read before the submit and the read + // after it are the same call, so what changed in between is what tells + // them apart. + const sent = new Set(); + const server = createServer((req, res) => { + let raw = ""; + req.on("data", (chunk) => { raw += chunk; }); + req.on("end", () => { + const json = (status, payload) => { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(payload)); + }; + const url = req.url.split("?")[0]; + + if (req.method === "PATCH" && url.startsWith("/v1/reviewSubmissions/")) { + // The body is untrusted input, so it is parsed in a try block and + // validated before anything is stored. The shape is checked by + // hand, because the script has no dependencies to lean on. + let parsed = null; + try { + parsed = raw ? JSON.parse(raw) : null; + } catch { + return json(400, { errors: [{ title: "the body is not JSON" }] }); + } + const isPlain = (value) => value !== null && typeof value === "object" && !Array.isArray(value); + if (!isPlain(parsed) || !isPlain(parsed.data) || !isPlain(parsed.data.attributes)) { + return json(400, { errors: [{ title: "the body does not name a submission with attributes" }] }); + } + const id = parsed.data.id; + if (typeof id !== "string" || id.length === 0) { + return json(400, { errors: [{ title: "the body does not name a submission" }] }); + } + const attributes = parsed.data.attributes; + if (attributes.submitted !== undefined && typeof attributes.submitted !== "boolean") { + return json(400, { errors: [{ title: "submitted is not a boolean" }] }); + } + if (attributes.platform !== undefined && + (typeof attributes.platform !== "string" || attributes.platform.length === 0)) { + return json(400, { errors: [{ title: "platform is not a platform" }] }); + } + // Only the normalized values are kept: the id, the submitted flag + // and the platform, and nothing else the request carried. + const submitted = attributes.submitted === true; + const platform = typeof attributes.platform === "string" ? attributes.platform : null; + patched.push({ + url, + body: { + data: { + type: "reviewSubmissions", + id, + attributes: { ...(submitted ? { submitted: true } : {}), ...(platform ? { platform } : {}) } + } + } + }); + order.push(submitted ? "submit" : "platform"); + if (submitted) { + sent.add(id); + } + if (platform !== null) { + platforms.set(id, platform); + } + if (!submitted && platform !== null && options.platformPatchFails) { + // The refusal of the platform change. + return json(409, { errors: [{ + status: "409", + code: "STATE_ERROR.ENTITY_STATE_INVALID", + title: "the platform could not be set on the review submission." + }] }); + } + if (submitted && (options.submitFails || options.submitFailsPlain)) { + if (options.submitFailsPlain) { + // A refusal that names no blockers at all. + return json(409, { errors: [{ + status: "409", + code: "STATE_ERROR.ENTITY_STATE_INVALID", + title: "appStoreVersions with id 'version-1' is not in valid state." + }] }); + } + // The refusal the live service answers with when the version is + // not ready: the blockers are named, one entry per resource. + return json(409, { errors: [{ + status: "409", + code: "STATE_ERROR.ENTITY_STATE_INVALID", + title: "appStoreVersions with id 'version-1' is not in valid state.", + detail: "This resource cannot be reviewed, please check associated errors to see why.", + meta: { + associatedErrors: { + "/v1/appStoreVersionLocalizations/loc-1/placements": [{ + status: "409", + code: "STATE_ERROR.SCREENSHOT_REQUIRED.IPAD_13_PROFILE", + title: "App screenshot missing (IPAD_13_PROFILE).", + detail: "A screenshot for one of the following types is required but was not provided: IPAD_13_PROFILE" + }], + "/v1/appDataUsages/": [{ + status: "409", + code: "STATE_ERROR.APP_DATA_USAGES_REQUIRED", + title: "Answers to what data your app collects and how it's used are needed.", + detail: "You must have published answers to your app's data usages." + }] + } + } + }] }); + } + const patchedPlatform = platformOf(id); + const patchedAttributes = { + state: submitted ? "WAITING_FOR_REVIEW" : "READY_FOR_REVIEW", + submittedDate: submitted ? "2026-10-20T00:00:00Z" : null + }; + if (patchedPlatform !== null) patchedAttributes.platform = patchedPlatform; + return json(200, { + data: { + type: "reviewSubmissions", + id, + attributes: patchedAttributes + } + }); + } + if (url === `/v1/appStoreVersions/${VERSION_ID}/appStoreVersionLocalizations`) { + return json(200, { data: [{ type: "appStoreVersionLocalizations", id: "loc-1" }] }); + } + if (url === "/v1/appStoreVersionLocalizations/loc-1/appScreenshotSets") { + if (options.setsFail) { + return json(401, { errors: [{ title: "unauthorized" }] }); + } + const sets = [ + { + id: "set-1", + attributes: { screenshotDisplayType: "APP_IPHONE_67", screenshotCount: options.iphoneScreens } + }, + { + id: "set-2", + attributes: { screenshotDisplayType: "APP_IPAD_PRO_129", screenshotCount: options.ipadScreens } + } + ]; + if (options.desktopScreens !== undefined) { + sets.push({ + id: "set-3", + attributes: { screenshotDisplayType: "APP_DESKTOP", screenshotCount: options.desktopScreens } + }); + } + return json(200, { data: sets }); + } + if (url === `/v1/appStoreVersions/${VERSION_ID}/build`) { + return json(200, { data: { type: "builds", id: "build-1", attributes: { version: "243" } } }); + } + if (url === `/v1/builds/build-1`) { + if (options.preReleaseFails) { + return json(401, { errors: [{ title: "unauthorized" }] }); + } + if (options.preReleaseMissing) { + // A build that names no pre-release version, so the document + // carries no `included` member at all. + return json(200, { + data: { type: "builds", id: "build-1", attributes: { version: "243" } } + }); + } + if (options.preReleaseWithoutVersion) { + // The include is there, but it carries no version string, so the + // comparison still cannot be made. + return json(200, { + data: { + type: "builds", + id: "build-1", + attributes: { version: "243" }, + relationships: { preReleaseVersion: { data: { type: "preReleaseVersions", id: "pre-1" } } } + }, + included: [{ type: "preReleaseVersions", id: "pre-1", attributes: {} }] + }); + } + if (options.preReleaseWithoutInclude) { + // The build names a pre-release version, but the read returns no + // include for it, so the marketing version cannot be read. + return json(200, { + data: { + type: "builds", + id: "build-1", + attributes: { version: "243" }, + relationships: { preReleaseVersion: { data: { type: "preReleaseVersions", id: "pre-1" } } } + } + }); + } + return json(200, { + data: { + type: "builds", + id: "build-1", + attributes: { version: "243" }, + relationships: { preReleaseVersion: { data: { type: "preReleaseVersions", id: "pre-1" } } } + }, + // The pre-release version carries `version`, and the live API + // answers with no `versionString` on that resource at all. A + // `versionString` here would be a different field, and the + // command must not read it: `versionString` belongs to an + // appStoreVersion. + included: [{ + type: "preReleaseVersions", + id: "pre-1", + attributes: { version: "1.0.243", versionString: "9.9.9", platform: "IOS" } + }] + }); + } + if (url === `/v1/appStoreVersions/${VERSION_ID}`) { + return json(200, { data: { + type: "appStoreVersions", + id: VERSION_ID, + attributes: { versionString: options.versionString || "1.0.243", appStoreState: "PREPARE_FOR_SUBMISSION" }, + relationships: { app: { data: { type: "apps", id: APP_ID } } } + } }); + } + if (url === `/v1/apps/${APP_ID}/reviewSubmissions`) { + if (options.noSubmissions) { + return json(200, { data: [] }); + } + const draft = (id) => { + const attributes = { state: "READY_FOR_REVIEW", submittedDate: null }; + // A service that answers a draft with no platform at all: the + // client has to set the platform and read it back. + if (!options.submissionHasNoPlatform && !options.platformReadBackMissing) { + attributes.platform = options.submissionPlatform || "IOS"; + } + return { id, type: "reviewSubmissions", attributes }; + }; + const data = [draft(SUBMISSION_ID)]; + if (options.secondSubmission) { + data.push(draft("sub-2")); + } + return json(200, { data }); + } + if (req.method === "POST" && url === "/v1/reviewSubmissions") { + // The body is untrusted input, so it is parsed in a try block and + // validated before anything is stored. A body that is not JSON, or + // that names no platform and no app, is refused with a 400 instead + // of throwing out of the handler. + let parsed = null; + try { + parsed = raw ? JSON.parse(raw) : null; + } catch { + return json(400, { errors: [{ title: "the body is not JSON" }] }); + } + const data = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed.data : null; + const attributes = data && typeof data === "object" && !Array.isArray(data) ? data.attributes : null; + const platform = attributes && typeof attributes === "object" && !Array.isArray(attributes) + ? attributes.platform + : null; + const appId = data?.relationships?.app?.data?.id; + if (typeof platform !== "string" || platform.length === 0 || + typeof appId !== "string" || appId.length === 0) { + return json(400, { errors: [{ title: "the body does not name a platform and an app" }] }); + } + // Only the validated object is stored. + created.push({ + data: { + type: "reviewSubmissions", + attributes: { platform }, + relationships: { app: { data: { type: "apps", id: appId } } } + } + }); + // The created submission carries the platform it was made with. + platforms.set("sub-new", platform); + const createdAttributes = { state: "READY_FOR_REVIEW", submittedDate: null }; + // A create response that does not echo the platform is a response + // the client cannot take the platform from. + if (!options.createHidesPlatform) createdAttributes.platform = platform; + return json(201, { data: { + type: "reviewSubmissions", + id: "sub-new", + attributes: createdAttributes + } }); + } + if (req.method === "POST" && url === "/v1/reviewSubmissionItems") { + // The body is untrusted input, so it is parsed in a try block and + // validated before anything is stored. A body that is not JSON, or + // that does not name a review submission and a version, is refused + // with a 400 instead of throwing out of the handler. + let parsed = null; + try { + parsed = raw ? JSON.parse(raw) : null; + } catch { + return json(400, { errors: [{ title: "the body is not JSON" }] }); + } + const data = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed.data : null; + const relationships = data && typeof data === "object" && !Array.isArray(data) ? data.relationships : null; + const submissionId = relationships?.reviewSubmission?.data?.id; + const versionId = relationships?.appStoreVersion?.data?.id; + if (typeof submissionId !== "string" || submissionId.length === 0 || + typeof versionId !== "string" || versionId.length === 0) { + return json(400, { errors: [{ title: "the body does not name a review submission and a version" }] }); + } + // Only the validated object is stored. + bound.push({ + data: { + type: "reviewSubmissionItems", + relationships: { + reviewSubmission: { data: { type: "reviewSubmissions", id: submissionId } }, + appStoreVersion: { data: { type: "appStoreVersions", id: versionId } } + } + } + }); + order.push("bind"); + if (options.bindAuthFailsOnce && bound.length === 1) { + // The refusal a rejected credential gets. The calls after this + // one include the submit, which cannot be undone. + return json(401, { errors: [{ + status: "401", + code: "NOT_AUTHORIZED", + title: "Authentication credentials are missing or invalid." + }] }); + } + if (options.bindFailsPlain) { + // A refusal that names no blockers at all. + return json(409, { errors: [{ + status: "409", + code: "STATE_ERROR.ENTITY_STATE_INVALID", + title: "appStoreVersions with id 'version-1' is not in valid state." + }] }); + } + if (options.bindFails) { + // The refusal the live service answers with when the version is + // not ready: the blockers are named, one entry per resource. + return json(409, { errors: [{ + status: "409", + code: "STATE_ERROR.ENTITY_STATE_INVALID", + title: "appStoreVersions with id 'version-1' is not in valid state.", + detail: "This resource cannot be reviewed, please check associated errors to see why.", + meta: { + associatedErrors: { + "/v1/appStoreVersionLocalizations/loc-1/placements": [{ + status: "409", + code: "STATE_ERROR.SCREENSHOT_REQUIRED.IPAD_13_PROFILE", + title: "App screenshot missing (IPAD_13_PROFILE).", + detail: "A screenshot for one of the following types is required but was not provided: IPAD_13_PROFILE" + }], + "/v1/appDataUsages/": [{ + status: "409", + code: "STATE_ERROR.APP_DATA_USAGES_REQUIRED", + title: "Answers to what data your app collects and how it's used are needed.", + detail: "You must have published answers to your app's data usages." + }] + } + } + }] }); + } + return json(201, { data: { type: "reviewSubmissionItems", id: "item-1" } }); + } + if (req.method === "GET" && /^\/v1\/reviewSubmissions\/[^/]+\/items$/.test(url)) { + if (options.itemsFail) { + // A read that fails: the run never gets as far as a submit. + return json(500, { errors: [{ + status: "500", + code: "INTERNAL_ERROR", + title: "the items could not be read." + }] }); + } + if (options.boundTo === undefined) { + // A submission with no items: the version is bound to nothing. + return json(200, { data: [] }); + } + // The item's own id is the item's id. The version is named in the + // relationship, and reading the item's id as the version's would + // never match. + return json(200, { data: [{ + type: "reviewSubmissionItems", + id: "item-1", + relationships: { appStoreVersion: { data: { type: "appStoreVersions", id: options.boundTo } } } + }] }); + } + if (req.method === "GET" && /^\/v1\/reviewSubmissions\/[^/]+$/.test(url)) { + // The read back after the submit, so the report can say what the + // submission's state actually is. + const id = url.split("/").pop(); + if (sent.has(id)) { + return json(200, { + data: { + type: "reviewSubmissions", + id, + attributes: { state: "WAITING_FOR_REVIEW", submittedDate: "2026-10-20T00:00:00Z" } + } + }); + } + // The read before the submission is touched: a draft, with the + // platform it carries. A server that has no platform at all + // answers with none, and the client has to catch that. + const draftPlatform = platformOf(id); + const draftAttributes = { state: "READY_FOR_REVIEW", submittedDate: null }; + if (draftPlatform !== null) draftAttributes.platform = draftPlatform; + return json(200, { + data: { + type: "reviewSubmissions", + id, + attributes: draftAttributes + } + }); + } + return json(404, { errors: [{ title: "not found" }] }); + }); + }); + return { server, patched, bound, created, order }; +} + +function submit(base, args = []) { + return new Promise((resolve) => { + const child = spawn("node", [script, "submit-review", VERSION_ID, ...args], { + // The child is given the values it needs and nothing else: a wholesale + // copy of the parent environment would hand every credential on this + // machine to the command under test. + env: { + PATH: process.env.PATH, + HOME: process.env.HOME, + ASC_API_BASE: base, + ASC_KEY_ID: "SYNTHETICKEYID1", + ASC_ISSUER_ID: "synthetic-issuer", + ASC_KEY_P8: keyPem + }, + stdio: ["ignore", "pipe", "pipe"] + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d; }); + child.stderr.on("data", (d) => { stderr += d; }); + child.on("close", (code) => resolve({ code, stdout, stderr })); + }); +} + +async function withServer(options, fn) { + const { server, patched, bound, created, order } = serve(options); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + try { + return await fn(base, patched, bound, created, order); + } finally { + server.close(); + } +} + +const READY = { iphoneScreens: 3, ipadScreens: 3, boundTo: VERSION_ID }; + +test("a version with no screenshots is not submitted", async () => { + await withServer({ ...READY, iphoneScreens: 0, ipadScreens: 0 }, async (base, patched) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /no screenshot for iphone and ipad/.test(stderr), + `the refusal did not say what was missing:\n${stderr}` + ); + assert.equal(patched.length, 0, "the submission was patched even though the version was not ready"); + }); +}); + +test("an empty iPad set does not pass on the iPhone images alone", async () => { + // The counts are pooled across display types, so three iPhone images and + // an empty iPad set would pass a total of six and still be refused by + // App Store Connect. + await withServer({ ...READY, ipadScreens: 0 }, async (base, patched) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /no screenshot for ipad/.test(stderr), + `the refusal did not name the missing family:\n${stderr}` + ); + assert.equal(patched.length, 0, "the submission was patched without an iPad screenshot"); + }); +}); + +test("a screenshot set that could not be read stops the submission", async () => { + // A read that failed is not an absent set: treating it as absent would + // let an irreversible submission go out on a read that never happened. + await withServer({ ...READY, setsFail: true }, async (base, patched) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /rejected the key/.test(stderr), + `the failed read was not reported as one:\n${stderr}` + ); + assert.equal(patched.length, 0, "the submission was patched after a failed read"); + }); +}); + +test("a review submission bound to another version is not submitted", async () => { + // One submission carries one version, so a submission that already holds + // another version cannot be given this one. It is left alone rather than + // submitted, because submitting it would send the wrong build to review. + await withServer({ ...READY, boundTo: "some-other-version" }, async (base, patched) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /is bound to some-other-version/.test(stderr), + `the refusal did not say the submission was bound elsewhere:\n${stderr}` + ); + assert.ok( + /holds another version/.test(stderr), + `the refusal did not say what has to be cleared:\n${stderr}` + ); + assert.equal(patched.length, 0, "the wrong version's submission was patched"); + }); +}); + +test("a submission with no items is bound to the version and submitted", async () => { + // This is the state a clean account is in: the version is bound to + // nothing, and the command has to make the binding itself. The items + // relationship cannot be written, so the binding is a review submission + // item of its own. + await withServer({ ...READY, boundTo: undefined }, async (base, patched, bound) => { + const { code, stdout, stderr } = await submit(base); + assert.equal(code, 0, `expected a clean run, got ${code}: ${stderr}`); + assert.equal(bound.length, 1, "the version was not bound to a review submission"); + const relationships = bound[0].data?.relationships; + assert.equal(relationships?.reviewSubmission?.data?.id, SUBMISSION_ID); + assert.equal(relationships?.appStoreVersion?.data?.id, VERSION_ID); + assert.equal(patched.length, 1, "the submission was not patched"); + assert.equal(patched[0].body?.data?.attributes?.submitted, true); + const report = JSON.parse(stdout.trim()); + assert.equal(report.reviewSubmission, SUBMISSION_ID); + }); +}); + +test("the binding refusal reports the blockers App Store Connect named", async () => { + // The refusal on the binding call carries the list of what is not ready, + // one entry per resource. It is the most useful thing the command can + // print, so it is reported rather than swallowed. + await withServer({ ...READY, boundTo: undefined, bindFails: true }, async (base, patched) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /STATE_ERROR\.SCREENSHOT_REQUIRED\.IPAD_13_PROFILE/.test(stderr), + `the refusal did not name the missing screenshot:\n${stderr}` + ); + assert.ok( + /STATE_ERROR\.APP_DATA_USAGES_REQUIRED/.test(stderr), + `the refusal did not name the missing privacy answers:\n${stderr}` + ); + assert.ok( + /\/v1\/appDataUsages\//.test(stderr), + `the refusal did not name the resource that is not ready:\n${stderr}` + ); + assert.equal(patched.length, 0, "the submission was patched even though the version was not ready"); + }); +}); + +test("a review submission is created when the app has none", async () => { + await withServer({ ...READY, noSubmissions: true, boundTo: undefined }, async (base, patched, bound, created) => { + const { code, stderr } = await submit(base); + assert.equal(code, 0, `expected a clean run, got ${code}: ${stderr}`); + assert.equal(created.length, 1, "no review submission was created"); + assert.equal(created[0].data?.attributes?.platform, "IOS"); + assert.equal(created[0].data?.relationships?.app?.data?.id, APP_ID); + assert.equal(bound.length, 1, "the version was not bound to the new submission"); + assert.equal(patched.length, 1, "the submission was not patched"); + }); +}); + +test("a build whose pre-release version has no include stops the submission", async () => { + // The build names a pre-release version and the read returns no include + // for it, so the comparison that pairs the version with the build did not + // happen. Letting the submission through would skip it. + await withServer({ ...READY, preReleaseWithoutInclude: true }, async (base, patched) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /returns no include for it/.test(stderr), + `the refusal did not say what was unreadable:\n${stderr}` + ); + assert.equal(patched.length, 0, "the submission was patched without a comparable build"); + }); +}); + +test("a build whose marketing version cannot be read stops the submission", async () => { + // The include is there but carries no version string, so the comparison + // is still impossible and the submission must not go out. + await withServer({ ...READY, preReleaseWithoutVersion: true }, async (base, patched) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /marketing version could not be read/.test(stderr), + `the refusal did not say what was unreadable:\n${stderr}` + ); + assert.equal(patched.length, 0, "the submission was patched without a comparable build"); + }); +}); + +test("a version whose version string does not match the build is not submitted", async () => { + // The version still carries the previous build's version string, so App + // Store Connect would refuse to pair it with the new build. + await withServer({ ...READY, versionString: "1.0.240" }, async (base, patched) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /the version says 1\.0\.240 and the build says 1\.0\.243/.test(stderr), + `the refusal did not name both versions:\n${stderr}` + ); + assert.equal(patched.length, 0, "the submission was patched even though the version did not match"); + }); +}); + +test("a failed build read stops the submission", async () => { + // The build read carries the version-string comparison. A read that + // failed is not a build with no pre-release version: submitting on it + // would send the build to review without the check that would have caught + // a mismatched version. + await withServer({ ...READY, preReleaseFails: true }, async (base, patched) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /submit-review: .*rejected the key/.test(stderr), + `the failed read was not reported as one:\n${stderr}` + ); + assert.equal(patched.length, 0, "the submission was patched after a failed build read"); + }); +}); + +test("a build with no pre-release version is submitted", async () => { + // JSON:API leaves `included` out when the document has no included + // resources, so a build with no pre-release version must not be refused + // for the absence of a comparison it could not have made. + await withServer({ ...READY, preReleaseMissing: true }, async (base, patched) => { + const { code, stderr } = await submit(base); + assert.equal(code, 0, `expected a clean run, got ${code}: ${stderr}`); + assert.equal(patched.length, 1, "the submission was not patched"); + }); +}); + +test("a ready version is submitted, and only the report is on stdout", async () => { + await withServer(READY, async (base, patched) => { + const { code, stdout, stderr } = await submit(base); + assert.equal(code, 0, `expected a clean run, got ${code}: ${stderr}`); + // A wrapper parses stdout as JSON, so the report has to be the only + // thing there. + const report = JSON.parse(stdout.trim()); + assert.equal(report.ok, true, `the submission did not report success:\n${stdout}`); + assert.equal(report.reviewSubmission, SUBMISSION_ID); + assert.equal(report.platform, "IOS"); + assert.equal(patched.length, 1, "the submission was not patched"); + const attributes = patched[0].body?.data?.attributes; + assert.equal(attributes?.submitted, true, "the submission was not marked submitted"); + // The one call that cannot be undone carries the one attribute that has + // been proven in production, and nothing else. The platform was already + // on the submission. + assert.equal(attributes?.platform, undefined, "the irreversible call carried more than the submit"); + }); +}); + +test("a rejected credential stops the run at the binding", async () => { + // A 401 on the binding is not a recoverable error: the calls after it + // include the submit, which cannot be undone, so the run has to stop with + // the credential App Store Connect has already refused. + await withServer({ ...READY, boundTo: undefined, bindAuthFailsOnce: true, secondSubmission: true }, + async (base, patched, bound) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.match(stderr, /rejected the key/, `the rejection was not reported:\n${stderr}`); + assert.equal(bound.length, 1, "the run went on to the next candidate with a rejected credential"); + assert.equal(patched.length, 0, "a submission was sent with a rejected credential"); + }); +}); + +test("a submission that holds another version is never patched", async () => { + // The platform is set on the candidate that is actually going to be + // submitted, so a submission that is left alone is not mutated either. + await withServer({ ...READY, boundTo: "some-other-version", desktopScreens: 3 }, + async (base, patched) => { + const { code, stderr } = await submit(base, [SUBMISSION_ID, "MAC_OS"]); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.match(stderr, /holds another version/, `the refusal did not say what has to be cleared:\n${stderr}`); + assert.equal(patched.length, 0, "a submission that was skipped was patched"); + }); +}); + +test("a named submission for another platform is refused when no platform was asked for", async () => { + // The default platform is this command's, not the operator's, so a + // submission somebody prepared for another platform is never rewritten to + // the default and submitted. + await withServer({ ...READY, submissionPlatform: "MAC_OS" }, async (base, patched, bound) => { + const { code, stderr } = await submit(base, [SUBMISSION_ID]); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.match(stderr, /is for MAC_OS/, `the refusal did not name the platform:\n${stderr}`); + assert.equal(patched.length, 0, "the submission was rewritten to the default platform"); + assert.equal(bound.length, 0, "the version was bound before the refusal"); + }); +}); + +test("the submit refusal reports the blockers App Store Connect named", async () => { + // The refusal on the submit carries the same blockers the binding refusal + // carries. Reporting that no submission is bound would be false: this + // run bound the version itself. + await withServer({ ...READY, boundTo: undefined, submitFails: true }, async (base, patched) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /STATE_ERROR\.SCREENSHOT_REQUIRED\.IPAD_13_PROFILE/.test(stderr), + `the refusal did not name the missing screenshot:\n${stderr}` + ); + assert.ok( + /STATE_ERROR\.APP_DATA_USAGES_REQUIRED/.test(stderr), + `the refusal did not name the missing privacy answers:\n${stderr}` + ); + assert.doesNotMatch( + stderr, + /no review submission is bound to this version/, + `the refusal said the version was unbound:\n${stderr}` + ); + assert.equal(patched.length, 1, "the submit was not attempted"); + }); +}); + +test("a submit refusal without blockers says that App Store Connect refused it", async () => { + // The run bound the version itself, so saying that no submission is bound + // to it would be false. + await withServer({ ...READY, boundTo: undefined, submitFailsPlain: true }, async (base) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /App Store Connect refused the submission/.test(stderr), + `the refusal did not say what App Store Connect answered:\n${stderr}` + ); + assert.doesNotMatch( + stderr, + /no review submission is bound to this version/, + `the refusal said the version was unbound:\n${stderr}` + ); + }); +}); + +test("an auto-discovered submission with no platform is given the platform before it is submitted", async () => { + // A submission whose platform attribute is absent would go to review with + // no platform at all, because the platform is never applied on that path. + // It is set, and read back, before the irreversible call. + await withServer({ ...READY, submissionHasNoPlatform: true }, async (base, patched) => { + const { code, stdout, stderr } = await submit(base); + assert.equal(code, 0, `expected a clean run, got ${code}: ${stderr}`); + assert.equal(patched.length, 2, `expected a platform patch and a submit patch, got ${patched.length}`); + assert.equal(patched[0].body?.data?.attributes?.platform, "IOS"); + assert.equal(patched[0].body?.data?.attributes?.submitted, undefined); + assert.equal(patched[1].body?.data?.attributes?.submitted, true); + const report = JSON.parse(stdout.trim()); + assert.equal(report.reviewSubmission, SUBMISSION_ID); + }); +}); + +test("the mock refuses a binding body that names no submission and no version", async () => { + // The mock is a trust boundary too: an inbound body is validated before + // it is stored, and a malformed one is refused with a 400 instead of + // throwing out of the handler. + const { server, bound } = serve({ ...READY }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + // The request carries a timeout, so a handler that throws instead of + // answering is a failed assertion and not a hung test. + const post = (body) => new Promise((resolve) => { + const request = fetch(`${base}/v1/reviewSubmissionItems`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + signal: AbortSignal.timeout(2000) + }); + request.then((response) => resolve(response.status)).catch(() => resolve(0)); + }); + try { + assert.equal(await post("{not json"), 400, "a body that is not JSON was accepted"); + assert.equal(await post(JSON.stringify({ data: { relationships: {} } })), 400, + "a body with no ids was accepted"); + assert.equal(await post(JSON.stringify({ + data: { relationships: { reviewSubmission: { data: { id: 7 } }, appStoreVersion: { data: { id: "" } } } } + })), 400, "a body with ids that are not strings was accepted"); + assert.equal(bound.length, 0, "a refused body was stored"); + assert.equal(await post(JSON.stringify({ + data: { + relationships: { + reviewSubmission: { data: { id: SUBMISSION_ID } }, + appStoreVersion: { data: { id: VERSION_ID } } + } + } + })), 201, "a valid body was refused"); + assert.equal(bound.length, 1, "the validated body was not stored"); + assert.equal(bound[0].data.relationships.reviewSubmission.data.id, SUBMISSION_ID); + } finally { + server.close(); + } +}); + +test("the platform can be chosen, and a bad one is refused", async () => { + // A macOS version carries desktop screenshots, so it is submitted against + // a server that serves them rather than the iPhone and iPad sets. + await withServer({ ...READY, iphoneScreens: 0, ipadScreens: 0, desktopScreens: 3 }, async (base, patched) => { + const { code, stdout, stderr } = await submit(base, [SUBMISSION_ID, "MAC_OS"]); + assert.equal(code, 0, `expected a clean run, got ${code}: ${stderr}`); + const report = JSON.parse(stdout.trim()); + assert.equal(report.platform, "MAC_OS"); + // A named submission that carries another platform has it set first, on + // a call that can be undone, so the irreversible one carries only the + // submit. + assert.equal(patched.length, 2, `expected a platform patch and a submit patch, got ${patched.length}`); + assert.equal(patched[0].body?.data?.attributes?.platform, "MAC_OS"); + assert.equal(patched[0].body?.data?.attributes?.submitted, undefined); + assert.equal(patched[1].body?.data?.attributes?.submitted, true); + assert.equal(patched[1].body?.data?.attributes?.platform, undefined); + }); + await withServer(READY, async (base, patched) => { + // The iPhone and iPad sets a macOS version does not carry are not + // enough for it, and submitting it anyway is the failure to catch. + const { code, stderr } = await submit(base, [SUBMISSION_ID, "MAC_OS"]); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /no screenshot for desktop/.test(stderr), + `the refusal did not say what was missing:\n${stderr}` + ); + assert.equal(patched.length, 0, "the macOS submission was patched with no desktop screenshot"); + }); + await withServer(READY, async (base) => { + const { code, stderr } = await submit(base, [SUBMISSION_ID, "WINDOWS"]); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok(/not a review platform/.test(stderr), `the refusal did not say what was wrong:\n${stderr}`); + }); +}); + +test("a refused submit binds the version once and stops the run", async () => { + // The submit was refused after the version was bound to a submission. A + // second candidate would be bound to the same version, and one version + // lives on one submission: the binding cannot be undone from the tool, + // so the run stops at the refusal instead of binding a second time. + await withServer({ ...READY, boundTo: undefined, submitFails: true, secondSubmission: true }, + async (base, patched, bound) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.equal(bound.length, 1, "the version was bound to more than one submission"); + assert.ok( + /is bound to review submission/.test(stderr), + `the run did not say that the version is bound:\n${stderr}` + ); + }); +}); + +test("a run that never submitted says that the version was never bound", async () => { + // The binding was refused and no submit was sent, so saying that App + // Store Connect refused a submission would be false: none was sent. + await withServer({ ...READY, boundTo: undefined, bindFailsPlain: true }, + async (base, patched, bound) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /the version was never bound to a submission/.test(stderr), + `the run did not say that nothing was bound:\n${stderr}` + ); + assert.doesNotMatch( + stderr, + /App Store Connect refused the submission/, + `the run said a submission was refused when none was sent:\n${stderr}` + ); + assert.equal(patched.length, 0, "the submission was patched even though the binding was refused"); + }); +}); + +test("a failed items read says that the version was never bound", async () => { + // The items could not be read, so the run never reached a binding and + // never reached a submit. + await withServer({ ...READY, itemsFail: true }, + async (base, patched, bound) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.ok( + /the version was never bound to a submission/.test(stderr), + `the run did not say that nothing was bound:\n${stderr}` + ); + assert.doesNotMatch( + stderr, + /App Store Connect refused the submission/, + `the run said a submission was refused when none was sent:\n${stderr}` + ); + assert.equal(bound.length, 0, "the version was bound even though the items could not be read"); + }); +}); + +test("a refused platform change leaves the version unbound", async () => { + // The platform is proven before the binding, so a platform that cannot be + // set stops the run with the version on no submission at all: there is + // nothing for the operator to clear by hand. + await withServer({ ...READY, boundTo: undefined, submissionHasNoPlatform: true, platformPatchFails: true }, + async (base, patched, bound) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.match(stderr, /the platform could not be set/, `the refusal did not name the platform:\n${stderr}`); + assert.match(stderr, /the version was not bound/, `the refusal did not say nothing was bound:\n${stderr}`); + assert.equal(bound.length, 0, "the version was bound even though the platform could not be set"); + assert.equal(patched.length, 1, "the submit was sent even though the platform could not be set"); + assert.equal(patched[0].body?.data?.attributes?.submitted, undefined); + }); +}); + +test("the platform is proven before the version is bound", async () => { + // The order matters: the binding cannot be undone, so the platform is + // set and read back first. + await withServer({ ...READY, boundTo: undefined, submissionHasNoPlatform: true }, + async (base, patched, bound, created, order) => { + const { code, stderr } = await submit(base); + assert.equal(code, 0, `expected a clean run, got ${code}: ${stderr}`); + assert.deepEqual(order, ["platform", "bind", "submit"], "the platform was not proven before the binding"); + assert.equal(bound.length, 1, "the version was not bound"); + }); +}); + +test("a draft with no platform stops the submit", async () => { + // The read back after the platform change answers with no platform at + // all, so the platform could not be proven and the submit does not go + // out. + await withServer({ ...READY, boundTo: undefined, platformReadBackMissing: true }, + async (base, patched, bound) => { + const { code, stderr } = await submit(base); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.match( + stderr, + /the platform on sub-1 is unreadable, not IOS/, + `the refusal did not name the platform:\n${stderr}` + ); + assert.equal(bound.length, 0, "the version was bound even though the platform was not proven"); + assert.equal(patched.length, 1, "the submit was sent even though the platform was not proven"); + assert.equal(patched[0].body?.data?.attributes?.submitted, undefined); + }); +}); + +test("a platform that did not take stops the submit", async () => { + // The service accepted the platform change and then answered with the + // platform the submission had before, so the change did not take and the + // submit does not go out. + await withServer({ ...READY, boundTo: undefined, submissionPlatform: "MAC_OS", platformPatchIgnored: true }, + async (base, patched, bound) => { + const { code, stderr } = await submit(base, [SUBMISSION_ID, "IOS"]); + assert.equal(code, 2, `expected a refusal, got ${code}: ${stderr}`); + assert.match( + stderr, + /the platform on sub-1 is MAC_OS, not IOS/, + `the refusal did not name the platform:\n${stderr}` + ); + assert.equal(bound.length, 0, "the version was bound even though the platform did not take"); + assert.equal(patched.length, 1, "the submit was sent even though the platform did not take"); + assert.equal(patched[0].body?.data?.attributes?.submitted, undefined); + }); +}); + +test("a created submission with no platform in the response is set before the submit", async () => { + // The create response did not echo a platform. Recording the platform + // that was asked for would make the platform check pass, and the + // submission would go to review with a platform that was never proven. + await withServer({ ...READY, noSubmissions: true, boundTo: undefined, createHidesPlatform: true }, + async (base, patched, bound, created) => { + const { code, stderr } = await submit(base); + assert.equal(code, 0, `expected a clean run, got ${code}: ${stderr}`); + assert.equal(created.length, 1, "no review submission was created"); + assert.equal(bound.length, 1, "the version was not bound to the new submission"); + assert.equal(patched.length, 2, `expected a platform patch and a submit patch, got ${patched.length}`); + assert.equal(patched[0].body?.data?.attributes?.platform, "IOS"); + assert.equal(patched[0].body?.data?.attributes?.submitted, undefined); + assert.equal(patched[1].body?.data?.attributes?.submitted, true); + }); +}); + +test("the mock refuses a patch body that is not a submission", async () => { + // The mock is a trust boundary too: an inbound body is validated before + // it is stored, and a malformed one is refused with a 400 instead of + // throwing out of the handler. + const { server } = serve({ ...READY }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + const patch = (body) => new Promise((resolve) => { + const request = fetch(`${base}/v1/reviewSubmissions/sub-1`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body, + signal: AbortSignal.timeout(2000) + }); + request.then((response) => resolve(response.status)).catch(() => resolve(0)); + }); + try { + assert.equal(await patch("{not json"), 400, "a body that is not JSON was accepted"); + assert.equal(await patch(JSON.stringify({ data: { id: "sub-1" } })), 400, + "a body without attributes was accepted"); + assert.equal(await patch(JSON.stringify({ data: { attributes: { platform: "IOS" } } })), 400, + "a body without an id was accepted"); + assert.equal(await patch(JSON.stringify({ data: { id: "", attributes: {} } })), 400, + "a body with an empty id was accepted"); + assert.equal(await patch(JSON.stringify({ data: { id: "sub-1", attributes: { submitted: "yes" } } })), 400, + "a body with a submitted flag that is not a boolean was accepted"); + assert.equal(await patch(JSON.stringify({ data: { id: "sub-1", attributes: { platform: "" } } })), 400, + "a body with an empty platform was accepted"); + assert.equal(await patch(JSON.stringify([1, 2, 3])), 400, "a body that is not an object was accepted"); + } finally { + server.close(); + } +}); + +test("the mock refuses a create body that names no platform and no app", async () => { + // The mock is a trust boundary too: an inbound body is validated before + // it is stored, and a malformed one is refused with a 400 instead of + // throwing out of the handler. + const { server, created } = serve({ ...READY }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + const post = (body) => new Promise((resolve) => { + const request = fetch(`${base}/v1/reviewSubmissions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + signal: AbortSignal.timeout(2000) + }); + request.then((response) => resolve(response.status)).catch(() => resolve(0)); + }); + try { + assert.equal(await post("{not json"), 400, "a body that is not JSON was accepted"); + assert.equal(await post(JSON.stringify({ data: { attributes: {} } })), 400, + "a body without a platform was accepted"); + assert.equal(await post(JSON.stringify({ data: { attributes: { platform: "IOS" } } })), 400, + "a body without an app was accepted"); + assert.equal(await post(JSON.stringify({ data: { attributes: { platform: "IOS" }, + relationships: { app: { data: { id: "" } } } } })), 400, + "a body with an empty app id was accepted"); + assert.equal(created.length, 0, "a refused body was stored"); + } finally { + server.close(); + } +}); diff --git a/scripts/ios-fleet/test-asc-upload.mjs b/scripts/ios-fleet/test-asc-upload.mjs new file mode 100644 index 00000000..56defe8e --- /dev/null +++ b/scripts/ios-fleet/test-asc-upload.mjs @@ -0,0 +1,297 @@ +// asc-api.mjs upload-screenshots cleans up after itself and keeps quiet. +// +// A screenshot set holds ten entries, so a reservation that is left open by a +// failed upload is not cosmetic: it sits in the set forever and pushes the real +// screenshots out of it. And the body of an App Store Connect error is +// whatever the service chose to say, so echoing it can put a header or a token +// into a shell history or a CI log. +// +// These tests run the real script against a local HTTP server with a synthetic +// key, so the failure paths are exercised without touching App Store Connect +// and without a real secret anywhere. No network, no fleet-secrets. +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { chmodSync, mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { generateKeyPairSync } from "node:crypto"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const here = dirname(fileURLToPath(import.meta.url)); +const script = join(here, "asc-api.mjs"); +const root = mkdtempSync(join(tmpdir(), "asc-upload-test-")); + +// A synthetic EC key: the script signs a JWT with it and the server here never +// verifies the signature. +// A synthetic EC key. The client takes it from the environment, because a +// path on disk is not an approved secret source: the shell layer that owns +// the credential hands it over in ASC_KEY_P8. +const keyPem = generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey + .export({ type: "pkcs8", format: "pem" }); + +// A stub PNG: the script only reads the IHDR width and height out of bytes +// 16..23 to decide which screenshot set the file belongs to, and never looks +// at the signature, so the rest of the file is not needed. +const pngPath = join(root, "shot.png"); +const ihdr = Buffer.alloc(24); +ihdr.writeUInt32BE(1320, 16); +ihdr.writeUInt32BE(2868, 20); +writeFileSync(pngPath, ihdr); + +// The same stub at one of Apple's Mac sizes, all of which are 16:10. +const macPath = join(root, "mac.png"); +const macIhdr = Buffer.alloc(24); +macIhdr.writeUInt32BE(1440, 16); +macIhdr.writeUInt32BE(900, 20); +writeFileSync(macPath, macIhdr); + +// A string the server puts in a body that the script must never echo. +const SECRET_MARKER = "leaked-token-marker-do-not-echo"; + +const SCREENSHOT_ID = "shot-abc-123"; +const SET_ID = "set-xyz-789"; + +// Run asc-api.mjs upload-screenshots against a local server and collect what +// happened. `behaviour` picks which step, if any, fails. +function upload(behaviour, file = pngPath) { + return new Promise((resolve) => { + const calls = []; + const createdSets = []; + // The pre-signed upload URL Apple hands out is absolute, so the port has to + // be known before a reservation is written -- and the port is only known + // once the server is listening. + let apiBase = ""; + const server = createServer(async (req, res) => { + const body = await new Promise((resolve2) => { + let acc = ""; + req.on("data", (chunk) => { acc += chunk; }); + req.on("end", () => resolve2(acc)); + }); + calls.push({ method: req.method, url: req.url }); + + const json = (status, payload) => { + const text = JSON.stringify(payload); + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(text); + }; + + // The set already exists, so the run goes straight to the reservation. + if (req.url.endsWith("/appScreenshotSets") && req.method === "GET") { + return json(200, { data: [{ + id: SET_ID, + type: "appScreenshotSets", + attributes: { screenshotDisplayType: "APP_IPHONE_67" } + }] }); + } + if (req.url === "/v1/appScreenshotSets" && req.method === "POST") { + // A set for a display type the server does not have yet. + let wanted; + try { + const parsed = JSON.parse(body || "{}"); + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) || + parsed.data === null || + typeof parsed.data !== "object" || + Array.isArray(parsed.data) || + parsed.data.attributes === null || + typeof parsed.data.attributes !== "object" || + Array.isArray(parsed.data.attributes) || + typeof parsed.data.attributes.screenshotDisplayType !== "string" || + parsed.data.attributes.screenshotDisplayType.trim().length === 0 + ) { + return json(400, { errors: [{ status: "400", code: "INVALID_REQUEST" }] }); + } + wanted = parsed.data.attributes.screenshotDisplayType; + } catch { + return json(400, { errors: [{ status: "400", code: "INVALID_REQUEST" }] }); + } + // The response echoes `wanted`, so echoing it back cannot tell a + // correct size-to-display-type mapping from a wrong one. Record what + // was actually asked for so a test can assert on it directly. + createdSets.push(wanted); + return json(201, { data: { + id: `set-${wanted}`, + type: "appScreenshotSets", + attributes: { screenshotDisplayType: wanted } + } }); + } + if (req.url === "/v1/appScreenshots" && req.method === "POST") { + return json(201, { + data: { + id: SCREENSHOT_ID, + type: "appScreenshots", + attributes: { uploadOperations: [{ + method: "PUT", + url: `${apiBase}/upload-target`, + length: 24, + requestHeaders: [{ name: "Content-Type", value: "image/png" }] + }] } + } + }); + } + if (req.url === "/upload-target") { + if (behaviour.putFails) { + res.writeHead(500, { "Content-Type": "text/plain" }); + return res.end(`upstream said: ${SECRET_MARKER}`); + } + res.writeHead(200); + return res.end(); + } + if (req.url === `/v1/appScreenshots/${SCREENSHOT_ID}` && req.method === "PATCH") { + if (behaviour.commitFails) { + return json(409, { errors: [{ + status: "409", + code: "STATE_ERROR", + title: "The asset is not in a committable state.", + detail: `internal reference ${SECRET_MARKER}` + }] }); + } + return json(200, { data: { + id: SCREENSHOT_ID, + type: "appScreenshots", + attributes: { assetDeliveryState: { state: "COMPLETE", errors: [] } } + } }); + } + if (req.url === `/v1/appScreenshots/${SCREENSHOT_ID}` && req.method === "DELETE") { + res.writeHead(204); + return res.end(); + } + res.writeHead(404, { "Content-Type": "text/plain" }); + return res.end(SECRET_MARKER); + }); + + server.listen(0, "127.0.0.1", () => { + apiBase = `http://127.0.0.1:${server.address().port}`; + const child = spawn("node", [script, "upload-screenshots", "loc-1", "auto", file], { + env: { + ...process.env, + ASC_API_BASE: apiBase, + ASC_KEY_ID: "SYNTHETICKEYID1", + ASC_ISSUER_ID: "synthetic-issuer", + ASC_KEY_P8: keyPem + }, + stdio: ["ignore", "pipe", "pipe"] + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d; }); + child.stderr.on("data", (d) => { stderr += d; }); + child.on("close", (code) => { + server.close(() => resolve({ code, stdout, stderr, calls, createdSets })); + }); + }); + }); +} + +const deletesFor = (calls) => calls + .filter((c) => c.method === "DELETE" && c.url === `/v1/appScreenshots/${SCREENSHOT_ID}`) + .length; + +// The delete is only worth anything if the caller learns whether it landed, so +// the script has to wait for the response rather than fire the request and exit. +// A DELETE that is merely *sent* still shows up at the server, which makes the +// request count the wrong thing to assert on: the reported outcome is the part +// that distinguishes a cleaned-up reservation from a silent orphan. +const reportsDropped = (stderr) => stderr.includes(`dropped the reservation ${SCREENSHOT_ID}`); + +test("a Mac screenshot goes to the desktop set", async () => { + // Apple's Mac sizes are all 16:10, and they belong to the desktop set. + // Without them in the map the upload would refuse a file it should + // accept. The exit code alone cannot tell the desktop set from a wrong + // one, because the mock answers with whatever display type it was asked + // for, so the created set is asserted on directly. + const { code, stderr, createdSets } = await upload({}, macPath); + assert.equal(code, 0, `expected a clean run, got ${code}: ${stderr}`); + assert.deepEqual( + createdSets, ["APP_DESKTOP"], + `the Mac size was mapped to the wrong set: ${JSON.stringify(createdSets)}` + ); +}); + +test("a successful upload commits the reservation and leaves nothing open", async () => { + const { code, stderr, calls } = await upload({ putFails: false, commitFails: false }); + assert.equal(code, 0, `expected a clean exit, got ${code}: ${stderr}`); + assert.ok( + calls.some((c) => c.method === "PATCH" && c.url === `/v1/appScreenshots/${SCREENSHOT_ID}`), + "the commit call was never made" + ); + assert.equal(deletesFor(calls), 0, "a committed reservation must not be deleted"); +}); + +test("a failed upload drops the reservation instead of leaving it in the set", async () => { + const { code, stderr, calls } = await upload({ putFails: true, commitFails: false }); + assert.equal(code, 2, "a failed upload has to exit non-zero"); + assert.equal( + deletesFor(calls), 1, + "the open reservation was never dropped, so it would sit in the ten-entry set forever" + ); + assert.ok( + reportsDropped(stderr), + `the script exited without reporting that the reservation went away:\n${stderr}` + ); +}); + +test("a failed commit drops the reservation", async () => { + const { code, stderr, calls } = await upload({ putFails: false, commitFails: true }); + assert.equal(code, 2, "a failed commit has to exit non-zero"); + assert.equal(deletesFor(calls), 1, "the uncommitted reservation was never dropped"); + assert.ok( + reportsDropped(stderr), + `the script exited without reporting that the reservation went away:\n${stderr}` + ); +}); + +test("no response body is echoed, on any failure", async () => { + for (const behaviour of [ + { putFails: true, commitFails: false }, + { putFails: false, commitFails: true } + ]) { + const { stdout, stderr } = await upload(behaviour); + const printed = stdout + stderr; + assert.ok( + !printed.includes(SECRET_MARKER), + `the raw response body was echoed to the caller:\n${printed}` + ); + } +}); + +test("a rejected key is named, never previewed", async () => { + // The server answers 401 to everything; the script must not echo the body, + // and it must not print any part of a key either. + const base = await new Promise((resolve) => { + const server = createServer((req, res) => { + res.writeHead(401, { "Content-Type": "text/plain" }); + res.end(`unauthorized: ${SECRET_MARKER}`); + }); + server.listen(0, "127.0.0.1", () => resolve(server)); + }); + const apiBase = `http://127.0.0.1:${base.address().port}`; + const result = await new Promise((resolve) => { + const child = spawn("node", [script, "upload-screenshots", "loc-1", "auto", pngPath], { + env: { + ...process.env, + ASC_API_BASE: apiBase, + ASC_KEY_ID: "SYNTHETICKEYID1", + ASC_ISSUER_ID: "synthetic-issuer", + ASC_KEY_P8: keyPem + }, + stdio: ["ignore", "pipe", "pipe"] + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d; }); + child.stderr.on("data", (d) => { stderr += d; }); + child.on("close", (code) => resolve({ code, stdout, stderr })); + }); + base.close(); + assert.equal(result.code, 2, "a rejected key has to exit non-zero"); + assert.ok(!result.stderr.includes(SECRET_MARKER), "the 401 body was echoed"); + assert.ok( + /ASC_KEY_ID|ASC_ISSUER_ID|ASC_KEY_P8/.test(result.stderr), + `the failure should name the variables to check, got: ${result.stderr}` + ); +}); \ No newline at end of file diff --git a/scripts/ios-fleet/test-capture-script.sh b/scripts/ios-fleet/test-capture-script.sh new file mode 100755 index 00000000..1c5e5dc0 --- /dev/null +++ b/scripts/ios-fleet/test-capture-script.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# +# Contract test for the App Store screenshot capture script. +# +# The capture runs on a macOS runner whose system Bash is 3.2, and Bash 3.2 +# reads an empty array under "set -u" as an unbound variable. The first +# capture passes no launch arguments at all, so the unguarded form +# "${args[@]}" aborts the run on the very first screen, after the build has +# already spent its time. The guarded form ${args[@]:+"${args[@]}"} is safe +# on every Bash the fleet uses, and this test keeps it that way. +# +# Usage: bash scripts/ios-fleet/test-capture-script.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CAPTURE="$SCRIPT_DIR/capture-appstore-screenshots.sh" + +fail() { echo "FAIL: $*" >&2; exit 1; } +pass() { echo "ok - $1"; } + +[[ -f "$CAPTURE" ]] || fail "the capture script is missing at $CAPTURE" + +# It still has to parse. +bash -n "$CAPTURE" || fail "the capture script does not parse" + +# Every expansion of the launch-argument array has to be guarded, so an empty +# array stays empty under "set -u" instead of becoming an unbound variable. +# shellcheck disable=SC2016 # the pattern is matched literally, not expanded +unguarded="$(grep -n '\${args\[@\]}' "$CAPTURE" | grep -v '\${args\[@\]:+' || true)" +[[ -z "$unguarded" ]] || fail "an unguarded array expansion is left in the capture script: +$unguarded" +pass "no unguarded array expansion" + +# The guarded form has to survive "set -u" with an empty array. +output="$(/bin/bash -c 'set -u; args=(); printf "%s" ${args[@]:+"${args[@]}"}' || true)" +[[ -z "$output" ]] || fail "the guarded expansion produced output for an empty array: $output" +pass "the guarded expansion is safe with an empty array" + +# Bash 4 builtins do not exist on the runner's system Bash. +newer="$(grep -nE '\b(readarray|mapfile)\b' "$CAPTURE" || true)" +[[ -z "$newer" ]] || fail "the capture script uses a Bash 4 builtin, which the runner's Bash 3.2 does not have: +$newer" +pass "no Bash 4 builtin" + +# The script must wait for a drawn frame rather than sleep a fixed number of +# seconds, which is what captured a blank window. +grep -q 'wait_for_render' "$CAPTURE" || fail "the capture script no longer waits for a drawn frame" +grep -q 'screenshot_ink.py' "$CAPTURE" || fail "the capture script no longer measures the frame before keeping it" +pass "the capture waits for a drawn frame" + +# The quota list fills the screen and the settings sheets do not, so one +# threshold for all three screens either fails the two settings captures or +# lets a half-drawn frame through. The settings screens need their own bar. +grep -q 'RENDER_MIN_PERCENT_SETTINGS' "$CAPTURE" \ + || fail "the capture script has no separate minimum for the settings screens" +# shellcheck disable=SC2016 # the pattern is matched literally, not expanded +grep -q 'wait_for_render "$udid" "$raw" "$min" "$RENDER_MIN_CONTENT_PERCENT"' "$CAPTURE" \ + || fail "the capture script does not give each screen its own minimum and the content bar" +pass "each screen is measured against a minimum that fits it" + +# A frame that has drawn only its navigation bar covers a few percent of the +# frame, so a threshold on the whole frame alone cannot tell it from a +# finished screen. The content region below the header is what can, and it +# has to be part of the gate rather than a separate check. +grep -q 'RENDER_MIN_CONTENT_PERCENT' "$CAPTURE" \ + || fail "the capture script does not measure the content region" +grep -q 'p >= m && c >= k' "$CAPTURE" \ + || fail "the capture script does not require the content region to hold ink" +pass "the content region below the header is part of the gate" + +# Holding still is a second condition on top of the thresholds, never a way +# around them: a frame that is still filling in has not finished drawing even +# when it has already cleared them. +if grep -q 'RENDER_STABLE_FLOOR' "$CAPTURE"; then + fail "the capture script has a stable-frame floor that can bypass the minimum" +fi +grep -q 'RENDER_STABLE_DELTA' "$CAPTURE" \ + || fail "the capture script does not require the frame to hold still" +grep -q 'RENDER_STABLE_POLLS' "$CAPTURE" \ + || fail "the capture script does not require the frame to hold still" +pass "the settled path cannot bypass the minimum" + +# A measurement that failed is not a blank frame. Swallowing it would spend +# the whole render budget on every screen and then report the wrong reason. +grep -q 'INK_ERROR_FILE' "$CAPTURE" \ + || fail "the capture script discards the ink measurement error" +grep -q 'status" -ge 2' "$CAPTURE" \ + || fail "the capture script does not fail fast on a failed measurement" +pass "a failed measurement stops the capture instead of timing out" + +# The decisions the capture makes are exercised against the real numbers, not +# only checked for their presence in the source. The script is sourced with +# the run guarded off, so the functions can be called directly. +# +# The gate values travel in the environment, not as positional arguments: the +# script reads its output directory from "$1", so passing the numbers that way +# would set OUT_DIR to a number and leave stray directories behind. +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +gate() { + # gate -> exit 0 when drawn + ( + cd "$work" + OUT_DIR="$work/out" DERIVED_ROOT="$work/derived" CAPTURE_SOURCE_ONLY=1 \ + GATE_OVERALL="$1" GATE_CONTENT="$2" GATE_MIN_OVERALL="$3" GATE_MIN_CONTENT="$4" \ + bash -c ". '$CAPTURE'; frame_clears_thresholds \"\$GATE_OVERALL\" \"\$GATE_CONTENT\" \"\$GATE_MIN_OVERALL\" \"\$GATE_MIN_CONTENT\"" + ) >/dev/null 2>&1 +} + +# A navigation bar and no content: about four percent of the frame, and none +# of it below the header. It has to be rejected for every screen, which is +# the case a threshold on the whole frame alone got wrong. +gate 4.6 0.0 10.0 0.5 && fail "a navigation bar with no content passed the quota-list gate" +gate 4.6 0.0 5.0 0.5 && fail "a navigation bar with no content passed the settings gate" +gate 4.6 0.0 4.5 0.4 && fail "a navigation bar with no content passed a low gate" +pass "a navigation bar with no content never passes the gate" + +# A half-drawn screen: some content, but the content share is under the bar. +gate 12.0 0.2 10.0 0.5 && fail "a half-drawn screen passed the quota-list gate" +gate 12.0 0.2 5.0 0.5 && fail "a half-drawn screen passed the settings gate" +pass "a half-drawn screen never passes the gate" + +# The finished quota list and the finished settings sheet both pass. +gate 43.0 12.0 10.0 0.5 || fail "the finished quota list did not pass the gate" +gate 6.2 1.1 5.0 0.5 || fail "the finished settings sheet did not pass the gate" +# Exactly on the bar still passes. +gate 10.0 0.5 10.0 0.5 || fail "a frame exactly on the bar did not pass the gate" +pass "a finished screen passes the gate" + +# Sourcing the capture must not write outside the directory the test set up. +# The script reads its output directory from "$1", so a helper that passes its +# own values positionally would silently send the output somewhere else. +stray="$(find "$work" -mindepth 1 -maxdepth 1 -not -name out -not -name derived -not -name 'ink-error.txt' 2>/dev/null)" +[[ -z "$stray" ]] || fail "sourcing the capture left directories behind: +$stray" +pass "sourcing the capture writes only inside the directory it was given" + +# Both simulators run the same runtime and the same product, so the app is +# built once and installed on both. A second build compiles the companion and +# the shared QuotaCore files from scratch again, for nothing. +builds="$(grep -c 'xcodebuild' "$CAPTURE" || true)" +[[ "$builds" -eq 1 ]] || fail "the capture script runs $builds xcodebuild calls; it has to build the app once: +$builds" +grep -q 'APP_PATH=' "$CAPTURE" || fail "the capture script has no shared app path" +pass "the app is built once and installed on both simulators" + +echo "capture script contract: 13 checks passed" diff --git a/scripts/ios-fleet/test_pick_simulator.py b/scripts/ios-fleet/test_pick_simulator.py new file mode 100644 index 00000000..540434cd --- /dev/null +++ b/scripts/ios-fleet/test_pick_simulator.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Tests for the simulator picker the App Store screenshot capture depends on. + +No simulator and no network: the device list is a fixture that mirrors the +names Xcode reports, including the iPad storage variants that a plain equality +match would miss. +""" +from __future__ import annotations + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from pick_simulator import select_device # noqa: E402 + +# The names Xcode 27.x reports. The iPads carry their storage variant in the +# name, which is exactly what an equality match would miss. +DEVICES = [ + {"name": "iPhone 18 Pro", "udid": "A0", "isAvailable": True}, + {"name": "iPhone 17 Pro Max", "udid": "A1", "isAvailable": True}, + {"name": "iPhone 16 Plus", "udid": "A2", "isAvailable": True}, + {"name": "iPad Pro 13-inch (M5) (16GB)", "udid": "B0", "isAvailable": True}, + {"name": "iPad Pro 13-inch (M5) (12GB)", "udid": "B1", "isAvailable": True}, + {"name": "iPad Pro 11-inch (M5) (16GB)", "udid": "B2", "isAvailable": True}, + {"name": "iPhone 15 Pro Max", "udid": "A3", "isAvailable": False}, +] + +IPHONE_PREFERRED = "iPhone 17 Pro Max|iPhone 16 Pro Max|iPhone 15 Pro Max" +IPAD_PREFERRED = ( + "iPad Pro 13-inch (M5)|iPad Pro 13-inch (M4)" + "|iPad Pro 12.9-inch (6th generation)" +) + + +class SelectDeviceTests(unittest.TestCase): + def test_picks_the_first_preferred_name(self): + device = select_device(DEVICES, IPHONE_PREFERRED.split("|")) + self.assertIsNotNone(device) + self.assertEqual(device["udid"], "A1") + + def test_matches_an_ipad_named_with_its_storage_variant(self): + device = select_device(DEVICES, IPAD_PREFERRED.split("|")) + self.assertIsNotNone(device) + self.assertTrue(device["name"].startswith("iPad Pro 13-inch (M5)")) + + def test_falls_through_to_the_next_preferred_name(self): + device = select_device(DEVICES, ["iPhone 16 Pro Max", "iPhone 16 Plus"]) + self.assertIsNotNone(device) + self.assertEqual(device["udid"], "A2") + + def test_skips_a_device_that_is_not_available(self): + device = select_device(DEVICES, ["iPhone 15 Pro Max"]) + self.assertIsNone(device) + + def test_returns_nothing_when_no_name_matches(self): + device = select_device(DEVICES, ["iPad Pro 12.9-inch (3rd generation)"]) + self.assertIsNone(device) + + def test_does_not_match_a_different_model_with_a_shared_prefix(self): + device = select_device(DEVICES, ["iPad Pro 13-inch (M4)"]) + self.assertIsNone(device) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ios-fleet/test_screenshot_ink.py b/scripts/ios-fleet/test_screenshot_ink.py new file mode 100644 index 00000000..edc8a53d --- /dev/null +++ b/scripts/ios-fleet/test_screenshot_ink.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""Tests for the screenshot ink measurement the App Store capture depends on. + +No simulator and no image library: the frames are synthetic BMPs built here, +in both pixel depths and both row orders, because that is the whole range +`sips` produces for the capture. +""" +from __future__ import annotations + +import os +import struct +import sys +import tempfile +import unittest +from collections import Counter + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from screenshot_ink import ( # noqa: E402 + background_value, + ink_percent, + ink_shares, + read_bmp_channels, + read_bmp_frame, +) + + +def header_only_frame(width=200, height=200, header_rows=20): + """A frame with a navigation bar and nothing below it. + + This is the shape of a screen that has drawn its header and is still + waiting for its content, and it is the case the whole-frame threshold + cannot see: it covers a few percent of the frame, but none of the content + region. + """ + rows = [[(255, 255, 255) for _ in range(width)] for _ in range(height)] + for y in range(header_rows): + for x in range(width): + rows[y][x] = (136, 148, 13) + return rows + + +def build_bmp(width, height, rows, bits=24, top_down=True): + """Return a BMP whose pixels are the given rows of (b, g, r) tuples.""" + bytes_per_pixel = bits // 8 + stride = width * bytes_per_pixel + stride += (-stride) % 4 + order = rows if top_down else list(reversed(rows)) + pixels = bytearray() + for row in order: + for pixel in row: + blue, green, red = pixel + pixels += bytes((blue, green, red)) + if bytes_per_pixel == 4: + pixels += b"\xff" + pixels += b"\x00" * (stride - width * bytes_per_pixel) + offset = 54 + header = b"BM" + struct.pack(""$err" || rc=$? + show_client_stderr "$err" + name_missing_credential "$err" + return "$rc" +} + +# Prints what the client said, so nothing it reported is lost when its +# stderr is captured. +show_client_stderr() { + local err="$1" + if [[ -s "$err" ]]; then + cat "$err" >&2 + fi +} + +# Names the variables that could not be resolved when the client found no +# credential. The client says which sources it tried; this says which +# variables to set, so the failure is actionable instead of a permission +# message that points at nothing. +name_missing_credential() { + local err="$1" + if grep -q "no App Store Connect credential found" "$err" 2>/dev/null; then + echo "error: no App Store Connect credential resolved: set ASC_KEY_P8 with ASC_KEY_ID and ASC_ISSUER_ID, or let fleet-secrets answer for Fleet-OPS prod /asc" >&2 + fi +} + +run_client GET '/v1/profiles?filter[profileType]=IOS_APP_STORE&limit=200&include=bundleId' \ >"$WORKDIR/profiles.json" python3 - "$MAP" "$DEST" "$WORKDIR/profiles.json" <<'PY' import base64, json, os, plistlib, subprocess, sys