From eef0d37055f202a35436c3ee17e58c2c4f8ada09 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Mon, 10 Aug 2026 19:48:14 -0700 Subject: [PATCH 01/57] Client(iOS) - Move the demo's step derivations out of the views The three QA screens each derived their step statuses inline, as private computed properties on the View. Nothing could check them without building a screen, and none of the defects they carry is visible to a test that renders. This moves them to Example/PayabliDemo/Flow/ as pure functions and changes nothing about what they decide. The bodies are a transliteration: the recorded activation outcome is still read before the sequence has reached activation, the charge step still consults the session rather than the step in front of it, and the PayIn result step still keys off a result arriving rather than the form finishing. The vocabulary is Android's, so the two platforms name these the same things: StepStatus, FlowStep, StepRow. The QA prefix named an audience rather than a thing, and the demo runs against sandbox and production as readily as QA. The tests land here, red: 18 of 40 fail, 214 assertions, over 144 Tap to Pay combinations and 64 per card-not-present entry point. The next commit makes them pass. Check this one out to watch them fail. They live in a new PayabliDemoFlowTests target with no host application. Secrets.swift is gitignored and is a member of the app target's Sources phase, so the app cannot compile on a clean checkout and cannot host anything. Co-Authored-By: Claude Opus 5 (1M context) --- Example/PayabliDemo/Config/FlowTests.xcconfig | 21 ++ Example/PayabliDemo/Flow/PayInSteps.swift | 105 ++++++++ Example/PayabliDemo/Flow/StepStatus.swift | 74 ++++++ Example/PayabliDemo/Flow/TapToPaySteps.swift | 116 +++++++++ .../FlowTests/PayInStepsTests.swift | 207 ++++++++++++++++ .../FlowTests/StepStatusTests.swift | 25 ++ .../FlowTests/TapToPayStepsTests.swift | 228 ++++++++++++++++++ .../PayIn/PaymentCaptureQAView.swift | 90 +++---- .../PayIn/PaymentMethodQAView.swift | 88 +++---- .../PayabliDemo.xcodeproj/project.pbxproj | 146 ++++++++++- .../xcschemes/PayabliDemoFlowTests.xcscheme | 30 +++ .../Shared/{QAStepRow.swift => StepRow.swift} | 43 +--- .../TapToPay/PaymentTapToPayQAView.swift | 114 ++------- 13 files changed, 1027 insertions(+), 260 deletions(-) create mode 100644 Example/PayabliDemo/Config/FlowTests.xcconfig create mode 100644 Example/PayabliDemo/Flow/PayInSteps.swift create mode 100644 Example/PayabliDemo/Flow/StepStatus.swift create mode 100644 Example/PayabliDemo/Flow/TapToPaySteps.swift create mode 100644 Example/PayabliDemo/FlowTests/PayInStepsTests.swift create mode 100644 Example/PayabliDemo/FlowTests/StepStatusTests.swift create mode 100644 Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift create mode 100644 Example/PayabliDemo/PayabliDemo.xcodeproj/xcshareddata/xcschemes/PayabliDemoFlowTests.xcscheme rename Example/PayabliDemo/Shared/{QAStepRow.swift => StepRow.swift} (66%) diff --git a/Example/PayabliDemo/Config/FlowTests.xcconfig b/Example/PayabliDemo/Config/FlowTests.xcconfig new file mode 100644 index 0000000..fd42035 --- /dev/null +++ b/Example/PayabliDemo/Config/FlowTests.xcconfig @@ -0,0 +1,21 @@ +// The step sequences under Flow/, tested on their own. +// +// No TEST_HOST: the app target cannot compile on a clean checkout, because +// Secrets.swift is gitignored and is a member of its Sources phase. The settings +// below clear what Shared.xcconfig sets for the app. + +PRODUCT_BUNDLE_IDENTIFIER = com.payabli.example.app.flowtests +GENERATE_INFOPLIST_FILE = YES +INFOPLIST_FILE = +CODE_SIGN_ENTITLEMENTS = + +IPHONEOS_DEPLOYMENT_TARGET = 16.7 +SDKROOT = iphoneos +TARGETED_DEVICE_FAMILY = 1,2 +SWIFT_VERSION = 5.0 + +CODE_SIGNING_ALLOWED = NO +CODE_SIGN_STYLE = Manual +DEVELOPMENT_TEAM = + +LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks @loader_path/Frameworks diff --git a/Example/PayabliDemo/Flow/PayInSteps.swift b/Example/PayabliDemo/Flow/PayInSteps.swift new file mode 100644 index 0000000..83ef9fa --- /dev/null +++ b/Example/PayabliDemo/Flow/PayInSteps.swift @@ -0,0 +1,105 @@ +/// How far a card-not-present screen has got. +/// +/// - Parameters: +/// - tokenCheck: what the token probe last said. +/// - hasResult: a submission returned. The component keeps this forever and +/// exposes no reset. +/// - resultAcknowledged: the result has been read and another entry is wanted. +/// - isSubmitting: the SDK is submitting. +/// - submitFailed: the last submission failed, or came back carrying nothing. +struct PayInProgress { + var tokenCheck: TokenCheck = .notRun + var hasResult = false + var resultAcknowledged = false + var isSubmitting = false + var submitFailed = false +} + +/// The three steps of a card-not-present screen. +struct PayInFlowSteps { + let backend: FlowStep + let form: FlowStep + let result: FlowStep + + var all: [FlowStep] { + [backend, form, result] + } +} + +/// What a card-not-present screen asks for, in the order the SDK needs it. +enum PayInSteps { + /// Storing an instrument, which returns a token to reuse. + static func forStoringMethod(_ progress: PayInProgress) -> PayInFlowSteps { + steps( + progress, + formTitle: "Enter the card or ACH details", + resultTitle: "Stored method", + resultDetail: "A successful submit returns a reusable stored-method id." + ) + } + + /// Taking a payment now. + static func forCapture(_ progress: PayInProgress) -> PayInFlowSteps { + steps( + progress, + formTitle: "Enter the payment details", + resultTitle: "Transaction", + resultDetail: "A successful submit returns an approved transaction id." + ) + } + + private static func steps( + _ progress: PayInProgress, + formTitle: String, + resultTitle: String, + resultDetail: String + ) -> PayInFlowSteps { + // True once the backend is known reachable — either the probe was run, or + // a submit already succeeded, which proves it just as well. + let backendProven = progress.tokenCheck == .reachable || progress.hasResult + let showingFinishedResult = progress.hasResult && !progress.resultAcknowledged + + let backend: StepStatus = { + if progress.tokenCheck == .unreachable { + return .failed + } + return backendProven ? .done : .current + }() + + let form: StepStatus = { + // Exactly one step is ever `.current`, so this waits rather than + // competing with step 1 for attention. + guard backendProven else { return .blocked } + if progress.isSubmitting { + return .inProgress + } + if progress.submitFailed { + return .failed + } + return showingFinishedResult ? .done : .current + }() + + let result: StepStatus = { + // A failure belongs to the step that produced it. Marking this one + // failed too would give the sequence two actionable failures. + if progress.submitFailed { + return .blocked + } + return showingFinishedResult ? .current : .blocked + }() + + return PayInFlowSteps( + backend: FlowStep( + title: "Reach the token backend", + detail: "The SDK asks your backend for a short-lived access token before it submits.", + status: backend + ), + form: FlowStep( + title: formTitle, + detail: "The SDK owns these fields; clear PAN never reaches the host app.", + status: form + ), + result: FlowStep(title: resultTitle, detail: resultDetail, status: result) + ) + } +} diff --git a/Example/PayabliDemo/Flow/StepStatus.swift b/Example/PayabliDemo/Flow/StepStatus.swift new file mode 100644 index 0000000..7ed97c1 --- /dev/null +++ b/Example/PayabliDemo/Flow/StepStatus.swift @@ -0,0 +1,74 @@ +/// Where one step of a flow has got to. +enum StepStatus { + /// Finished, and nothing more to do here. + case done + /// The next thing to do. + case current + /// Underway inside the SDK; the app is waiting, not the person. + case inProgress + /// Cannot run until an earlier step finishes. + case blocked + /// Genuinely does not apply to this device or session. + case notNeeded + /// Attempted and failed. + case failed + + /// Whether this step shows its controls. + var showsContent: Bool { + self == .current || self == .failed + } + + /// Whether this step is the one asking for something. Narrower than + /// `showsContent`. + var isActionable: Bool { + self == .current || self == .failed + } + + /// Whether the step after this one may proceed. A skipped step counts as + /// finished. + /// + /// A step that reads the state underneath instead can offer itself alongside + /// an earlier step that is still asking for something. + var isFinished: Bool { + self == .done || self == .notNeeded + } +} + +/// One step, as a screen describes it. +/// +/// - Parameters: +/// - title: what the person is doing. +/// - detail: what the SDK does at this point, in one line. +struct FlowStep { + let title: String + let detail: String + let status: StepStatus +} + +/// What the token probe last said. +/// +/// Each screen records the probe as display text, and this is where the text +/// becomes an answer. +enum TokenCheck { + /// The probe has not been run. + case notRun + /// The probe is in flight. + case checking + /// The endpoint returned a token. + case reachable + /// The endpoint did not. + case unreachable + + static func classify(_ text: String) -> TokenCheck { + if text.hasPrefix("✓") { + return .reachable + } + if text.hasPrefix("✗") { + return .unreachable + } + if text.hasPrefix("Checking") { + return .checking + } + return .notRun + } +} diff --git a/Example/PayabliDemo/Flow/TapToPaySteps.swift b/Example/PayabliDemo/Flow/TapToPaySteps.swift new file mode 100644 index 0000000..63dfe6e --- /dev/null +++ b/Example/PayabliDemo/Flow/TapToPaySteps.swift @@ -0,0 +1,116 @@ +import PayabliSDKTapToPay + +/// Which half of the activation step happened. +/// +/// Activation is two SDK calls. `sessionState` cannot tell them apart: both land +/// in `.error`, except a revoked attestation, which lands in `.idle`. +enum TapToPayActivationOutcome { + case none + case activationFailed + case enableFailed + case succeeded +} + +/// The four steps of taking a contactless payment, in the order the SDK needs +/// them. +struct TapToPayFlowSteps { + let token: FlowStep + let enable: FlowStep + let activation: FlowStep + let charge: FlowStep + + var all: [FlowStep] { + [token, enable, activation, charge] + } +} + +/// What taking a contactless payment asks for. +enum TapToPaySteps { + /// - Parameters: + /// - tokenCheck: what the token probe last said. + /// - session: where the terminal session has got to. + /// - outcome: what the last activation attempt did. + static func forCharging( + tokenCheck: TokenCheck, + session: PayabliTTPSessionState, + activation outcome: TapToPayActivationOutcome + ) -> TapToPayFlowSteps { + // True once the backend is known reachable — either because the probe was + // run, or because the SDK already fetched a token to get past `idle`. + // + // Only states the session cannot reach without a successful authenticated + // request. `.attestingDevice` is set before that request, and `.error` is + // where a failing token provider lands, so neither proves anything. + let backendProven = switch session { + case .fetchingConfig, .initializingReader, .ready, .pendingActivation, .reinitializing: + true + default: + tokenCheck == .reachable + } + + let token: StepStatus = { + if tokenCheck == .unreachable { + return .failed + } + return backendProven ? .done : .current + }() + + let enable: StepStatus = { + // Exactly one step is ever `.current`, so this stays blocked until the + // backend is proven rather than competing with step 1 for attention. + guard backendProven else { return .blocked } + switch session { + case .ready: return .done + case .attestingDevice, .fetchingConfig, .initializingReader, .reinitializing: return .inProgress + // Activation is a separate step, so reaching it means this one finished. + case .pendingActivation: return .done + case .error, .sessionExpired: return .failed + case .idle: return .current + @unknown default: return .current + } + }() + + let activation: StepStatus = { + // A failed activation must stay actionable: `.failed` is the only other + // status whose content renders, so blocking it would hide the reason and + // the retry together. Read from the recorded outcome rather than from + // `sessionState`, which cannot distinguish the two calls this step makes + // and reports `.idle` for a revoked attestation. + if outcome == .activationFailed { + return .failed + } + switch session { + case .pendingActivation: return .current + case .ready: return .notNeeded + default: return .blocked + } + }() + + let charge: StepStatus = session == .ready ? .current : .blocked + + return TapToPayFlowSteps( + token: FlowStep( + title: "Reach the token backend", + detail: "The SDK calls your backend for a fresh access token whenever it needs one.", + status: token + ), + enable: FlowStep( + title: "Enable the terminal", + detail: "Attests the device, fetches the merchant config, and prepares the reader.", + status: enable + ), + activation: FlowStep( + title: "Activate the device", + detail: session == .pendingActivation + ? "Activate the device with the code provided by the Paypoint Device Management dashboard." + : "Only when the backend registers the device as pending.", + status: activation + ), + charge: FlowStep( + title: "Charge a card", + detail: "Presents Apple's Tap to Pay sheet. Hold a card to the top of the phone.", + status: charge + ) + ) + } +} diff --git a/Example/PayabliDemo/FlowTests/PayInStepsTests.swift b/Example/PayabliDemo/FlowTests/PayInStepsTests.swift new file mode 100644 index 0000000..23aeef9 --- /dev/null +++ b/Example/PayabliDemo/FlowTests/PayInStepsTests.swift @@ -0,0 +1,207 @@ +import XCTest + +/// The two card-not-present sequences, over every combination they can be asked +/// for. Both entry points go through the whole space, since they share a +/// derivation. +final class PayInStepsTests: XCTestCase { + private struct Entry { + let name: String + let build: (PayInProgress) -> PayInFlowSteps + } + + private let entries = [ + Entry(name: "storing a method", build: PayInSteps.forStoringMethod), + Entry(name: "capturing a payment", build: PayInSteps.forCapture) + ] + + private let everyProgress: [PayInProgress] = { + let checks: [TokenCheck] = [.notRun, .checking, .reachable, .unreachable] + return checks.flatMap { check in + [false, true].flatMap { hasResult in + [false, true].flatMap { acknowledged in + [false, true].flatMap { submitting in + [false, true].map { failed in + PayInProgress( + tokenCheck: check, + hasResult: hasResult, + resultAcknowledged: acknowledged, + isSubmitting: submitting, + submitFailed: failed + ) + } + } + } + } + } + }() + + /// Enough of the progress to name the case a failure came from. + private func describe(_ entry: Entry, _ progress: PayInProgress) -> String { + """ + \(entry.name): token \(progress.tokenCheck), \ + result \(progress.hasResult), acknowledged \(progress.resultAcknowledged), \ + submitting \(progress.isSubmitting), failed \(progress.submitFailed) + """ + } + + private func each(_ body: (Entry, PayInProgress, PayInFlowSteps) -> Void) { + for entry in entries { + for progress in everyProgress { + body(entry, progress, entry.build(progress)) + } + } + } + + func testTheSpaceIsTheSizeItClaims() { + XCTAssertEqual(everyProgress.count, 4 * 2 * 2 * 2 * 2) + } + + // MARK: - Invariants, over the whole space + + func testNoTwoStepsShowTheirControlsAtOnce() { + each { entry, progress, steps in + let showing = steps.all.filter(\.status.showsContent) + XCTAssertLessThanOrEqual( + showing.count, 1, + "\(describe(entry, progress)) renders \(showing.map(\.title))" + ) + } + } + + func testNoTwoStepsAskForSomethingAtOnce() { + each { entry, progress, steps in + let actionable = steps.all.filter(\.status.isActionable) + XCTAssertLessThanOrEqual( + actionable.count, 1, + "\(describe(entry, progress)) asks for \(actionable.map(\.title))" + ) + } + } + + func testAFailureIsNeverReportedByMoreThanOneStep() { + each { entry, progress, steps in + let failed = steps.all.filter { $0.status == .failed } + XCTAssertLessThanOrEqual( + failed.count, 1, + "\(describe(entry, progress)) reports \(failed.count) failures" + ) + } + } + + func testEveryStepAfterAnUnfinishedOneIsBlocked() { + each { entry, progress, steps in + let all = steps.all + guard let firstUnfinished = all.firstIndex(where: { !$0.status.isFinished }) else { return } + for step in all.dropFirst(firstUnfinished + 1) { + XCTAssertEqual( + step.status, .blocked, + "\(describe(entry, progress)) let \(step.title) run past \(all[firstUnfinished].title)" + ) + } + } + } + + func testEveryStepSomeoneCanActOnShowsItsControls() { + each { entry, progress, steps in + for step in steps.all where step.status.isActionable { + XCTAssertTrue( + step.status.showsContent, + "\(describe(entry, progress)): \(step.title) asks for something it does not show" + ) + } + } + } + + func testAResultIsOfferedOnlyWhenEveryStepBeforeItHasFinished() { + each { entry, progress, steps in + guard steps.result.status.isActionable else { return } + XCTAssertTrue(steps.backend.status.isFinished, "\(describe(entry, progress)) offered a result") + XCTAssertTrue(steps.form.status.isFinished, "\(describe(entry, progress)) offered a result") + } + } + + func testEveryStepSaysWhatItIs() { + each { entry, progress, steps in + XCTAssertEqual(steps.all.count, 3, "\(describe(entry, progress))") + for step in steps.all { + XCTAssertFalse(step.title.isEmpty, "\(describe(entry, progress))") + XCTAssertFalse(step.detail.isEmpty, "\(describe(entry, progress))") + } + } + } + + // MARK: - The order, one point at a time + + func testAnUnprovenBackendKeepsTheSequenceOnTheProbe() { + let steps = PayInSteps.forCapture(PayInProgress()) + XCTAssertEqual(steps.backend.status, .current) + XCTAssertEqual(steps.form.status, .blocked) + XCTAssertEqual(steps.result.status, .blocked) + } + + func testAProbeInFlightIsTheAppWorkingNotThePerson() { + let steps = PayInSteps.forCapture(PayInProgress(tokenCheck: .checking)) + XCTAssertEqual(steps.backend.status, .inProgress) + XCTAssertFalse(steps.backend.status.isActionable) + } + + func testAProvenBackendHandsTheSequenceToTheForm() { + let steps = PayInSteps.forCapture(PayInProgress(tokenCheck: .reachable)) + XCTAssertEqual(steps.backend.status, .done) + XCTAssertEqual(steps.form.status, .current) + } + + func testASubmissionInFlightKeepsWhatTheFormIsHolding() { + // A hidden row is a deallocated view model, so a decline would come back + // to an empty form. + let steps = PayInSteps.forCapture(PayInProgress(tokenCheck: .reachable, isSubmitting: true)) + XCTAssertEqual(steps.form.status, .inProgress) + XCTAssertTrue(steps.form.status.showsContent) + XCTAssertFalse(steps.form.status.isActionable) + } + + func testAFailedSubmissionIsReportedByTheStepThatTookIt() { + let steps = PayInSteps.forCapture(PayInProgress(tokenCheck: .reachable, submitFailed: true)) + XCTAssertEqual(steps.form.status, .failed) + XCTAssertEqual(steps.result.status, .blocked) + } + + func testAFinishedSubmissionHandsTheSequenceToItsResult() { + let steps = PayInSteps.forCapture(PayInProgress(tokenCheck: .reachable, hasResult: true)) + XCTAssertEqual(steps.form.status, .done) + XCTAssertEqual(steps.result.status, .current) + } + + func testStartingOverHandsTheSequenceBackToTheForm() { + let steps = PayInSteps.forCapture( + PayInProgress(tokenCheck: .reachable, hasResult: true, resultAcknowledged: true) + ) + XCTAssertEqual(steps.form.status, .current) + XCTAssertEqual(steps.result.status, .blocked) + } + + func testTheLatestProbeOutranksASubmissionThatSucceededEarlier() { + let steps = PayInSteps.forCapture(PayInProgress(tokenCheck: .unreachable, hasResult: true)) + XCTAssertEqual(steps.backend.status, .failed) + XCTAssertEqual(steps.form.status, .blocked) + XCTAssertEqual(steps.result.status, .blocked) + } + + func testASubmissionProvesTheBackendWhenNoProbeHasRun() { + let steps = PayInSteps.forCapture(PayInProgress(tokenCheck: .notRun, hasResult: true)) + XCTAssertEqual(steps.backend.status, .done) + XCTAssertEqual(steps.result.status, .current) + } + + func testTheTwoEntryPointsDifferOnlyInWhatTheyCallTheirSteps() { + let progress = PayInProgress(tokenCheck: .reachable, hasResult: true) + let storing = PayInSteps.forStoringMethod(progress) + let capturing = PayInSteps.forCapture(progress) + + XCTAssertEqual(storing.all.map(\.status), capturing.all.map(\.status)) + XCTAssertEqual(storing.backend.title, capturing.backend.title) + XCTAssertNotEqual(storing.form.title, capturing.form.title) + XCTAssertNotEqual(storing.result.title, capturing.result.title) + XCTAssertNotEqual(storing.result.detail, capturing.result.detail) + } +} diff --git a/Example/PayabliDemo/FlowTests/StepStatusTests.swift b/Example/PayabliDemo/FlowTests/StepStatusTests.swift new file mode 100644 index 0000000..97c7c66 --- /dev/null +++ b/Example/PayabliDemo/FlowTests/StepStatusTests.swift @@ -0,0 +1,25 @@ +import PayabliSDKTapToPay +import XCTest + +/// The vocabulary the sequences are written in. +final class StepStatusTests: XCTestCase { + func testOnlyDoneAndNotNeededReleaseTheStepAfter() { + XCTAssertTrue(StepStatus.done.isFinished) + XCTAssertTrue(StepStatus.notNeeded.isFinished) + XCTAssertFalse(StepStatus.current.isFinished) + XCTAssertFalse(StepStatus.inProgress.isFinished) + XCTAssertFalse(StepStatus.blocked.isFinished) + XCTAssertFalse(StepStatus.failed.isFinished) + } + + func testTheDemoKnowsEverySessionStateTheSDKHas() { + // `PayabliTTPSessionState` is `@objc`, so it cannot be `CaseIterable` and + // the list below is written out. A tenth state fails here first. + XCTAssertEqual(everyTapToPaySession.count, 9) + XCTAssertNil(PayabliTTPSessionState(rawValue: 9)) + } +} + +/// The nine session states, by raw value, since the enum is `@objc`. +let everyTapToPaySession: [PayabliTTPSessionState] = + (0 ... 8).compactMap(PayabliTTPSessionState.init(rawValue:)) diff --git a/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift b/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift new file mode 100644 index 0000000..56e192a --- /dev/null +++ b/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift @@ -0,0 +1,228 @@ +import PayabliSDKTapToPay +import XCTest + +/// The Tap to Pay sequence, over every combination it can be asked for. +final class TapToPayStepsTests: XCTestCase { + /// What the probe said, where the session is, and what activation did. + private struct Combination: CustomStringConvertible { + let tokenCheck: TokenCheck + let session: PayabliTTPSessionState + let outcome: TapToPayActivationOutcome + + var description: String { + "token \(tokenCheck), session \(sessionName(session)), activation \(outcome)" + } + } + + private let everyCombination: [Combination] = { + let checks: [TokenCheck] = [.notRun, .checking, .reachable, .unreachable] + let outcomes: [TapToPayActivationOutcome] = [.none, .activationFailed, .enableFailed, .succeeded] + return checks.flatMap { check in + everyTapToPaySession.flatMap { session in + outcomes.map { Combination(tokenCheck: check, session: session, outcome: $0) } + } + } + }() + + private func steps(_ combination: Combination) -> TapToPayFlowSteps { + TapToPaySteps.forCharging( + tokenCheck: combination.tokenCheck, + session: combination.session, + activation: combination.outcome + ) + } + + func testTheSpaceIsTheSizeItClaims() { + XCTAssertEqual(everyCombination.count, 4 * 9 * 4) + } + + // MARK: - Invariants, over the whole space + + func testNoTwoStepsShowTheirControlsAtOnce() { + for combination in everyCombination { + let showing = steps(combination).all.filter(\.status.showsContent) + XCTAssertLessThanOrEqual( + showing.count, 1, + "\(combination) renders \(showing.map(\.title))" + ) + } + } + + func testNoTwoStepsAskForSomethingAtOnce() { + for combination in everyCombination { + let actionable = steps(combination).all.filter(\.status.isActionable) + XCTAssertLessThanOrEqual( + actionable.count, 1, + "\(combination) asks for \(actionable.map(\.title))" + ) + } + } + + func testAFailureIsNeverReportedByMoreThanOneStep() { + for combination in everyCombination { + let failed = steps(combination).all.filter { $0.status == .failed } + XCTAssertLessThanOrEqual( + failed.count, 1, + "\(combination) reports \(failed.count) failures: \(failed.map(\.title))" + ) + } + } + + func testEveryStepAfterAnUnfinishedOneIsBlocked() { + for combination in everyCombination { + let all = steps(combination).all + guard let firstUnfinished = all.firstIndex(where: { !$0.status.isFinished }) else { continue } + for step in all.dropFirst(firstUnfinished + 1) { + XCTAssertEqual( + step.status, .blocked, + "\(combination) let \(step.title) run past \(all[firstUnfinished].title)" + ) + } + } + } + + func testEveryStepSomeoneCanActOnShowsItsControls() { + for combination in everyCombination { + for step in steps(combination).all where step.status.isActionable { + XCTAssertTrue( + step.status.showsContent, + "\(combination): \(step.title) asks for something it does not show" + ) + } + } + } + + func testAChargeIsOfferedOnlyWhenEveryStepBeforeItHasFinished() { + for combination in everyCombination { + let sequence = steps(combination) + guard sequence.charge.status.isActionable else { continue } + XCTAssertTrue(sequence.token.status.isFinished, "\(combination) offered a charge") + XCTAssertEqual(sequence.enable.status, .done, "\(combination) offered a charge") + XCTAssertTrue(sequence.activation.status.isFinished, "\(combination) offered a charge") + XCTAssertEqual(combination.session, .ready, "\(combination) offered a charge") + } + } + + func testEveryStepSaysWhatItIs() { + for combination in everyCombination { + let all = steps(combination).all + XCTAssertEqual(all.count, 4, "\(combination)") + for step in all { + XCTAssertFalse(step.title.isEmpty, "\(combination)") + XCTAssertFalse(step.detail.isEmpty, "\(combination)") + } + } + } + + // MARK: - The order, one point at a time + + func testAnUnprovenBackendKeepsTheSequenceOnTheProbe() { + let sequence = TapToPaySteps.forCharging(tokenCheck: .notRun, session: .idle, activation: .none) + XCTAssertEqual(sequence.token.status, .current) + XCTAssertEqual(sequence.enable.status, .blocked) + } + + func testAProbeInFlightIsTheAppWorkingNotThePerson() { + let sequence = TapToPaySteps.forCharging(tokenCheck: .checking, session: .idle, activation: .none) + XCTAssertEqual(sequence.token.status, .inProgress) + XCTAssertFalse(sequence.token.status.isActionable) + } + + func testAProvenBackendHandsTheSequenceToTheTerminal() { + let sequence = TapToPaySteps.forCharging(tokenCheck: .reachable, session: .idle, activation: .none) + XCTAssertEqual(sequence.token.status, .done) + XCTAssertEqual(sequence.enable.status, .current) + } + + func testStartingTheTerminalIsTheAppWaitingNotThePerson() { + for session in [PayabliTTPSessionState.attestingDevice, .fetchingConfig, .initializingReader, .reinitializing] { + let sequence = TapToPaySteps.forCharging(tokenCheck: .reachable, session: session, activation: .none) + XCTAssertEqual(sequence.enable.status, .inProgress, "\(sessionName(session))") + XCTAssertFalse(sequence.enable.status.isActionable, "\(sessionName(session))") + } + } + + func testASessionThatStoppedKeepsTheStepThatCanStartItAgain() { + for session in [PayabliTTPSessionState.error, .sessionExpired] { + let sequence = TapToPaySteps.forCharging(tokenCheck: .reachable, session: session, activation: .none) + XCTAssertEqual(sequence.enable.status, .failed, "\(sessionName(session))") + XCTAssertTrue(sequence.enable.status.showsContent, "\(sessionName(session))") + } + } + + func testADeviceAwaitingRegistrationIsAskedForACodeAndCannotChargeYet() { + let sequence = TapToPaySteps.forCharging( + tokenCheck: .reachable, session: .pendingActivation, activation: .none + ) + XCTAssertEqual(sequence.enable.status, .done) + XCTAssertEqual(sequence.activation.status, .current) + XCTAssertEqual(sequence.charge.status, .blocked) + } + + func testARefusedActivationKeepsItsOwnStepAndBlocksTheCharge() { + let sequence = TapToPaySteps.forCharging( + tokenCheck: .reachable, session: .pendingActivation, activation: .activationFailed + ) + XCTAssertEqual(sequence.activation.status, .failed) + XCTAssertTrue(sequence.activation.status.showsContent) + XCTAssertEqual(sequence.charge.status, .blocked) + } + + func testARecordedActivationFailureStaysQuietUntilTheSequenceReachesActivation() { + let sequence = TapToPaySteps.forCharging( + tokenCheck: .reachable, session: .idle, activation: .activationFailed + ) + XCTAssertEqual(sequence.enable.status, .current) + XCTAssertEqual(sequence.activation.status, .blocked) + } + + func testAnActivationThatSucceededReadsAsDoneNotAsOneThatNeverApplied() { + let sequence = TapToPaySteps.forCharging( + tokenCheck: .reachable, session: .ready, activation: .succeeded + ) + XCTAssertEqual(sequence.activation.status, .done) + } + + func testATerminalThatNeverNeededActivationSaysSoRatherThanPretendingItIsDone() { + let sequence = TapToPaySteps.forCharging(tokenCheck: .reachable, session: .ready, activation: .none) + XCTAssertEqual(sequence.activation.status, .notNeeded) + XCTAssertEqual(sequence.charge.status, .current) + } + + func testAFailedProbeHoldsTheWholeSequenceEvenWithAReadyTerminal() { + // Enable the terminal, then point the probe at a backend that is down. + let sequence = TapToPaySteps.forCharging( + tokenCheck: .unreachable, session: .ready, activation: .none + ) + XCTAssertEqual(sequence.token.status, .failed) + XCTAssertEqual(sequence.enable.status, .blocked) + XCTAssertEqual(sequence.activation.status, .blocked) + XCTAssertEqual(sequence.charge.status, .blocked) + } + + func testTheActivationStepSaysWhereTheCodeComesFromOnlyWhenOneIsWanted() { + let pending = TapToPaySteps.forCharging( + tokenCheck: .reachable, session: .pendingActivation, activation: .none + ) + let idle = TapToPaySteps.forCharging(tokenCheck: .reachable, session: .idle, activation: .none) + XCTAssertTrue(pending.activation.detail.contains("Device Management")) + XCTAssertNotEqual(idle.activation.detail, pending.activation.detail) + } +} + +/// `PayabliTTPSessionState` is `@objc`, so `String(describing:)` renders a raw +/// value rather than the case name, and a failure message has to name the state. +func sessionName(_ state: PayabliTTPSessionState) -> String { + switch state { + case .idle: return "idle" + case .attestingDevice: return "attestingDevice" + case .fetchingConfig: return "fetchingConfig" + case .initializingReader: return "initializingReader" + case .ready: return "ready" + case .sessionExpired: return "sessionExpired" + case .reinitializing: return "reinitializing" + case .pendingActivation: return "pendingActivation" + case .error: return "error" + @unknown default: return "state(\(state.rawValue))" + } +} diff --git a/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift b/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift index 1e526cb..78ab900 100644 --- a/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift +++ b/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift @@ -25,12 +25,7 @@ struct PaymentCaptureQAView: View { Text("Steps") .font(.headline) - QAStepRow( - index: 1, - title: "Reach the token backend", - detail: "The SDK asks your backend for a short-lived access token before it submits.", - status: tokenStepStatus - ) { + StepRow(index: 1, step: steps.backend) { VStack(alignment: .leading, spacing: 6) { Button { runTokenCheck() } label: { Label("Check token endpoint", systemImage: "key.horizontal") @@ -45,12 +40,7 @@ struct PaymentCaptureQAView: View { } } - QAStepRow( - index: 2, - title: "Enter the payment details", - detail: "The SDK owns these fields; clear PAN never reaches the host app.", - status: formStepStatus - ) { + StepRow(index: 2, step: steps.form) { VStack(alignment: .leading, spacing: 12) { Button { isPaymentCaptureSheetPresented = true @@ -61,7 +51,7 @@ struct PaymentCaptureQAView: View { .buttonStyle(.bordered) #if DEBUG - DebugPrefillButton() + DebugPrefillButton() #endif PayabliPayInPaymentFlowView( @@ -74,12 +64,7 @@ struct PaymentCaptureQAView: View { } } - QAStepRow( - index: 3, - title: "Transaction", - detail: "A successful submit returns an approved transaction id.", - status: resultStepStatus - ) { + StepRow(index: 3, step: steps.result) { VStack(alignment: .leading, spacing: 10) { Text(resultText.isEmpty ? "Nothing captured yet." : resultText) .font(.footnote) @@ -120,34 +105,34 @@ struct PaymentCaptureQAView: View { ) #if DEBUG .onChange(of: isPaymentCaptureSheetPresented) { isPresented in - guard isPresented else { return } - // Let the sheet's fields mount before injecting values. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { - DebugPrefill.fill() + guard isPresented else { return } + // Let the sheet's fields mount before injecting values. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + DebugPrefill.fill() + } } - } #endif } - // MARK: - Step status + // MARK: - The sequence /// `PayabliPayInPaymentFlow` publishes only `isSubmitting` and `lastResult`, - /// so these three derive from those plus the token check — never from state - /// tracked separately, which could disagree with the component. - /// True once the backend is known reachable — either the check was run, or - /// a submit already succeeded, which proves it just as well. - private var backendProven: Bool { - tokenCheckText.hasPrefix("✓") || paymentFlow.lastResult != nil - } - - /// The component keeps `lastResult` forever and exposes no reset, so a - /// finished submit would pin the flow on step 3 with no way back. This - /// records that the result has been read and another entry is wanted. - private var showingFinishedResult: Bool { - paymentFlow.lastResult != nil && !resultAcknowledged + /// so the sequence derives from those plus the token probe. + private var steps: PayInFlowSteps { + PayInSteps.forCapture( + PayInProgress( + tokenCheck: TokenCheck.classify(tokenCheckText), + hasResult: paymentFlow.lastResult != nil, + resultAcknowledged: resultAcknowledged, + isSubmitting: paymentFlow.isSubmitting, + submitFailed: submitFailed + ) + ) } - /// Hands the flow back to step 2 for another entry. + /// Hands the flow back to step 2 for another entry. The component keeps + /// `lastResult` forever and exposes no reset, so a finished submit would + /// otherwise pin the sequence on step 3. private func startAnother() { resultAcknowledged = true submitFailed = false @@ -155,27 +140,6 @@ struct PaymentCaptureQAView: View { paymentFlow.configure(requestConfiguration: Self.freshRequestConfiguration()) } - private var tokenStepStatus: QAStepStatus { - if tokenCheckText.hasPrefix("✗") { return .failed } - return backendProven ? .done : .current - } - - private var formStepStatus: QAStepStatus { - // Exactly one step is ever `.current`, so this waits rather than - // competing with step 1 for attention. - guard backendProven else { return .blocked } - if paymentFlow.isSubmitting { return .inProgress } - if submitFailed { return .failed } - return showingFinishedResult ? .done : .current - } - - private var resultStepStatus: QAStepStatus { - // A failure belongs to the step that produced it. Marking this one failed - // too would give the sequence two actionable failures. - if submitFailed { return .blocked } - return showingFinishedResult ? .current : .blocked - } - /// A capture's request configuration, with a key minted per submission. /// /// The app builds one of these at launch for the initial submit. Reusing that @@ -326,7 +290,9 @@ struct PaymentCaptureQAView: View { ) } - private var style: PayabliPayInPaymentFlowStyle { PayInSharedConfiguration.style } + private var style: PayabliPayInPaymentFlowStyle { + PayInSharedConfiguration.style + } private var fieldsWithHiddenLabels: [PayabliPayInPaymentFlowField] { PayInSharedConfiguration.fieldsWithHiddenLabels @@ -386,7 +352,6 @@ struct PaymentCaptureQAView: View { } } - #Preview { PaymentCaptureQAView( paymentFlow: PayabliPayInPaymentFlow( @@ -408,4 +373,3 @@ struct PaymentCaptureQAView: View { ) ) } - diff --git a/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift b/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift index 8d1fe35..8e2f0bd 100644 --- a/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift +++ b/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift @@ -24,12 +24,7 @@ struct PaymentMethodQAView: View { Text("Steps") .font(.headline) - QAStepRow( - index: 1, - title: "Reach the token backend", - detail: "The SDK asks your backend for a short-lived access token before it submits.", - status: tokenStepStatus - ) { + StepRow(index: 1, step: steps.backend) { VStack(alignment: .leading, spacing: 6) { Button { runTokenCheck() } label: { Label("Check token endpoint", systemImage: "key.horizontal") @@ -44,12 +39,7 @@ struct PaymentMethodQAView: View { } } - QAStepRow( - index: 2, - title: "Enter the card or ACH details", - detail: "The SDK owns these fields; clear PAN never reaches the host app.", - status: formStepStatus - ) { + StepRow(index: 2, step: steps.form) { VStack(alignment: .leading, spacing: 12) { Button { isPaymentMethodSheetPresented = true @@ -60,7 +50,7 @@ struct PaymentMethodQAView: View { .buttonStyle(.bordered) #if DEBUG - DebugPrefillButton() + DebugPrefillButton() #endif PayabliPayInPaymentFlowView( @@ -73,12 +63,7 @@ struct PaymentMethodQAView: View { } } - QAStepRow( - index: 3, - title: "Stored method", - detail: "A successful submit returns a reusable stored-method id.", - status: resultStepStatus - ) { + StepRow(index: 3, step: steps.result) { VStack(alignment: .leading, spacing: 10) { Text(resultText.isEmpty ? "Nothing stored yet." : resultText) .font(.footnote) @@ -117,61 +102,40 @@ struct PaymentMethodQAView: View { ) #if DEBUG .onChange(of: isPaymentMethodSheetPresented) { isPresented in - guard isPresented else { return } - // Let the sheet's fields mount before injecting values. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { - DebugPrefill.fill() + guard isPresented else { return } + // Let the sheet's fields mount before injecting values. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + DebugPrefill.fill() + } } - } #endif } - // MARK: - Step status + // MARK: - The sequence /// `PayabliPayInPaymentFlow` publishes only `isSubmitting` and `lastResult`, - /// so these three derive from those plus the token check — never from state - /// tracked separately, which could disagree with the component. - /// True once the backend is known reachable — either the check was run, or - /// a submit already succeeded, which proves it just as well. - private var backendProven: Bool { - tokenCheckText.hasPrefix("✓") || paymentFlow.lastResult != nil - } - - /// The component keeps `lastResult` forever and exposes no reset, so a - /// finished submit would pin the flow on step 3 with no way back. This - /// records that the result has been read and another entry is wanted. - private var showingFinishedResult: Bool { - paymentFlow.lastResult != nil && !resultAcknowledged + /// so the sequence derives from those plus the token probe. + private var steps: PayInFlowSteps { + PayInSteps.forStoringMethod( + PayInProgress( + tokenCheck: TokenCheck.classify(tokenCheckText), + hasResult: paymentFlow.lastResult != nil, + resultAcknowledged: resultAcknowledged, + isSubmitting: paymentFlow.isSubmitting, + submitFailed: submitFailed + ) + ) } - /// Hands the flow back to step 2 for another entry. + /// Hands the flow back to step 2 for another entry. The component keeps + /// `lastResult` forever and exposes no reset, so a finished submit would + /// otherwise pin the sequence on step 3. private func startAnother() { resultAcknowledged = true submitFailed = false resultText = "" } - private var tokenStepStatus: QAStepStatus { - if tokenCheckText.hasPrefix("✗") { return .failed } - return backendProven ? .done : .current - } - - private var formStepStatus: QAStepStatus { - // Exactly one step is ever `.current`, so this waits rather than - // competing with step 1 for attention. - guard backendProven else { return .blocked } - if paymentFlow.isSubmitting { return .inProgress } - if submitFailed { return .failed } - return showingFinishedResult ? .done : .current - } - - private var resultStepStatus: QAStepStatus { - // A failure belongs to the step that produced it. Marking this one failed - // too would give the sequence two actionable failures. - if submitFailed { return .blocked } - return showingFinishedResult ? .current : .blocked - } - /// Reports only that a token arrived. Never the token itself. private func runTokenCheck() { isCheckingToken = true @@ -274,7 +238,9 @@ struct PaymentMethodQAView: View { ) } - private var style: PayabliPayInPaymentFlowStyle { PayInSharedConfiguration.style } + private var style: PayabliPayInPaymentFlowStyle { + PayInSharedConfiguration.style + } private var fieldsWithHiddenLabels: [PayabliPayInPaymentFlowField] { PayInSharedConfiguration.fieldsWithHiddenLabels diff --git a/Example/PayabliDemo/PayabliDemo.xcodeproj/project.pbxproj b/Example/PayabliDemo/PayabliDemo.xcodeproj/project.pbxproj index bfc31dc..23b48cf 100644 --- a/Example/PayabliDemo/PayabliDemo.xcodeproj/project.pbxproj +++ b/Example/PayabliDemo/PayabliDemo.xcodeproj/project.pbxproj @@ -8,7 +8,7 @@ /* Begin PBXBuildFile section */ A1B2C3D4E5F60000000000E2 /* QAContextLine.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60000000000E1 /* QAContextLine.swift */; }; - A1B2C3D4E5F60000000000D2 /* QAStepRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60000000000D1 /* QAStepRow.swift */; }; + A1B2C3D4E5F60000000000D2 /* StepRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60000000000D1 /* StepRow.swift */; }; 0F5E8CC7E7A24D19A71B0A1C /* PaymentMethodAddedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F5E8CC6E7A24D19A71B0A1C /* PaymentMethodAddedView.swift */; }; 1C7A9E9B6D2F4C0F8A1B3D5E /* PaymentCaptureQAView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B1E0F9A8C7D6E5F4A3B2C1D /* PaymentCaptureQAView.swift */; }; 3F9C8D7E6A5B4C3D2E1F0A9B /* PayabliSDKExampleAggregate in Frameworks */ = {isa = PBXBuildFile; productRef = 4A0D9E8F7B6C5D4E3F2A1B0C /* PayabliSDKExampleAggregate */; }; @@ -30,9 +30,31 @@ E1D2C3B4A5960718293A4B01 /* DebugPrefill.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D2C3B4A5960718293A4B02 /* DebugPrefill.swift */; }; E1D2C3B4A5960718293A4B03 /* DebugPrefill.json in Resources */ = {isa = PBXBuildFile; fileRef = E1D2C3B4A5960718293A4B04 /* DebugPrefill.json */; }; F10000000000000000000030 /* PayabliSDKExampleAggregate in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 4A0D9E8F7B6C5D4E3F2A1B0C /* PayabliSDKExampleAggregate */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + F20000000000000000000002 /* StepStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000001 /* StepStatus.swift */; }; + F20000000000000000000004 /* TapToPaySteps.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000003 /* TapToPaySteps.swift */; }; + F20000000000000000000006 /* PayInSteps.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000005 /* PayInSteps.swift */; }; + F20000000000000000000012 /* StepStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000001 /* StepStatus.swift */; }; + F20000000000000000000013 /* TapToPaySteps.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000003 /* TapToPaySteps.swift */; }; + F20000000000000000000014 /* PayInSteps.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000005 /* PayInSteps.swift */; }; + F20000000000000000000022 /* StepStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000021 /* StepStatusTests.swift */; }; + F20000000000000000000024 /* TapToPayStepsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000023 /* TapToPayStepsTests.swift */; }; + F20000000000000000000026 /* PayInStepsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000025 /* PayInStepsTests.swift */; }; + F20000000000000000000040 /* PayabliSDKExampleAggregate in Frameworks */ = {isa = PBXBuildFile; productRef = F20000000000000000000041 /* PayabliSDKExampleAggregate */; }; + F20000000000000000000042 /* PayabliSDKExampleAggregate in Embed Frameworks */ = {isa = PBXBuildFile; productRef = F20000000000000000000041 /* PayabliSDKExampleAggregate */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ + F20000000000000000000043 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + F20000000000000000000042 /* PayabliSDKExampleAggregate in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; F10000000000000000000031 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -48,7 +70,7 @@ /* Begin PBXFileReference section */ A1B2C3D4E5F60000000000E1 /* QAContextLine.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = QAContextLine.swift; sourceTree = ""; }; - A1B2C3D4E5F60000000000D1 /* QAStepRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = QAStepRow.swift; sourceTree = ""; }; + A1B2C3D4E5F60000000000D1 /* StepRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StepRow.swift; sourceTree = ""; }; 0F5E8CC6E7A24D19A71B0A1C /* PaymentMethodAddedView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PaymentMethodAddedView.swift; sourceTree = ""; }; 1DADA588F49DF0C4A6611302 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 4C921D1F3EE7130C23911757 /* Secrets.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = ""; }; @@ -75,10 +97,26 @@ F1000000000000000000000D /* Debug-XCFramework.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = "Debug-XCFramework.xcconfig"; sourceTree = ""; }; F10000000000000000000020 /* check-sdk-linkage.sh */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.script.sh; path = "check-sdk-linkage.sh"; sourceTree = ""; }; F201F43C477597B328446F93 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; + F20000000000000000000001 /* StepStatus.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StepStatus.swift; sourceTree = ""; }; + F20000000000000000000003 /* TapToPaySteps.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TapToPaySteps.swift; sourceTree = ""; }; + F20000000000000000000005 /* PayInSteps.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PayInSteps.swift; sourceTree = ""; }; + F20000000000000000000021 /* StepStatusTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StepStatusTests.swift; sourceTree = ""; }; + F20000000000000000000023 /* TapToPayStepsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TapToPayStepsTests.swift; sourceTree = ""; }; + F20000000000000000000025 /* PayInStepsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PayInStepsTests.swift; sourceTree = ""; }; + F20000000000000000000030 /* FlowTests.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = FlowTests.xcconfig; sourceTree = ""; }; + F20000000000000000000050 /* PayabliDemoFlowTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PayabliDemoFlowTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; F427A5EA0CEF029F22AF2170 /* PayabliDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PayabliDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + F20000000000000000000044 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F20000000000000000000040 /* PayabliSDKExampleAggregate in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 57ED6F126A7F63E859E6B9C1 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -112,6 +150,7 @@ isa = PBXGroup; children = ( F427A5EA0CEF029F22AF2170 /* PayabliDemo.app */, + F20000000000000000000050 /* PayabliDemoFlowTests.xctest */, ); name = Products; sourceTree = ""; @@ -130,6 +169,8 @@ E1000000000000000000000A /* App */, F10000000000000000000010 /* Config */, F10000000000000000000021 /* Scripts */, + F20000000000000000000010 /* Flow */, + F20000000000000000000020 /* FlowTests */, E1000000000000000000000B /* Configuration */, E1000000000000000000000C /* TapToPay */, E10000000000000000000020 /* Shared */, @@ -225,6 +266,7 @@ F1000000000000000000000B /* Debug.xcconfig */, F1000000000000000000000C /* Release.xcconfig */, F1000000000000000000000D /* Debug-XCFramework.xcconfig */, + F20000000000000000000030 /* FlowTests.xcconfig */, ); path = Config; sourceTree = ""; @@ -241,15 +283,55 @@ isa = PBXGroup; children = ( D10000000000000000000005 /* QADetailRow.swift */, - A1B2C3D4E5F60000000000D1 /* QAStepRow.swift */, + A1B2C3D4E5F60000000000D1 /* StepRow.swift */, A1B2C3D4E5F60000000000E1 /* QAContextLine.swift */, ); path = Shared; sourceTree = ""; }; + F20000000000000000000010 /* Flow */ = { + isa = PBXGroup; + children = ( + F20000000000000000000001 /* StepStatus.swift */, + F20000000000000000000003 /* TapToPaySteps.swift */, + F20000000000000000000005 /* PayInSteps.swift */, + ); + path = Flow; + sourceTree = ""; + }; + F20000000000000000000020 /* FlowTests */ = { + isa = PBXGroup; + children = ( + F20000000000000000000021 /* StepStatusTests.swift */, + F20000000000000000000023 /* TapToPayStepsTests.swift */, + F20000000000000000000025 /* PayInStepsTests.swift */, + ); + path = FlowTests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + F20000000000000000000060 /* PayabliDemoFlowTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = F20000000000000000000070 /* Build configuration list for PBXNativeTarget "PayabliDemoFlowTests" */; + buildPhases = ( + F20000000000000000000045 /* Sources */, + F20000000000000000000044 /* Frameworks */, + F20000000000000000000043 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = PayabliDemoFlowTests; + packageProductDependencies = ( + F20000000000000000000041 /* PayabliSDKExampleAggregate */, + ); + productName = PayabliDemoFlowTests; + productReference = F20000000000000000000050 /* PayabliDemoFlowTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 475485E1E243C7CA6B159E47 /* PayabliDemo */ = { isa = PBXNativeTarget; buildConfigurationList = 87189C26EFA0A693AC05A10A /* Build configuration list for PBXNativeTarget "PayabliDemo" */; @@ -298,6 +380,7 @@ projectRoot = ""; targets = ( 475485E1E243C7CA6B159E47 /* PayabliDemo */, + F20000000000000000000060 /* PayabliDemoFlowTests */, ); }; /* End PBXProject section */ @@ -335,6 +418,19 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + F20000000000000000000045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F20000000000000000000012 /* StepStatus.swift in Sources */, + F20000000000000000000013 /* TapToPaySteps.swift in Sources */, + F20000000000000000000014 /* PayInSteps.swift in Sources */, + F20000000000000000000022 /* StepStatusTests.swift in Sources */, + F20000000000000000000024 /* TapToPayStepsTests.swift in Sources */, + F20000000000000000000026 /* PayInStepsTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 1511487013BC083FF8294541 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -351,7 +447,10 @@ 1C7A9E9B6D2F4C0F8A1B3D5E /* PaymentCaptureQAView.swift in Sources */, A1B2C3D4E5F60000000000A2 /* PaymentTapToPayQAView.swift in Sources */, A1B2C3D4E5F60000000000B2 /* TapToPayPreflight.swift in Sources */, - A1B2C3D4E5F60000000000D2 /* QAStepRow.swift in Sources */, + F20000000000000000000002 /* StepStatus.swift in Sources */, + F20000000000000000000004 /* TapToPaySteps.swift in Sources */, + F20000000000000000000006 /* PayInSteps.swift in Sources */, + A1B2C3D4E5F60000000000D2 /* StepRow.swift in Sources */, A1B2C3D4E5F60000000000E2 /* QAContextLine.swift in Sources */, 0F5E8CC7E7A24D19A71B0A1C /* PaymentMethodAddedView.swift in Sources */, C9B9D43650B2230A8582B93C /* Secrets.swift in Sources */, @@ -363,6 +462,30 @@ /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ + F20000000000000000000071 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F20000000000000000000030 /* FlowTests.xcconfig */; + buildSettings = { + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + F20000000000000000000072 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F20000000000000000000030 /* FlowTests.xcconfig */; + buildSettings = { + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; + F20000000000000000000073 /* Debug-XCFramework */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F20000000000000000000030 /* FlowTests.xcconfig */; + buildSettings = { + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = "Debug-XCFramework"; + }; 261639CBD142F4F5DABF734B /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = F1000000000000000000000C /* Release.xcconfig */; @@ -627,6 +750,16 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + F20000000000000000000070 /* Build configuration list for PBXNativeTarget "PayabliDemoFlowTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F20000000000000000000071 /* Debug */, + F20000000000000000000072 /* Release */, + F20000000000000000000073 /* Debug-XCFramework */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; 87189C26EFA0A693AC05A10A /* Build configuration list for PBXNativeTarget "PayabliDemo" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -657,6 +790,11 @@ /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + F20000000000000000000041 /* PayabliSDKExampleAggregate */ = { + isa = XCSwiftPackageProductDependency; + package = 9D53E7C2DDF2C3C9B8D4F07B /* XCLocalSwiftPackageReference "../.." */; + productName = PayabliSDKExampleAggregate; + }; 4A0D9E8F7B6C5D4E3F2A1B0C /* PayabliSDKExampleAggregate */ = { isa = XCSwiftPackageProductDependency; package = 9D53E7C2DDF2C3C9B8D4F07B /* XCLocalSwiftPackageReference "../.." */; diff --git a/Example/PayabliDemo/PayabliDemo.xcodeproj/xcshareddata/xcschemes/PayabliDemoFlowTests.xcscheme b/Example/PayabliDemo/PayabliDemo.xcodeproj/xcshareddata/xcschemes/PayabliDemoFlowTests.xcscheme new file mode 100644 index 0000000..8a42af1 --- /dev/null +++ b/Example/PayabliDemo/PayabliDemo.xcodeproj/xcshareddata/xcschemes/PayabliDemoFlowTests.xcscheme @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/Example/PayabliDemo/Shared/QAStepRow.swift b/Example/PayabliDemo/Shared/StepRow.swift similarity index 66% rename from Example/PayabliDemo/Shared/QAStepRow.swift rename to Example/PayabliDemo/Shared/StepRow.swift index 74e5883..29a103f 100644 --- a/Example/PayabliDemo/Shared/QAStepRow.swift +++ b/Example/PayabliDemo/Shared/StepRow.swift @@ -1,23 +1,7 @@ import SwiftUI -/// One step of a QA flow, with its own status and action. -/// -/// Shared by every payment tab so they read the same way. The steps mirror the -/// order the SDK enforces, so the next thing to do is the only thing offered. -enum QAStepStatus { - /// Finished, and nothing more to do here. - case done - /// The next thing to do. - case current - /// Underway inside the SDK; the app is waiting, not the person. - case inProgress - /// Cannot run until an earlier step finishes. - case blocked - /// Genuinely does not apply to this device or session. - case notNeeded - /// Attempted and failed. - case failed - +/// How a status looks. The status itself is in `Flow/StepStatus.swift`. +private extension StepStatus { var label: String { switch self { case .done: return "done" @@ -52,21 +36,14 @@ enum QAStepStatus { } } -/// One row of the sequence. -struct QAStepRow: View { +/// One row of the sequence. It renders a step; it never decides one. +struct StepRow: View { let index: Int - let title: String - let detail: String - let status: QAStepStatus + let step: FlowStep @ViewBuilder var content: Content - /// Only the step being acted on shows its controls. Anything else would put - /// the reader back in front of buttons that do not apply yet. - private var showsContent: Bool { - switch status { - case .current, .failed: return true - case .done, .inProgress, .blocked, .notNeeded: return false - } + private var status: StepStatus { + step.status } var body: some View { @@ -77,7 +54,7 @@ struct QAStepRow: View { VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline) { - Text("\(index). \(title)") + Text("\(index). \(step.title)") .font(.subheadline.weight(.semibold)) .foregroundColor(status == .blocked || status == .notNeeded ? .payabliOnSurfaceVariant @@ -87,13 +64,13 @@ struct QAStepRow: View { .font(.caption) .foregroundColor(status.tint) } - Text(detail) + Text(step.detail) .font(.caption) .foregroundColor(.payabliOnSurfaceVariant) } } - if showsContent { + if status.showsContent { content .padding(.leading, 28) } diff --git a/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift b/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift index 93ffaa1..918a424 100644 --- a/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift +++ b/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift @@ -26,18 +26,7 @@ struct PaymentTapToPayQAView: View { @State private var eventToken: PayabliTTPEventToken? @State private var isActivationPresented = false @State private var isActivationHelpPresented = false - @State private var activationOutcome = ActivationOutcome.none - - /// Which half of the activation step failed. - /// - /// Activation is two SDK calls. `sessionState` cannot tell them apart: both - /// land in `.error`, except a revoked attestation, which lands in `.idle`. - private enum ActivationOutcome { - case none - case activationFailed - case enableFailed - case succeeded - } + @State private var activationOutcome = TapToPayActivationOutcome.none @State private var isWorking = false @FocusState private var focusedField: Field? @@ -81,20 +70,22 @@ struct PaymentTapToPayQAView: View { // MARK: - The sequence - /// The order the SDK enforces, made visible. Every status is derived from - /// `sessionState` rather than tracked separately, so the screen cannot - /// disagree with the session it is describing. + /// The order the SDK enforces, made visible. Derived in one place, so no two + /// steps can disagree about which is next. + private var steps: TapToPayFlowSteps { + TapToPaySteps.forCharging( + tokenCheck: TokenCheck.classify(tokenCheckText), + session: terminal.sessionState, + activation: activationOutcome + ) + } + private var stepsSection: some View { VStack(alignment: .leading, spacing: 10) { Text("Steps") .font(.headline) - QAStepRow( - index: 1, - title: "Reach the token backend", - detail: "The SDK calls your backend for a fresh access token whenever it needs one.", - status: tokenStepStatus - ) { + StepRow(index: 1, step: steps.token) { VStack(alignment: .leading, spacing: 6) { Button { runTokenCheck() } label: { Label("Check token endpoint", systemImage: "key.horizontal") @@ -109,12 +100,7 @@ struct PaymentTapToPayQAView: View { } } - QAStepRow( - index: 2, - title: "Enable the terminal", - detail: "Attests the device, fetches the merchant config, and prepares the reader.", - status: enableStepStatus - ) { + StepRow(index: 2, step: steps.enable) { VStack(alignment: .leading, spacing: 6) { Button { runEnableTerminal() } label: { Label("Enable Terminal", systemImage: "wave.3.right") @@ -126,12 +112,7 @@ struct PaymentTapToPayQAView: View { } } - QAStepRow( - index: 3, - title: "Activate the device", - detail: activationDetail, - status: activationStepStatus - ) { + StepRow(index: 3, step: steps.activation) { VStack(alignment: .leading, spacing: 6) { Button { isActivationPresented = true } label: { Label("Enter activation code", systemImage: "checkmark.shield") @@ -143,12 +124,7 @@ struct PaymentTapToPayQAView: View { } } - QAStepRow( - index: 4, - title: "Charge a card", - detail: "Presents Apple's Tap to Pay sheet. Hold a card to the top of the phone.", - status: chargeStepStatus - ) { + StepRow(index: 4, step: steps.charge) { VStack(alignment: .leading, spacing: 8) { HStack { Text("$") @@ -193,66 +169,6 @@ struct PaymentTapToPayQAView: View { } } - // MARK: - Step status, derived from the session - - /// True once the backend is known reachable — either because the check was - /// run here, or because the SDK already fetched a token to get past `idle`. - private var backendProven: Bool { - if tokenCheckText.hasPrefix("✓") { return true } - // Only states the session cannot reach without a successful authenticated - // request. `.attestingDevice` is set before that request, and `.error` is - // where a failing token provider lands, so neither proves anything. - switch terminal.sessionState { - case .fetchingConfig, .initializingReader, .ready, .pendingActivation, .reinitializing: - return true - default: - return false - } - } - - private var tokenStepStatus: QAStepStatus { - if tokenCheckText.hasPrefix("✗") { return .failed } - return backendProven ? .done : .current - } - - private var enableStepStatus: QAStepStatus { - // Exactly one step is ever `.current`, so this stays blocked until the - // backend is proven rather than competing with step 1 for attention. - guard backendProven else { return .blocked } - switch terminal.sessionState { - case .ready: return .done - case .attestingDevice, .fetchingConfig, .initializingReader, .reinitializing: return .inProgress - case .pendingActivation: return .done - case .error, .sessionExpired: return .failed - case .idle: return .current - @unknown default: return .current - } - } - - private var activationStepStatus: QAStepStatus { - // A failed activation must stay actionable: `.failed` is the only other - // status whose content renders, so blocking it would hide the reason and - // the retry together. Read from the recorded outcome rather than from - // `sessionState`, which cannot distinguish the two calls this step makes - // and reports `.idle` for a revoked attestation. - if activationOutcome == .activationFailed { return .failed } - switch terminal.sessionState { - case .pendingActivation: return .current - case .ready: return .notNeeded - default: return .blocked - } - } - - private var activationDetail: String { - return terminal.sessionState == .pendingActivation - ? "Activate the device with the code provided by the Paypoint Device Management dashboard." - : "Only when the backend registers the device as pending." - } - - private var chargeStepStatus: QAStepStatus { - terminal.isReady ? .current : .blocked - } - private var isRecoverable: Bool { switch terminal.sessionState { case .sessionExpired, .error: return true From 676f412929fd77e851ebc0c9f9cf8d737d52c985 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Mon, 10 Aug 2026 19:48:43 -0700 Subject: [PATCH 02/57] Client(iOS) - Order each step behind the one before it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A step now reads the step in front of it rather than the state underneath. Two steps consulting the same state is how they come to disagree about which is next, and the file already stated the rule two functions above the break: "Exactly one step is ever .current". isFinished is that rule, written once. A step is finished when it is done or notNeeded; one that is working, blocked or failed does not release the next. The three combinations that broke it, all reachable: ✓ probe, session .idle, activation refused step 2 asked and step 3 failed ✗ probe, session .ready step 1 failed, step 4 offered a charge ✗ probe, .pendingActivation, refused two failures, no order between them On the card-not-present screens the same shape: lastResult is never cleared, so one successful payment proved the backend for the life of the app, including while the probe was reporting the endpoint down. The probe's own answer now outranks it once it has run. Two behaviours change with it. A working step keeps its controls, because the SDK's form owns its typed values in a @StateObject and hiding the row discards them — a declined card came back to an empty form. And a device that was activated reads "done" where one that never needed it reads "not needed"; the outcome was already recorded and never read. 40 tests green. Verified on the simulator against api-qa: the probe advances step 1 to done and hands step 2 the form, and mid-submit the form is still on screen with every value intact. Co-Authored-By: Claude Opus 5 (1M context) --- Example/PayabliDemo/Flow/PayInSteps.swift | 31 +++++------ Example/PayabliDemo/Flow/StepStatus.swift | 5 +- Example/PayabliDemo/Flow/TapToPaySteps.swift | 57 +++++++++++--------- 3 files changed, 50 insertions(+), 43 deletions(-) diff --git a/Example/PayabliDemo/Flow/PayInSteps.swift b/Example/PayabliDemo/Flow/PayInSteps.swift index 83ef9fa..329ebdd 100644 --- a/Example/PayabliDemo/Flow/PayInSteps.swift +++ b/Example/PayabliDemo/Flow/PayInSteps.swift @@ -54,22 +54,24 @@ enum PayInSteps { resultTitle: String, resultDetail: String ) -> PayInFlowSteps { - // True once the backend is known reachable — either the probe was run, or - // a submit already succeeded, which proves it just as well. - let backendProven = progress.tokenCheck == .reachable || progress.hasResult let showingFinishedResult = progress.hasResult && !progress.resultAcknowledged let backend: StepStatus = { - if progress.tokenCheck == .unreachable { - return .failed + switch progress.tokenCheck { + // Before the outcome, or the step offers its button over a request + // already in flight. + case .checking: return .inProgress + // The probe outranks a submission that succeeded earlier. The + // component never clears `lastResult`, so one payment would otherwise + // prove the backend for the life of the app. + case .unreachable: return .failed + case .reachable: return .done + case .notRun: return progress.hasResult ? .done : .current } - return backendProven ? .done : .current }() let form: StepStatus = { - // Exactly one step is ever `.current`, so this waits rather than - // competing with step 1 for attention. - guard backendProven else { return .blocked } + guard backend.isFinished else { return .blocked } if progress.isSubmitting { return .inProgress } @@ -79,14 +81,9 @@ enum PayInSteps { return showingFinishedResult ? .done : .current }() - let result: StepStatus = { - // A failure belongs to the step that produced it. Marking this one - // failed too would give the sequence two actionable failures. - if progress.submitFailed { - return .blocked - } - return showingFinishedResult ? .current : .blocked - }() + // From the step before, not from `hasResult`, which can be true while the + // form is still asking for something. + let result: StepStatus = form.isFinished ? .current : .blocked return PayInFlowSteps( backend: FlowStep( diff --git a/Example/PayabliDemo/Flow/StepStatus.swift b/Example/PayabliDemo/Flow/StepStatus.swift index 7ed97c1..0fd02c7 100644 --- a/Example/PayabliDemo/Flow/StepStatus.swift +++ b/Example/PayabliDemo/Flow/StepStatus.swift @@ -14,8 +14,11 @@ enum StepStatus { case failed /// Whether this step shows its controls. + /// + /// A working step keeps them: the SDK's form owns its typed values in a + /// `@StateObject`, so hiding the row discards them. var showsContent: Bool { - self == .current || self == .failed + self == .current || self == .failed || self == .inProgress } /// Whether this step is the one asking for something. Narrower than diff --git a/Example/PayabliDemo/Flow/TapToPaySteps.swift b/Example/PayabliDemo/Flow/TapToPaySteps.swift index 63dfe6e..58ec271 100644 --- a/Example/PayabliDemo/Flow/TapToPaySteps.swift +++ b/Example/PayabliDemo/Flow/TapToPaySteps.swift @@ -35,30 +35,31 @@ enum TapToPaySteps { session: PayabliTTPSessionState, activation outcome: TapToPayActivationOutcome ) -> TapToPayFlowSteps { - // True once the backend is known reachable — either because the probe was - // run, or because the SDK already fetched a token to get past `idle`. - // - // Only states the session cannot reach without a successful authenticated + // States the session cannot reach without a successful authenticated // request. `.attestingDevice` is set before that request, and `.error` is - // where a failing token provider lands, so neither proves anything. - let backendProven = switch session { + // where a failing token provider lands. + let sessionProvesBackend = switch session { case .fetchingConfig, .initializingReader, .ready, .pendingActivation, .reinitializing: true default: - tokenCheck == .reachable + false } let token: StepStatus = { - if tokenCheck == .unreachable { - return .failed + switch tokenCheck { + // Before the outcome, or the step offers its button over a request + // already in flight. + case .checking: return .inProgress + // The probe outranks the session, which says only that the backend + // answered at some point in the past. + case .unreachable: return .failed + case .reachable: return .done + case .notRun: return sessionProvesBackend ? .done : .current } - return backendProven ? .done : .current }() let enable: StepStatus = { - // Exactly one step is ever `.current`, so this stays blocked until the - // backend is proven rather than competing with step 1 for attention. - guard backendProven else { return .blocked } + guard token.isFinished else { return .blocked } switch session { case .ready: return .done case .attestingDevice, .fetchingConfig, .initializingReader, .reinitializing: return .inProgress @@ -71,22 +72,28 @@ enum TapToPaySteps { }() let activation: StepStatus = { - // A failed activation must stay actionable: `.failed` is the only other - // status whose content renders, so blocking it would hide the reason and - // the retry together. Read from the recorded outcome rather than from - // `sessionState`, which cannot distinguish the two calls this step makes - // and reports `.idle` for a revoked attestation. - if outcome == .activationFailed { - return .failed + // Ordered. The outcome outlives the session it belongs to, so reading + // it first lets a stale failure report itself while the sequence is + // still on the step before. + guard enable == .done else { return .blocked } + // A session reports `.ready` for a device that was activated and for + // one that never had to be. Only the caller can tell them apart. + if outcome == .succeeded { + return .done } - switch session { - case .pendingActivation: return .current - case .ready: return .notNeeded - default: return .blocked + if session == .ready { + return .notNeeded } + // `.ready` and `.pendingActivation` are the two states that finish the + // step before, so this is `.pendingActivation`. + return outcome == .activationFailed ? .failed : .current }() - let charge: StepStatus = session == .ready ? .current : .blocked + // From the step before, not the session. + let charge: StepStatus = { + guard activation.isFinished else { return .blocked } + return session == .ready ? .current : .blocked + }() return TapToPayFlowSteps( token: FlowStep( From 171940a211c700323a38fe40212a6d34d83e90cf Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Mon, 10 Aug 2026 19:48:54 -0700 Subject: [PATCH 03/57] Disable the swiftformat rules that rewrite code, then format the tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit swiftformat --lint failed on 80 of 146 files, so the gate could not be added without this. Running it unguarded broke the build, which is the reason four rules are now off: hoistAwait, hoistTry move the keyword to the start of the expression. Across an async autoclosure that changes what the code means: `await XCTAssertThrowsErrorAsync(try await charge(ttp))` lost its inner await and stopped compiling — five errors in PayabliTTPReaderSessionRecoveryTests. noForceUnwrapInTests, noForceTryInTests rewrote `Decimal(string: "25.00")!` as `try XCTUnwrap(...)`. That is a different test, a nil fails it rather than crashing it, and it needs the case to be throws. With those off the pass is a no-op, checked rather than assumed: `await` 437 before and 437 after, `try` 681 and 681. The SDK suite is green. Co-Authored-By: Claude Opus 5 (1M context) --- .swiftformat | 13 +- Bridges/ReactNative/PayabliSDKModule.swift | 4 +- .../PayabliDemo/App/PayabliDemoQAApp.swift | 14 +- .../Configuration/ConfigurationQAView.swift | 6 +- .../Configuration/DemoConfiguration.swift | 18 +- Example/PayabliDemo/Debug/DebugPrefill.swift | 278 +++++++-------- .../PayIn/PayInSharedConfiguration.swift | 1 - .../TapToPay/TapToPayPreflight.swift | 36 +- .../PayabliDemo/Theme/PayabliDemoColors.swift | 42 +-- Sources/PayabliSDKCore/Auth/PayabliAuth.swift | 7 +- .../Auth/SessionTierValidator.swift | 3 +- .../Concurrency/EventMulticaster.swift | 12 +- .../PayabliSDKCore/Models/PayabliError.swift | 35 +- .../Networking/AuthenticatedTransport.swift | 8 +- .../Networking/PayabliService.swift | 4 +- .../Networking/ResponseEnvelope.swift | 10 +- .../Networking/RetryPolicy.swift | 14 +- .../Public/PayabliEnvironment.swift | 10 +- .../Telemetry/TelemetryClient.swift | 6 +- .../Telemetry/TelemetryEvent.swift | 44 +-- ...ymentFlowFormConfiguration+Signature.swift | 2 +- .../Adapters/FiservCardReader+Errors.swift | 77 +++-- .../Adapters/FiservCardReader.swift | 326 +++++++++--------- .../AppAttestService+Activation.swift | 5 +- .../AppAttestService+Attest.swift | 6 +- .../AppAttestService+Defaults.swift | 24 +- .../AppAttestService+Requests.swift | 14 +- .../PayabliSDKTapToPay/AppAttestService.swift | 3 +- .../AppAttestWireFormat.swift | 1 + Sources/PayabliSDKTapToPay/AppAttestor.swift | 124 ++++--- .../EventMulticasterAlias.swift | 2 +- .../PayabliSDKTapToPay/KeychainStorage.swift | 6 +- .../PayabliTTP+Activation.swift | 7 +- .../PayabliTTP+Charge.swift | 26 +- .../PayabliTTP+Initialize.swift | 13 +- Sources/PayabliSDKTapToPay/PayabliTTP.swift | 207 ++++++----- .../PayabliSDKTapToPay/PayabliTTPEvent.swift | 42 +-- .../PayabliTTPTransactionData+ObjC.swift | 1 + .../PayabliTTPTransactionData.swift | 4 +- .../ReaderFailureClassification.swift | 12 +- .../PayabliSDKTapToPay/SessionManager.swift | 10 +- .../PayabliSDKTapToPay/TTPConfigClient.swift | 13 +- .../TTPConfigWireFormat.swift | 1 + .../TTPTransactionClient.swift | 9 +- .../TTPTransactionWireFormat.swift | 51 ++- .../PayabliSDKTapToPay/_ObjCBridging.swift | 8 +- .../InMemorySecureStorage.swift | 9 +- .../PayabliSDKTestUtils/MockAppAttestor.swift | 12 +- .../MockDeviceAttestationService.swift | 8 +- .../MockTapToPayProvider.swift | 16 +- .../PayabliSDKTestUtils/StubURLProtocol.swift | 13 +- .../AuthenticatedTransportTests.swift | 7 +- .../EventMulticasterTests.swift | 15 +- .../PayabliAuthTests.swift | 9 +- .../PayabliEnvironmentTests.swift | 4 +- .../PayabliErrorCodeMappingTests.swift | 3 +- .../PayabliSDKCoreTests.swift | 2 +- .../PayabliServiceTests.swift | 8 +- .../PayabliSessionTests.swift | 2 +- .../PayabliTransportTests.swift | 4 +- .../RetryPolicyTests.swift | 21 +- .../TelemetryClientTests.swift | 3 +- .../PayabliPaymentCaptureTests.swift | 1 - .../PaymentCaptureClientTests.swift | 1 - .../AppAttestServiceTests.swift | 101 ++++-- .../FiservCardReaderTests.swift | 43 ++- .../PayabliTTPErrorNSErrorTests.swift | 21 +- .../PayabliTTPEventCodeMappingTests.swift | 9 +- .../PayabliTTPObjCInteropTests.swift | 5 +- ...PayabliTTPReaderSessionRecoveryTests.swift | 90 +++-- .../PayabliTTPSessionInitTests.swift | 6 +- .../PayabliTTPTests.swift | 12 +- .../SecureStorageTests.swift | 25 +- .../SessionManagerTests.swift | 3 +- .../TTPTransactionWireFormatTests.swift | 3 +- .../PayabliSDKTelemetryTests.swift | 2 +- .../TelemetryTransportTests.swift | 42 ++- .../PayabliSDKTestUtilsTests.swift | 2 +- 78 files changed, 1149 insertions(+), 912 deletions(-) diff --git a/.swiftformat b/.swiftformat index 992dd59..ca75c24 100644 --- a/.swiftformat +++ b/.swiftformat @@ -10,5 +10,16 @@ --trimwhitespace always --semicolons never --header ignore ---disable redundantSelf,redundantReturn +# Four rules below rewrite code, not layout. Verified on swiftformat 0.62.1. +# +# hoistAwait, hoistTry move the keyword to the start of the expression. Across an +# async autoclosure that changes what the code means: +# `await XCTAssertThrowsErrorAsync(try await charge(ttp))` loses its inner await +# and stops compiling, because the autoclosure is its own async context. +# +# noForceUnwrapInTests, noForceTryInTests rewrite `Decimal(string: "25.00")!` as +# `try XCTUnwrap(...)`. That is a different test — a nil fails it instead of +# crashing it — and it needs the case to be `throws`. A formatting pass is the +# wrong place to decide either. +--disable redundantSelf,redundantReturn,hoistAwait,hoistTry,noForceUnwrapInTests,noForceTryInTests --exclude .build,.swiftpm,Example/PayabliDemo/build,ThirdParty diff --git a/Bridges/ReactNative/PayabliSDKModule.swift b/Bridges/ReactNative/PayabliSDKModule.swift index 3adf01c..d9f0b65 100644 --- a/Bridges/ReactNative/PayabliSDKModule.swift +++ b/Bridges/ReactNative/PayabliSDKModule.swift @@ -515,7 +515,9 @@ public final class PayabliSDKModule: RCTEventEmitter { private extension NSError { func rnCode(default fallback: String) -> String { - if domain == "com.payabli.ttp" { return "TTP_\(self.code)" } + if domain == "com.payabli.ttp" { + return "TTP_\(self.code)" + } return fallback } diff --git a/Example/PayabliDemo/App/PayabliDemoQAApp.swift b/Example/PayabliDemo/App/PayabliDemoQAApp.swift index a445299..89f4dee 100644 --- a/Example/PayabliDemo/App/PayabliDemoQAApp.swift +++ b/Example/PayabliDemo/App/PayabliDemoQAApp.swift @@ -12,8 +12,10 @@ struct PayabliDemoQAApp: App { accessTokenProvider: { return try await Secrets.fetchPaymentMethodAccessToken() }, - diagnostics: .qaLogging(enabled: Secrets.paymentMethodDiagnosticsEnabled, - store: .paymentMethod) + diagnostics: .qaLogging( + enabled: Secrets.paymentMethodDiagnosticsEnabled, + store: .paymentMethod + ) ) @StateObject private var paymentCapture = PayabliPayInPaymentFlow( @@ -22,8 +24,10 @@ struct PayabliDemoQAApp: App { accessTokenProvider: { return try await Secrets.fetchPaymentCaptureAccessToken() }, - diagnostics: .qaLogging(enabled: Secrets.paymentCaptureDiagnosticsEnabled, - store: .paymentCapture), + diagnostics: .qaLogging( + enabled: Secrets.paymentCaptureDiagnosticsEnabled, + store: .paymentCapture + ), operation: .capture, requestConfiguration: PaymentCaptureQAView.freshRequestConfiguration() ) @@ -67,7 +71,6 @@ struct PayabliDemoQAApp: App { .tint(.payabliPrimary) } } - } #Preview { @@ -127,4 +130,3 @@ struct PayabliDemoQAApp: App { } } } - diff --git a/Example/PayabliDemo/Configuration/ConfigurationQAView.swift b/Example/PayabliDemo/Configuration/ConfigurationQAView.swift index 566e9cc..def2545 100644 --- a/Example/PayabliDemo/Configuration/ConfigurationQAView.swift +++ b/Example/PayabliDemo/Configuration/ConfigurationQAView.swift @@ -45,14 +45,13 @@ struct ConfigurationQAView: View { QADetailRow(label: "App ID", value: Secrets.appId) QADetailRow( label: "Environment", - value: "\(DemoConfiguration.nameFor(DemoConfiguration.environment)) · " + (DemoConfiguration.environment.baseURL.host ?? "—") + value: "\(DemoConfiguration.nameFor(DemoConfiguration.environment)) · " + + (DemoConfiguration.environment.baseURL.host ?? "—") + " · " + DemoConfiguration.environmentSource ) } } - - // MARK: - Token endpoint private var tokenEndpointSection: some View { @@ -189,7 +188,6 @@ struct ConfigurationQAView: View { // MARK: - Chrome - @ViewBuilder private func section( _ title: String, note: String?, diff --git a/Example/PayabliDemo/Configuration/DemoConfiguration.swift b/Example/PayabliDemo/Configuration/DemoConfiguration.swift index 9787b3d..042630d 100644 --- a/Example/PayabliDemo/Configuration/DemoConfiguration.swift +++ b/Example/PayabliDemo/Configuration/DemoConfiguration.swift @@ -7,7 +7,6 @@ import PayabliSDKCore /// credentials, so it must not become the home of ordinary settings. Anything /// here is safe to commit and safe to show on the Configuration screen. enum DemoConfiguration { - /// Which Payabli backend every SDK facade in this app talks to. /// /// Sandbox by default: it is the environment an integrator can reach. Pass @@ -23,8 +22,12 @@ enum DemoConfiguration { static let environment: PayabliEnvironment = resolvedEnvironment() static let environmentSource: String = { - if argumentEnvironment() != nil { return "launch argument" } - if rememberedEnvironment() != nil { return "remembered from a launch argument" } + if argumentEnvironment() != nil { + return "launch argument" + } + if rememberedEnvironment() != nil { + return "remembered from a launch argument" + } return "default" }() @@ -82,7 +85,6 @@ enum DemoConfiguration { /// Running against a device needs the server bound past loopback and the /// Local Network permission; see `LocalTokenServer/README.md`. enum TokenServer { - static let defaultPort = 8787 static let accessTokenPath = "/payabli/access-token" @@ -97,7 +99,8 @@ enum DemoConfiguration { static var source: Source { if let raw = UserDefaults.standard.string(forKey: overrideKey), - !raw.trimmingCharacters(in: .whitespaces).isEmpty { + !raw.trimmingCharacters(in: .whitespaces).isEmpty + { return .override(raw.trimmingCharacters(in: .whitespaces)) } return TapToPayPreflight.runtimeEnvironment == .simulator @@ -108,7 +111,7 @@ enum DemoConfiguration { /// Base URL, e.g. `http://127.0.0.1:8787`. static var baseURL: URL { switch source { - case .override(let raw): + case let .override(raw): return normalized(raw) case .simulatorLoopback: return url(host: "127.0.0.1", port: defaultPort) @@ -149,7 +152,8 @@ enum DemoConfiguration { return parsed } if let colon = raw.lastIndex(of: ":"), - let port = Int(raw[raw.index(after: colon)...]) { + let port = Int(raw[raw.index(after: colon)...]) + { return url(host: String(raw[raw.startIndex ..< colon]), port: port) } return url(host: raw, port: defaultPort) diff --git a/Example/PayabliDemo/Debug/DebugPrefill.swift b/Example/PayabliDemo/Debug/DebugPrefill.swift index 1851263..9520f4d 100644 --- a/Example/PayabliDemo/Debug/DebugPrefill.swift +++ b/Example/PayabliDemo/Debug/DebugPrefill.swift @@ -1,161 +1,161 @@ #if DEBUG -import PayabliSDKPayInPaymentFlow -import SwiftUI -import UIKit + import PayabliSDKPayInPaymentFlow + import SwiftUI + import UIKit -/// Debug-only convenience that fills the PayIn form from `DebugPrefill.json`. -/// -/// The SDK owns the form's field state and offers no way to seed it, so each -/// field is found by the `accessibilityIdentifier` the SDK assigns -/// (`payabli.payInPaymentFlow.field.`) and fed through the SDK's -/// own input path, as if typed. -/// -/// This file is Debug-only, and `Config/Release.xcconfig` keeps the JSON out of -/// Release builds. -/// -/// The expiration field is a wheel picker rather than a text field, so it cannot -/// be prefilled and has to be chosen by hand. -enum DebugPrefill { - /// Runtime-read prefill values. Only the fields that map to editable text - /// inputs are applied; `cardExpiration` is decoded for documentation but is - /// entered manually (picker field). - struct Values: Decodable { - var cardholderName: String? - var cardNumber: String? - var cardCvv: String? - var cardZip: String? - var cardExpiration: String? - var firstName: String? - var lastName: String? - var billingEmail: String? - var customerNumber: String? - var achHolder: String? - var achRouting: String? - var achAccount: String? - } - - /// Decoded once from `DebugPrefill.json` in the app bundle. - static let values: Values? = { - guard let url = Bundle.main.url(forResource: "DebugPrefill", withExtension: "json") else { - print("[DebugPrefill] DebugPrefill.json not found in bundle") - return nil - } - do { - let data = try Data(contentsOf: url) - return try JSONDecoder().decode(Values.self, from: data) - } catch { - print("[DebugPrefill] Failed to decode DebugPrefill.json: \(error)") - return nil + /// Debug-only convenience that fills the PayIn form from `DebugPrefill.json`. + /// + /// The SDK owns the form's field state and offers no way to seed it, so each + /// field is found by the `accessibilityIdentifier` the SDK assigns + /// (`payabli.payInPaymentFlow.field.`) and fed through the SDK's + /// own input path, as if typed. + /// + /// This file is Debug-only, and `Config/Release.xcconfig` keeps the JSON out of + /// Release builds. + /// + /// The expiration field is a wheel picker rather than a text field, so it cannot + /// be prefilled and has to be chosen by hand. + enum DebugPrefill { + /// Runtime-read prefill values. Only the fields that map to editable text + /// inputs are applied; `cardExpiration` is decoded for documentation but is + /// entered manually (picker field). + struct Values: Decodable { + var cardholderName: String? + var cardNumber: String? + var cardCvv: String? + var cardZip: String? + var cardExpiration: String? + var firstName: String? + var lastName: String? + var billingEmail: String? + var customerNumber: String? + var achHolder: String? + var achRouting: String? + var achAccount: String? } - }() - /// Fills every visible text field whose identifier matches a value in the - /// JSON. Safe to call repeatedly; missing fields are skipped. - @MainActor - static func fill() { - guard let values else { return } + /// Decoded once from `DebugPrefill.json` in the app bundle. + static let values: Values? = { + guard let url = Bundle.main.url(forResource: "DebugPrefill", withExtension: "json") else { + print("[DebugPrefill] DebugPrefill.json not found in bundle") + return nil + } + do { + let data = try Data(contentsOf: url) + return try JSONDecoder().decode(Values.self, from: data) + } catch { + print("[DebugPrefill] Failed to decode DebugPrefill.json: \(error)") + return nil + } + }() + + /// Fills every visible text field whose identifier matches a value in the + /// JSON. Safe to call repeatedly; missing fields are skipped. + @MainActor + static func fill() { + guard let values else { return } - let mapping: [(PayabliPayInPaymentFlowField, String?)] = [ - (.cardholderName, values.cardholderName), - (.cardNumber, values.cardNumber), - (.cardCvv, values.cardCvv), - (.cardZip, values.cardZip), - (.firstName, values.firstName), - (.lastName, values.lastName), - (.billingEmail, values.billingEmail), - (.customerNumber, values.customerNumber), - (.achHolder, values.achHolder), - (.achRouting, values.achRouting), - (.achAccount, values.achAccount) - ] + let mapping: [(PayabliPayInPaymentFlowField, String?)] = [ + (.cardholderName, values.cardholderName), + (.cardNumber, values.cardNumber), + (.cardCvv, values.cardCvv), + (.cardZip, values.cardZip), + (.firstName, values.firstName), + (.lastName, values.lastName), + (.billingEmail, values.billingEmail), + (.customerNumber, values.customerNumber), + (.achHolder, values.achHolder), + (.achRouting, values.achRouting), + (.achAccount, values.achAccount) + ] - let textFields = onScreenTextFields() - for (field, value) in mapping { - guard let value, !value.isEmpty else { continue } - let identifier = "payabli.payInPaymentFlow.field.\(field.rawValue)" - guard let textField = textFields.first(where: { $0.accessibilityIdentifier == identifier }) else { - continue + let textFields = onScreenTextFields() + for (field, value) in mapping { + guard let value, !value.isEmpty else { continue } + let identifier = "payabli.payInPaymentFlow.field.\(field.rawValue)" + guard let textField = textFields.first(where: { $0.accessibilityIdentifier == identifier }) else { + continue + } + inject(value, into: textField) } - inject(value, into: textField) } - } - /// Feeds `value` into `textField` through the SDK's own input path. - /// - /// A protected field ignores direct `.text` assignment, so the value goes in - /// through the delegate, as a paste over the whole field. - /// - /// The delegate's answer has to be honoured: a protected field applies the - /// change itself and says `false`, an unprotected one says `true` and leaves - /// the applying to the caller. - private static func inject(_ value: String, into textField: UITextField) { - let current = (textField.text ?? "") as NSString - let fullRange = NSRange(location: 0, length: current.length) - let shouldApply = textField.delegate?.textField?( - textField, - shouldChangeCharactersIn: fullRange, - replacementString: value - ) ?? true + /// Feeds `value` into `textField` through the SDK's own input path. + /// + /// A protected field ignores direct `.text` assignment, so the value goes in + /// through the delegate, as a paste over the whole field. + /// + /// The delegate's answer has to be honoured: a protected field applies the + /// change itself and says `false`, an unprotected one says `true` and leaves + /// the applying to the caller. + private static func inject(_ value: String, into textField: UITextField) { + let current = (textField.text ?? "") as NSString + let fullRange = NSRange(location: 0, length: current.length) + let shouldApply = textField.delegate?.textField?( + textField, + shouldChangeCharactersIn: fullRange, + replacementString: value + ) ?? true - guard shouldApply else { return } - textField.text = current.replacingCharacters(in: fullRange, with: value) - textField.sendActions(for: .editingChanged) - } + guard shouldApply else { return } + textField.text = current.replacingCharacters(in: fullRange, with: value) + textField.sendActions(for: .editingChanged) + } - /// Text fields of the frontmost presentation only. - /// - /// A presented sheet does not unmount the form behind it, and both carry the - /// same accessibility identifiers, so searching every window and taking the - /// first match could prefill the covered form instead of the sheet. Which one - /// won depended on view order, which made the sheet prefill nondeterministic. - private static func onScreenTextFields() -> [UITextField] { - let windows = UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .flatMap(\.windows) + /// Text fields of the frontmost presentation only. + /// + /// A presented sheet does not unmount the form behind it, and both carry the + /// same accessibility identifiers, so searching every window and taking the + /// first match could prefill the covered form instead of the sheet. Which one + /// won depended on view order, which made the sheet prefill nondeterministic. + private static func onScreenTextFields() -> [UITextField] { + let windows = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) - let root = windows.first(where: \.isKeyWindow) ?? windows.last - var result: [UITextField] = [] - if let host = topmostViewController(from: root?.rootViewController) { - collectTextFields(in: host.view, into: &result) - } - // A form outside any view controller still has to be reachable. - if result.isEmpty, let root { - collectTextFields(in: root, into: &result) + let root = windows.first(where: \.isKeyWindow) ?? windows.last + var result: [UITextField] = [] + if let host = topmostViewController(from: root?.rootViewController) { + collectTextFields(in: host.view, into: &result) + } + // A form outside any view controller still has to be reachable. + if result.isEmpty, let root { + collectTextFields(in: root, into: &result) + } + return result } - return result - } - private static func topmostViewController( - from controller: UIViewController? - ) -> UIViewController? { - guard let controller else { return nil } - if let presented = controller.presentedViewController { - return topmostViewController(from: presented) + private static func topmostViewController( + from controller: UIViewController? + ) -> UIViewController? { + guard let controller else { return nil } + if let presented = controller.presentedViewController { + return topmostViewController(from: presented) + } + return controller } - return controller - } - private static func collectTextFields(in view: UIView, into result: inout [UITextField]) { - if let textField = view as? UITextField { - result.append(textField) - } - for subview in view.subviews { - collectTextFields(in: subview, into: &result) + private static func collectTextFields(in view: UIView, into result: inout [UITextField]) { + if let textField = view as? UITextField { + result.append(textField) + } + for subview in view.subviews { + collectTextFields(in: subview, into: &result) + } } } -} -/// Debug-only button that triggers ``DebugPrefill/fill()``. -struct DebugPrefillButton: View { - var body: some View { - Button { - DebugPrefill.fill() - } label: { - Label("Prefill test data (Debug)", systemImage: "wand.and.stars") - .frame(maxWidth: .infinity) + /// Debug-only button that triggers ``DebugPrefill/fill()``. + struct DebugPrefillButton: View { + var body: some View { + Button { + DebugPrefill.fill() + } label: { + Label("Prefill test data (Debug)", systemImage: "wand.and.stars") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(.payabliWarning) } - .buttonStyle(.bordered) - .tint(.payabliWarning) } -} #endif diff --git a/Example/PayabliDemo/PayIn/PayInSharedConfiguration.swift b/Example/PayabliDemo/PayIn/PayInSharedConfiguration.swift index ef94b39..8fd0d81 100644 --- a/Example/PayabliDemo/PayIn/PayInSharedConfiguration.swift +++ b/Example/PayabliDemo/PayIn/PayInSharedConfiguration.swift @@ -11,7 +11,6 @@ import SwiftUI /// The Configuration screen reads these same values, so what it displays cannot /// drift from what the forms actually use. enum PayInSharedConfiguration { - // MARK: - Methods static let allowedMethods: [PayabliPayInPaymentFlowMethodType] = [.card, .ach] diff --git a/Example/PayabliDemo/TapToPay/TapToPayPreflight.swift b/Example/PayabliDemo/TapToPay/TapToPayPreflight.swift index 67aac54..9475b7c 100644 --- a/Example/PayabliDemo/TapToPay/TapToPayPreflight.swift +++ b/Example/PayabliDemo/TapToPay/TapToPayPreflight.swift @@ -1,7 +1,7 @@ import DeviceCheck import Foundation #if canImport(ProximityReader) -import ProximityReader + import ProximityReader #endif /// Credential-independent pre-flight checks for the Tap to Pay tab. @@ -14,7 +14,6 @@ import ProximityReader /// /// Nothing in here reaches the network and nothing reads a secret. enum TapToPayPreflight { - // MARK: - Result model struct Check: Identifiable { @@ -54,15 +53,15 @@ enum TapToPayPreflight { /// (`arm64` / `x86_64`); a device reports a model such as `iPhone17,1`. static var runtimeEnvironment: RuntimeEnvironment { #if targetEnvironment(simulator) - return .simulator - #else - if ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] != nil { - return .simulator - } - if ["arm64", "x86_64", "i386"].contains(machineIdentifier) { return .simulator - } - return .physicalDevice + #else + if ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] != nil { + return .simulator + } + if ["arm64", "x86_64", "i386"].contains(machineIdentifier) { + return .simulator + } + return .physicalDevice #endif } @@ -93,12 +92,12 @@ enum TapToPayPreflight { /// against `runtimeEnvironment`. static var tapToPayHardwareSupported: Bool? { #if canImport(ProximityReader) - if #available(iOS 16.7, *) { - return PaymentCardReader.isSupported - } - return false + if #available(iOS 16.7, *) { + return PaymentCardReader.isSupported + } + return false #else - return nil + return nil #endif } @@ -157,7 +156,8 @@ enum TapToPayPreflight { return team } if let applicationIdentifier = entitlements["application-identifier"] as? String, - let prefix = applicationIdentifier.split(separator: ".").first { + let prefix = applicationIdentifier.split(separator: ".").first + { return String(prefix) } return nil @@ -204,8 +204,8 @@ enum TapToPayPreflight { title: "Host: \(environment.label)", detail: environment == .simulator ? "uname reports \(machineIdentifier). Tap to Pay needs a physical iPhone XS " - + "or later on iOS 16.7+; a Simulator cannot attest or read a card even " - + "with a valid team, token, and entitlement." + + "or later on iOS 16.7+; a Simulator cannot attest or read a card even " + + "with a valid team, token, and entitlement." : "uname reports \(machineIdentifier).", status: environment == .simulator ? .fail : .pass ) diff --git a/Example/PayabliDemo/Theme/PayabliDemoColors.swift b/Example/PayabliDemo/Theme/PayabliDemoColors.swift index 9510454..72384b2 100644 --- a/Example/PayabliDemo/Theme/PayabliDemoColors.swift +++ b/Example/PayabliDemo/Theme/PayabliDemoColors.swift @@ -7,34 +7,34 @@ import UIKit /// its source. The Android sample app carries the same values under the same names; the two demos /// are meant to screenshot as one product. enum PayabliPalette { - static let black: UInt32 = 0x00_00_00 - static let white: UInt32 = 0xFF_FF_FF - static let deepBlue: UInt32 = 0x02_0B_27 + static let black: UInt32 = 0x000000 + static let white: UInt32 = 0xFFFFFF + static let deepBlue: UInt32 = 0x020B27 - static let blue1: UInt32 = 0x04_C3_FF - static let blue3: UInt32 = 0xDD_F7_FF - static let blue4: UInt32 = 0x00_1C_6E + static let blue1: UInt32 = 0x04C3FF + static let blue3: UInt32 = 0xDDF7FF + static let blue4: UInt32 = 0x001C6E - static let teal2: UInt32 = 0xA7_FC_FF - static let teal4: UInt32 = 0x00_55_58 + static let teal2: UInt32 = 0xA7FCFF + static let teal4: UInt32 = 0x005558 - static let cinnamon2: UInt32 = 0xFF_AE_B7 - static let cinnamon4: UInt32 = 0x68_0A_04 + static let cinnamon2: UInt32 = 0xFFAEB7 + static let cinnamon4: UInt32 = 0x680A04 - static let lemon1: UInt32 = 0xFF_C8_5C - static let lemon4: UInt32 = 0x63_42_00 + static let lemon1: UInt32 = 0xFFC85C + static let lemon4: UInt32 = 0x634200 - static let neutral1: UInt32 = 0x13_1D_3A - static let neutral3: UInt32 = 0x3C_47_6B - static let neutral4: UInt32 = 0x57_61_80 - static let neutral5: UInt32 = 0x89_92_AC - static let neutral6: UInt32 = 0xC5_CB_DB - static let neutral7: UInt32 = 0xEF_F0_F7 - static let neutral8: UInt32 = 0xF9_F9_FF + static let neutral1: UInt32 = 0x131D3A + static let neutral3: UInt32 = 0x3C476B + static let neutral4: UInt32 = 0x576180 + static let neutral5: UInt32 = 0x8992AC + static let neutral6: UInt32 = 0xC5CBDB + static let neutral7: UInt32 = 0xEFF0F7 + static let neutral8: UInt32 = 0xF9F9FF /// Two steps the guide does not name, blended between the tones on either side of them. - static let lightContainerHigh: UInt32 = 0xDD_E0_EB - static let darkContainerHigh: UInt32 = 0x1A_26_4A + static let lightContainerHigh: UInt32 = 0xDDE0EB + static let darkContainerHigh: UInt32 = 0x1A264A } /// The roles the demo draws with. diff --git a/Sources/PayabliSDKCore/Auth/PayabliAuth.swift b/Sources/PayabliSDKCore/Auth/PayabliAuth.swift index 4d1d2fb..893bead 100644 --- a/Sources/PayabliSDKCore/Auth/PayabliAuth.swift +++ b/Sources/PayabliSDKCore/Auth/PayabliAuth.swift @@ -9,8 +9,8 @@ public actor PayabliAuth { /// other callers await the same Task result. private var inFlightRefresh: Task? - // Multicasts every successful token rotation. Producers append on every - // refresh; consumers iterate as long as they want. + /// Multicasts every successful token rotation. Producers append on every + /// refresh; consumers iterate as long as they want. private var tokenChangeContinuations: [UUID: AsyncStream.Continuation] = [:] public init(config: PayabliConfig) { @@ -45,8 +45,7 @@ public actor PayabliAuth { let task = Task { [logger] in logger.info("Refreshing access token via partner tokenProvider") - let fresh = try await provider() - return fresh + return try await provider() } inFlightRefresh = task diff --git a/Sources/PayabliSDKCore/Auth/SessionTierValidator.swift b/Sources/PayabliSDKCore/Auth/SessionTierValidator.swift index 4a4479d..64ec07e 100644 --- a/Sources/PayabliSDKCore/Auth/SessionTierValidator.swift +++ b/Sources/PayabliSDKCore/Auth/SessionTierValidator.swift @@ -12,8 +12,7 @@ import Foundation /// /// Components pass in their static requirements; a mismatch throws /// `PayabliGenericError(.permissionDenied)`. -internal enum SessionTierValidator { - +enum SessionTierValidator { static func validate( component: any PayabliComponent.Type, against config: PayabliConfig diff --git a/Sources/PayabliSDKCore/Concurrency/EventMulticaster.swift b/Sources/PayabliSDKCore/Concurrency/EventMulticaster.swift index 4188dce..51f4ac7 100644 --- a/Sources/PayabliSDKCore/Concurrency/EventMulticaster.swift +++ b/Sources/PayabliSDKCore/Concurrency/EventMulticaster.swift @@ -3,7 +3,6 @@ import Foundation /// Multicast emitter for any `Sendable` event type. Every concurrent caller /// of `stream()` receives all subsequent events. public final class EventMulticaster: @unchecked Sendable { - private final class Subscription: @unchecked Sendable { let id = UUID() let continuation: AsyncStream.Continuation @@ -41,7 +40,9 @@ public final class EventMulticaster: @unchecked Sendable { lock.lock() let snapshot = subscribers lock.unlock() - for sub in snapshot { sub.continuation.yield(event) } + for sub in snapshot { + sub.continuation.yield(event) + } } /// Terminates every active stream. Typical use is during SDK teardown so @@ -52,11 +53,14 @@ public final class EventMulticaster: @unchecked Sendable { let snapshot = subscribers subscribers.removeAll() lock.unlock() - for sub in snapshot { sub.continuation.finish() } + for sub in snapshot { + sub.continuation.finish() + } } private func remove(_ id: UUID) { - lock.lock(); defer { lock.unlock() } + lock.lock() + defer { lock.unlock() } subscribers.removeAll { $0.id == id } } } diff --git a/Sources/PayabliSDKCore/Models/PayabliError.swift b/Sources/PayabliSDKCore/Models/PayabliError.swift index 55e6b4f..92a6222 100644 --- a/Sources/PayabliSDKCore/Models/PayabliError.swift +++ b/Sources/PayabliSDKCore/Models/PayabliError.swift @@ -74,8 +74,13 @@ public struct PayabliValidationError: PayabliError, Decodable { public let errors: [String: [PayabliFieldError]]? public let token: String? - public var code: PayabliErrorCode { .validation } - public var reason: String { title ?? "Validation failed" } + public var code: PayabliErrorCode { + .validation + } + + public var reason: String { + title ?? "Validation failed" + } enum CodingKeys: String, CodingKey { case type, title, status, detail, instance, errors, token @@ -90,8 +95,13 @@ public struct PayabliServerError: PayabliError, Decodable { public let detail: String? public let instance: String? - public var code: PayabliErrorCode { .unknown } - public var reason: String { title ?? "Internal server error" } + public var code: PayabliErrorCode { + .unknown + } + + public var reason: String { + title ?? "Internal server error" + } } /// HTTP 402 declined payment. See PRD §8.1.1 "Declined Response". @@ -101,8 +111,13 @@ public struct PayabliDeclineError: PayabliError, Decodable { public let explanation: String? public let action: String? - public var code: PayabliErrorCode { .unknown } - public var detail: String? { explanation } + public var code: PayabliErrorCode { + .unknown + } + + public var detail: String? { + explanation + } enum CodingKeys: String, CodingKey { case reason, explanation, action @@ -120,10 +135,10 @@ public enum PayabliPaymentError: Error, Sendable { public var asPayabliError: any PayabliError { switch self { - case .decline(let err): return err - case .validation(let err): return err - case .server(let err): return err - case .generic(let err): return err + case let .decline(err): return err + case let .validation(err): return err + case let .server(err): return err + case let .generic(err): return err } } } diff --git a/Sources/PayabliSDKCore/Networking/AuthenticatedTransport.swift b/Sources/PayabliSDKCore/Networking/AuthenticatedTransport.swift index b0b6f50..16ca17b 100644 --- a/Sources/PayabliSDKCore/Networking/AuthenticatedTransport.swift +++ b/Sources/PayabliSDKCore/Networking/AuthenticatedTransport.swift @@ -7,16 +7,16 @@ import Foundation /// /// Endpoint clients that need bearer auth depend on this transport rather /// than open-coding the header / retry dance themselves. -internal struct AuthenticatedTransport: PayabliTransport { +struct AuthenticatedTransport: PayabliTransport { private let base: any PayabliTransport private let auth: PayabliAuth - internal init(base: any PayabliTransport, auth: PayabliAuth) { + init(base: any PayabliTransport, auth: PayabliAuth) { self.base = base self.auth = auth } - public func perform(_ request: PayabliRequest) async throws -> PayabliResponse { + func perform(_ request: PayabliRequest) async throws -> PayabliResponse { let token = await auth.currentAccessToken() let firstAttempt = try await base.perform(authorize(request, with: token)) @@ -33,7 +33,7 @@ internal struct AuthenticatedTransport: PayabliTransport { return secondAttempt } - public func performV2( + func performV2( _ request: PayabliRequest, decoding: T.Type ) async throws -> PayabliV2Envelope { diff --git a/Sources/PayabliSDKCore/Networking/PayabliService.swift b/Sources/PayabliSDKCore/Networking/PayabliService.swift index 8e8b747..246474b 100644 --- a/Sources/PayabliSDKCore/Networking/PayabliService.swift +++ b/Sources/PayabliSDKCore/Networking/PayabliService.swift @@ -28,7 +28,7 @@ public final class PayabliService: PayabliTransport, Sendable { self.logger = PayabliLogger(category: .network) } - internal static func makeDefaultSession() -> URLSession { + static func makeDefaultSession() -> URLSession { let config = URLSessionConfiguration.ephemeral config.timeoutIntervalForRequest = defaultRequestTimeout config.timeoutIntervalForResource = defaultRequestTimeout * 3 @@ -150,7 +150,7 @@ public func mapPayabliHTTPError( response: PayabliResponse, override: ((Int) -> (any Error)?)? = nil ) throws { - guard !(200..<300).contains(response.statusCode) else { return } + guard !(200 ..< 300).contains(response.statusCode) else { return } // Component-specific override takes priority. if let customError = override?(response.statusCode) { diff --git a/Sources/PayabliSDKCore/Networking/ResponseEnvelope.swift b/Sources/PayabliSDKCore/Networking/ResponseEnvelope.swift index 5d7f04a..f79b687 100644 --- a/Sources/PayabliSDKCore/Networking/ResponseEnvelope.swift +++ b/Sources/PayabliSDKCore/Networking/ResponseEnvelope.swift @@ -1,6 +1,7 @@ import Foundation // MARK: - Legacy "isSuccess / responseData" envelope + // // Used by the `/api/v2/device/...` family (attestation, activation) and by // `/api/v2/device/taptopay/config/{entry}`. Business-level failures come back @@ -25,7 +26,6 @@ import Foundation /// } /// ``` public enum PayabliEnvelope { - /// Thin "peek" of the response body: only `isSuccess` and the top-level /// `responseText`. Used to decide between the success and decline decodes /// without committing to the full payload shape. @@ -109,8 +109,12 @@ public struct PayabliV2Envelope: Decodable { public let data: Data? /// `true` if `code` starts with `"A"` (Approved family). - public var isApproved: Bool { code.hasPrefix("A") } + public var isApproved: Bool { + code.hasPrefix("A") + } /// `true` if `code` starts with `"D"` (Declined family). - public var isDeclined: Bool { code.hasPrefix("D") } + public var isDeclined: Bool { + code.hasPrefix("D") + } } diff --git a/Sources/PayabliSDKCore/Networking/RetryPolicy.swift b/Sources/PayabliSDKCore/Networking/RetryPolicy.swift index a4e804a..e839084 100644 --- a/Sources/PayabliSDKCore/Networking/RetryPolicy.swift +++ b/Sources/PayabliSDKCore/Networking/RetryPolicy.swift @@ -48,7 +48,7 @@ public struct RetryPolicy: Sendable { guard attempt > 1 else { return 0 } let exponent = Double(attempt - 2) let backoff = min(baseDelay * pow(multiplier, exponent), maxDelay) - let jitter = Double.random(in: 0...maxJitter) + let jitter = Double.random(in: 0 ... maxJitter) return backoff + jitter } @@ -58,7 +58,7 @@ public struct RetryPolicy: Sendable { // Non-retryable: 400, 401, 403, 404. switch statusCode { case 408: return true // request timeout - case 500...599: return true + case 500 ... 599: return true default: return false } } @@ -71,7 +71,9 @@ public struct RetryPolicy: Sendable { /// `policy.maxAttempts`. Other errors propagate immediately. public struct RetryableError: Error { public let underlying: Error - public init(_ underlying: Error) { self.underlying = underlying } + public init(_ underlying: Error) { + self.underlying = underlying + } } public enum Retry { @@ -80,7 +82,7 @@ public enum Retry { _ operation: @Sendable (_ attempt: Int) async throws -> T ) async throws -> T { var lastUnderlying: Error? - for attempt in 1...policy.maxAttempts { + for attempt in 1 ... policy.maxAttempts { let delay = policy.delay(forAttempt: attempt) if delay > 0 { try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) @@ -89,7 +91,9 @@ public enum Retry { return try await operation(attempt) } catch let retryable as RetryableError { lastUnderlying = retryable.underlying - if attempt == policy.maxAttempts { throw retryable.underlying } + if attempt == policy.maxAttempts { + throw retryable.underlying + } continue } catch { throw error diff --git a/Sources/PayabliSDKCore/Public/PayabliEnvironment.swift b/Sources/PayabliSDKCore/Public/PayabliEnvironment.swift index 7c65710..08f1aae 100644 --- a/Sources/PayabliSDKCore/Public/PayabliEnvironment.swift +++ b/Sources/PayabliSDKCore/Public/PayabliEnvironment.swift @@ -6,9 +6,9 @@ import Foundation /// See PRD §8.2 for base URLs. @objc public enum PayabliEnvironment: Int, Sendable { #if DEBUG - /// Developer-only environment pointing at a local tunnel. Available only - /// in DEBUG builds — never shipped in a release binary. - case local = 0 + /// Developer-only environment pointing at a local tunnel. Available only + /// in DEBUG builds — never shipped in a release binary. + case local = 0 #endif case qa = 1 case sandbox = 2 @@ -19,8 +19,8 @@ import Foundation // swiftlint:disable force_unwrapping switch self { #if DEBUG - case .local: - return URL(string: "https://wallets-test.ngrok.app")! + case .local: + return URL(string: "https://wallets-test.ngrok.app")! #endif case .qa: return URL(string: "https://api-qa.payabli.com")! diff --git a/Sources/PayabliSDKCore/Telemetry/TelemetryClient.swift b/Sources/PayabliSDKCore/Telemetry/TelemetryClient.swift index 53a738f..9bccb89 100644 --- a/Sources/PayabliSDKCore/Telemetry/TelemetryClient.swift +++ b/Sources/PayabliSDKCore/Telemetry/TelemetryClient.swift @@ -1,5 +1,5 @@ -import Foundation import CryptoKit +import Foundation /// Transport abstraction — production uses `URLSession` to POST to the Payabli /// telemetry endpoint; tests inject an in-memory sink. @@ -89,5 +89,7 @@ public actor TelemetryClient { await transport.send(batch) } - public var bufferedCount: Int { buffer.count } + public var bufferedCount: Int { + buffer.count + } } diff --git a/Sources/PayabliSDKCore/Telemetry/TelemetryEvent.swift b/Sources/PayabliSDKCore/Telemetry/TelemetryEvent.swift index 3082702..3ac1461 100644 --- a/Sources/PayabliSDKCore/Telemetry/TelemetryEvent.swift +++ b/Sources/PayabliSDKCore/Telemetry/TelemetryEvent.swift @@ -41,34 +41,34 @@ public struct TelemetryEvent: Encodable, Sendable { /// Catalog of event names emitted by the SDK (PRD §24.3). public enum TelemetryEventName { // Tokenization - public static let tokenizationStarted = "tokenization.started" + public static let tokenizationStarted = "tokenization.started" public static let tokenizationSucceeded = "tokenization.succeeded" - public static let tokenizationFailed = "tokenization.failed" + public static let tokenizationFailed = "tokenization.failed" public static let tokenizationCancelled = "tokenization.cancelled" - public static let formPresented = "form.presented" - public static let formValidationError = "form.validationError" + public static let formPresented = "form.presented" + public static let formValidationError = "form.validationError" // TTP lifecycle - public static let ttpInitializeStarted = "ttp.initialize.started" - public static let ttpInitializeSucceeded = "ttp.initialize.succeeded" - public static let ttpInitializeFailed = "ttp.initialize.failed" - public static let ttpAttestationStarted = "ttp.attestation.started" - public static let ttpAttestationSucceeded = "ttp.attestation.succeeded" - public static let ttpAttestationFailed = "ttp.attestation.failed" - public static let ttpChargeStarted = "ttp.charge.started" - public static let ttpChargeSucceeded = "ttp.charge.succeeded" - public static let ttpChargeFailed = "ttp.charge.failed" - public static let ttpNfcStarted = "ttp.nfc.started" - public static let ttpNfcSucceeded = "ttp.nfc.succeeded" - public static let ttpNfcFailed = "ttp.nfc.failed" - public static let ttpReinitializeStarted = "ttp.reinitialize.started" + public static let ttpInitializeStarted = "ttp.initialize.started" + public static let ttpInitializeSucceeded = "ttp.initialize.succeeded" + public static let ttpInitializeFailed = "ttp.initialize.failed" + public static let ttpAttestationStarted = "ttp.attestation.started" + public static let ttpAttestationSucceeded = "ttp.attestation.succeeded" + public static let ttpAttestationFailed = "ttp.attestation.failed" + public static let ttpChargeStarted = "ttp.charge.started" + public static let ttpChargeSucceeded = "ttp.charge.succeeded" + public static let ttpChargeFailed = "ttp.charge.failed" + public static let ttpNfcStarted = "ttp.nfc.started" + public static let ttpNfcSucceeded = "ttp.nfc.succeeded" + public static let ttpNfcFailed = "ttp.nfc.failed" + public static let ttpReinitializeStarted = "ttp.reinitialize.started" public static let ttpReinitializeSucceeded = "ttp.reinitialize.succeeded" - public static let ttpStateChanged = "ttp.session.stateChanged" + public static let ttpStateChanged = "ttp.session.stateChanged" // System - public static let sdkInitialized = "sdk.initialized" - public static let telemetryDisabled = "sdk.telemetryDisabled" - public static let authTokenAcquired = "auth.tokenAcquired" - public static let authTokenFailed = "auth.tokenFailed" + public static let sdkInitialized = "sdk.initialized" + public static let telemetryDisabled = "sdk.telemetryDisabled" + public static let authTokenAcquired = "auth.tokenAcquired" + public static let authTokenFailed = "auth.tokenFailed" public static let authTokenRefreshed = "auth.tokenRefreshed" } diff --git a/Sources/PayabliSDKPayInPaymentFlow/PayabliPayInPaymentFlowFormConfiguration+Signature.swift b/Sources/PayabliSDKPayInPaymentFlow/PayabliPayInPaymentFlowFormConfiguration+Signature.swift index d84c3b4..9770e0a 100644 --- a/Sources/PayabliSDKPayInPaymentFlow/PayabliPayInPaymentFlowFormConfiguration+Signature.swift +++ b/Sources/PayabliSDKPayInPaymentFlow/PayabliPayInPaymentFlowFormConfiguration+Signature.swift @@ -69,7 +69,7 @@ private extension PayabliPayInPaymentFlowPaymentSummaryConfiguration { } } -private extension Dictionary where Key == PayabliPayInPaymentFlowField, Value == String { +private extension [PayabliPayInPaymentFlowField: String] { var payabliViewModelSignature: String { map { "\($0.key.rawValue)=\($0.value)" } .sorted() diff --git a/Sources/PayabliSDKTapToPay/Adapters/FiservCardReader+Errors.swift b/Sources/PayabliSDKTapToPay/Adapters/FiservCardReader+Errors.swift index 64b04da..a89b436 100644 --- a/Sources/PayabliSDKTapToPay/Adapters/FiservCardReader+Errors.swift +++ b/Sources/PayabliSDKTapToPay/Adapters/FiservCardReader+Errors.swift @@ -1,57 +1,62 @@ import Foundation import PayabliSDKCore #if canImport(PayabliCardReaderCore) -import PayabliCardReaderCore -import ProximityReader + import PayabliCardReaderCore + import ProximityReader #endif // MARK: - Card reader error mapping extension FiservCardReader { - /// Prefix inside `.nfcFailed(reason:)` that marks a user-cancel, so hosts /// can distinguish it from a hard failure by substring. static let cancellationReasonPrefix = "cancelled:" #if canImport(PayabliCardReaderCore) - /// Translates a card-reader / ProximityReader error into `PayabliTTPError`. - /// `fallback` picks the case (setup vs. NFC) for non-cancel errors. - static func mapError( - _ error: Error, - fallback: (String) -> PayabliTTPError - ) -> PayabliTTPError { - if let pte = error as? PayabliTTPError { return pte } - - if (error as NSError).code == NSUserCancelledError { - return .nfcFailed( - reason: "\(cancellationReasonPrefix) user dismissed Tap to Pay sheet" - ) - } + /// Translates a card-reader / ProximityReader error into `PayabliTTPError`. + /// `fallback` picks the case (setup vs. NFC) for non-cancel errors. + static func mapError( + _ error: Error, + fallback: (String) -> PayabliTTPError + ) -> PayabliTTPError { + if let pte = error as? PayabliTTPError { + return pte + } + + if (error as NSError).code == NSUserCancelledError { + return .nfcFailed( + reason: "\(cancellationReasonPrefix) user dismissed Tap to Pay sheet" + ) + } - if #available(iOS 16.4, *), error is PaymentCardReaderError { - let desc = error.localizedDescription - if desc.lowercased().contains("version") { - return .readerSetupFailed(reason: "OS version not supported: \(desc)") + if #available(iOS 16.4, *), error is PaymentCardReaderError { + let desc = error.localizedDescription + if desc.lowercased().contains("version") { + return .readerSetupFailed(reason: "OS version not supported: \(desc)") + } } + + let detail = extractReaderDetail(error) + return fallback(detail.isEmpty ? error.localizedDescription : detail) } - let detail = extractReaderDetail(error) - return fallback(detail.isEmpty ? error.localizedDescription : detail) - } - - /// `FiservTTPCardReaderError.localizedDescription` is a stored property - /// that shadows (doesn't override) `Error.localizedDescription`, so the - /// usual accessor returns a generic NSError message. Read the real - /// `title` + `localizedDescription` via reflection. - static func extractReaderDetail(_ error: Error) -> String { - let mirror = Mirror(reflecting: error) - let title = mirror.children.first(where: { $0.label == "title" })?.value as? String - let desc = mirror.children.first(where: { $0.label == "localizedDescription" })?.value as? String - if let title, let desc { return "\(title): \(desc)" } - if let desc { return desc } - return "" - } + /// `FiservTTPCardReaderError.localizedDescription` is a stored property + /// that shadows (doesn't override) `Error.localizedDescription`, so the + /// usual accessor returns a generic NSError message. Read the real + /// `title` + `localizedDescription` via reflection. + static func extractReaderDetail(_ error: Error) -> String { + let mirror = Mirror(reflecting: error) + let title = mirror.children.first(where: { $0.label == "title" })?.value as? String + let desc = mirror.children.first(where: { $0.label == "localizedDescription" })?.value as? String + if let title, let desc { + return "\(title): \(desc)" + } + if let desc { + return desc + } + return "" + } #endif } diff --git a/Sources/PayabliSDKTapToPay/Adapters/FiservCardReader.swift b/Sources/PayabliSDKTapToPay/Adapters/FiservCardReader.swift index dd8f700..1f735bd 100644 --- a/Sources/PayabliSDKTapToPay/Adapters/FiservCardReader.swift +++ b/Sources/PayabliSDKTapToPay/Adapters/FiservCardReader.swift @@ -1,8 +1,8 @@ import Foundation import PayabliSDKCore #if canImport(PayabliCardReaderCore) -import PayabliCardReaderCore -import ProximityReader + import PayabliCardReaderCore + import ProximityReader #endif /// Card-reader adapter for `TapToPayProvider` (PRD FR-11B), backed by the @@ -18,11 +18,12 @@ import ProximityReader /// /// Error mapping: see `FiservCardReader+Errors.swift`. public final class FiservCardReader: TapToPayProvider, @unchecked Sendable { - - public static var providerId: String { "fiserv" } + public static var providerId: String { + "fiserv" + } /// `/config` `credentials` block — maps 1:1 to `FiservTTPConfig`. - internal struct Credentials: Sendable { + struct Credentials: Sendable { let secretKey: String let apiKey: String let environment: String @@ -64,13 +65,13 @@ public final class FiservCardReader: TapToPayProvider, @unchecked Sendable { private let logger = PayabliLogger(category: .taptopay) #if canImport(PayabliCardReaderCore) - private var reader: FiservTTPCardReader? + private var reader: FiservTTPCardReader? #endif public init() {} /// Injects `Credentials` directly. Facade path uses `configure(credentials:)`. - internal func setCredentials(_ credentials: Credentials) { + func setCredentials(_ credentials: Credentials) { lock.lock() self.credentials = credentials lock.unlock() @@ -127,112 +128,112 @@ public final class FiservCardReader: TapToPayProvider, @unchecked Sendable { /// is fetched, so must not require credentials. public func checkEligibility() async -> Result { #if canImport(PayabliCardReaderCore) - if #available(iOS 16.7, *) { - guard PaymentCardReader.isSupported else { - return .failure(.readerSetupFailed( - reason: "Tap to Pay hardware not supported on this device" - )) + if #available(iOS 16.7, *) { + guard PaymentCardReader.isSupported else { + return .failure(.readerSetupFailed( + reason: "Tap to Pay hardware not supported on this device" + )) + } + return .success(()) } - return .success(()) - } - return .failure(.readerSetupFailed(reason: "Tap to Pay requires iOS 16.7+")) + return .failure(.readerSetupFailed(reason: "Tap to Pay requires iOS 16.7+")) #else - return .failure(.readerSetupFailed(reason: "Tap to Pay is iOS-only")) + return .failure(.readerSetupFailed(reason: "Tap to Pay is iOS-only")) #endif } public func prepareReader() async throws { #if canImport(PayabliCardReaderCore) - let creds = try requireCredentials() - let newReader = try buildReader(credentials: creds) - - // Drop our copy; credentials now live inside `newReader` (NFR-5D). - lock.lock() - credentials = nil - lock.unlock() - - logger.info("[fiserv.prepare] → requesting session") - do { - try await newReader.requestSessionToken() - - let linked = try await newReader.isAccountLinked() - if !linked { - try await newReader.linkAccount() + let creds = try requireCredentials() + let newReader = try buildReader(credentials: creds) + + // Drop our copy; credentials now live inside `newReader` (NFR-5D). + lock.lock() + credentials = nil + lock.unlock() + + logger.info("[fiserv.prepare] → requesting session") + do { + try await newReader.requestSessionToken() + + let linked = try await newReader.isAccountLinked() + if !linked { + try await newReader.linkAccount() + } + + try await newReader.initializeSession() + logger.info("[fiserv.prepare] ← reader ready (linked=\(linked))") + } catch { + clearAllState() + throw Self.mapError(error) { .readerSetupFailed(reason: $0) } } - - try await newReader.initializeSession() - logger.info("[fiserv.prepare] ← reader ready (linked=\(linked))") - } catch { - clearAllState() - throw Self.mapError(error) { .readerSetupFailed(reason: $0) } - } #else - throw PayabliTTPError.readerSetupFailed(reason: "Tap to Pay is iOS-only") + throw PayabliTTPError.readerSetupFailed(reason: "Tap to Pay is iOS-only") #endif } public func startReading(_ request: CardReadRequest) async throws -> CardReadResult { #if canImport(PayabliCardReaderCore) - lock.lock() - let activeReader = reader - lock.unlock() - guard let reader = activeReader else { - throw PayabliTTPError.readerSetupFailed(reason: "Reader not prepared") - } + lock.lock() + let activeReader = reader + lock.unlock() + guard let reader = activeReader else { + throw PayabliTTPError.readerSetupFailed(reason: "Reader not prepared") + } - let amount = request.amount.rounded(2, .bankers) - let details = Models.TransactionDetailsRequest( - merchantTransactionId: request.merchantTransactionId, - merchantOrderId: request.merchantOrderId ?? request.merchantTransactionId, - merchantInvoiceNumber: request.merchantInvoiceNumber, - captureFlag: true, - createToken: false - ) + let amount = request.amount.rounded(2, .bankers) + let details = Models.TransactionDetailsRequest( + merchantTransactionId: request.merchantTransactionId, + merchantOrderId: request.merchantOrderId ?? request.merchantTransactionId, + merchantInvoiceNumber: request.merchantInvoiceNumber, + captureFlag: true, + createToken: false + ) - logger.info( - "[fiserv.charges] → amount=\(amount) " + - "currency=\(credentials?.currencyCode ?? "?") " + - "merchantTxId=\(request.merchantTransactionId) " + - "merchantOrderId=\(request.merchantOrderId ?? "") " + - "invoice=\(request.merchantInvoiceNumber ?? "")" - ) - logger.info( - "[fiserv.charges] customer={firstName=\(request.customer.firstName ?? "") " + - "lastName=\(request.customer.lastName ?? "") " + - "customerNumber=\(request.customer.customerNumber ?? "")} " + - "invoice={invoiceNumber=\(request.invoice.invoiceNumber ?? "")}" - ) - // The atomic card-reader API has no slot for customer data; it - // ships only at /initiate and in the logs above. - - let started = Date() - let response: Models.CommerceHubResponse - do { - response = try await reader.charges( - amount: amount, - transactionType: .sale, - transactionDetailsRequest: details + logger.info( + "[fiserv.charges] → amount=\(amount) " + + "currency=\(credentials?.currencyCode ?? "?") " + + "merchantTxId=\(request.merchantTransactionId) " + + "merchantOrderId=\(request.merchantOrderId ?? "") " + + "invoice=\(request.merchantInvoiceNumber ?? "")" ) - } catch { - throw Self.mapError(error) { .nfcFailed(reason: $0) } - } + logger.info( + "[fiserv.charges] customer={firstName=\(request.customer.firstName ?? "") " + + "lastName=\(request.customer.lastName ?? "") " + + "customerNumber=\(request.customer.customerNumber ?? "")} " + + "invoice={invoiceNumber=\(request.invoice.invoiceNumber ?? "")}" + ) + // The atomic card-reader API has no slot for customer data; it + // ships only at /initiate and in the logs above. + + let started = Date() + let response: Models.CommerceHubResponse + do { + response = try await reader.charges( + amount: amount, + transactionType: .sale, + transactionDetailsRequest: details + ) + } catch { + throw Self.mapError(error) { .nfcFailed(reason: $0) } + } - let elapsedMs = Int(Date().timeIntervalSince(started) * 1000) - let responseJSON = try Self.encode(response) - let cardNetwork = Self.extractCardNetwork(from: responseJSON) - logger.info("[fiserv.charges] ← OK (\(elapsedMs)ms) bytes=\(responseJSON.count) cardNetwork=\(cardNetwork ?? "")") - if let pretty = Self.prettyPrintJSON(responseJSON) { - logger.info("[fiserv.charges] responseBody:\n\(pretty)") - } - return CardReadResult( - provider: Self.providerId, - encryptedPayload: Data(), - cardNetwork: cardNetwork, - providerMetadata: [:], - providerResponseJSON: responseJSON - ) + let elapsedMs = Int(Date().timeIntervalSince(started) * 1000) + let responseJSON = try Self.encode(response) + let cardNetwork = Self.extractCardNetwork(from: responseJSON) + logger.info("[fiserv.charges] ← OK (\(elapsedMs)ms) bytes=\(responseJSON.count) cardNetwork=\(cardNetwork ?? "")") + if let pretty = Self.prettyPrintJSON(responseJSON) { + logger.info("[fiserv.charges] responseBody:\n\(pretty)") + } + return CardReadResult( + provider: Self.providerId, + encryptedPayload: Data(), + cardNetwork: cardNetwork, + providerMetadata: [:], + providerResponseJSON: responseJSON + ) #else - throw PayabliTTPError.nfcFailed(reason: "Tap to Pay is iOS-only") + throw PayabliTTPError.nfcFailed(reason: "Tap to Pay is iOS-only") #endif } @@ -254,90 +255,93 @@ public final class FiservCardReader: TapToPayProvider, @unchecked Sendable { private func clearAllState() { lock.lock() #if canImport(PayabliCardReaderCore) - reader?.finalize() - reader = nil + reader?.finalize() + reader = nil #endif credentials = nil lock.unlock() } #if canImport(PayabliCardReaderCore) - private func requireCredentials() throws -> Credentials { - lock.lock(); defer { lock.unlock() } - guard let creds = credentials else { - throw PayabliTTPError.readerSetupFailed(reason: "Missing provider credentials") + private func requireCredentials() throws -> Credentials { + lock.lock() + defer { lock.unlock() } + guard let creds = credentials else { + throw PayabliTTPError.readerSetupFailed(reason: "Missing provider credentials") + } + return creds } - return creds - } - /// Builds a fresh `FiservTTPCardReader` (vendored from PayabliCardReaderCore). - /// Tears down the previous instance first — Apple's `PaymentCardReader` - /// allows only one per process. - private func buildReader(credentials creds: Credentials) throws -> FiservTTPCardReader { - lock.lock() - reader?.finalize() - reader = nil - lock.unlock() - - let config = FiservTTPConfig( - secretKey: creds.secretKey, - apiKey: creds.apiKey, - environment: creds.environment.lowercased() == "production" ? .Production : .Sandbox, - currencyCode: creds.currencyCode, - merchantId: creds.merchantId, - appleTtpMerchantId: creds.appleTtpMerchantId, - merchantName: creds.merchantName, - merchantCategoryCode: creds.merchantCategoryCode, - terminalId: creds.terminalId, - terminalProfileId: creds.terminalProfileId - ) + /// Builds a fresh `FiservTTPCardReader` (vendored from PayabliCardReaderCore). + /// Tears down the previous instance first — Apple's `PaymentCardReader` + /// allows only one per process. + private func buildReader(credentials creds: Credentials) throws -> FiservTTPCardReader { + lock.lock() + reader?.finalize() + reader = nil + lock.unlock() + + let config = FiservTTPConfig( + secretKey: creds.secretKey, + apiKey: creds.apiKey, + environment: creds.environment.lowercased() == "production" ? .Production : .Sandbox, + currencyCode: creds.currencyCode, + merchantId: creds.merchantId, + appleTtpMerchantId: creds.appleTtpMerchantId, + merchantName: creds.merchantName, + merchantCategoryCode: creds.merchantCategoryCode, + terminalId: creds.terminalId, + terminalProfileId: creds.terminalProfileId + ) - let newReader = FiservTTPCardReader(configuration: config) + let newReader = FiservTTPCardReader(configuration: config) - lock.lock() - reader = newReader - lock.unlock() + lock.lock() + reader = newReader + lock.unlock() - return newReader - } + return newReader + } - private static func encode(_ value: T) throws -> Data { - do { - return try JSONEncoder().encode(value) - } catch { - throw PayabliTTPError.nfcFailed( - reason: "Failed to encode provider response: \(error.localizedDescription)" - ) + private static func encode(_ value: some Encodable) throws -> Data { + do { + return try JSONEncoder().encode(value) + } catch { + throw PayabliTTPError.nfcFailed( + reason: "Failed to encode provider response: \(error.localizedDescription)" + ) + } } - } - /// Re-encodes `json` with `.prettyPrinted` + `.sortedKeys` for log output. - /// Returns `nil` if `json` isn't valid JSON. - private static func prettyPrintJSON(_ json: Data) -> String? { - guard - let obj = try? JSONSerialization.jsonObject(with: json), - let pretty = try? JSONSerialization.data( - withJSONObject: obj, - options: [.prettyPrinted, .sortedKeys] - ) - else { return nil } - return String(data: pretty, encoding: .utf8) - } + /// Re-encodes `json` with `.prettyPrinted` + `.sortedKeys` for log output. + /// Returns `nil` if `json` isn't valid JSON. + private static func prettyPrintJSON(_ json: Data) -> String? { + guard + let obj = try? JSONSerialization.jsonObject(with: json), + let pretty = try? JSONSerialization.data( + withJSONObject: obj, + options: [.prettyPrinted, .sortedKeys] + ) + else { return nil } + return String(data: pretty, encoding: .utf8) + } - /// Pulls `card.brand` out of the CommerceHub response for - /// `CardReadResult.cardNetwork`. Tolerates minor schema drift. - private static func extractCardNetwork(from json: Data) -> String? { - guard - let obj = try? JSONSerialization.jsonObject(with: json) as? [String: Any] - else { return nil } - if let pm = (obj["source"] as? [String: Any]) ?? (obj["paymentSources"] as? [String: Any]) { - if let brand = pm["brand"] as? String { return brand } - if let card = pm["card"] as? [String: Any], let brand = card["brand"] as? String { - return brand + /// Pulls `card.brand` out of the CommerceHub response for + /// `CardReadResult.cardNetwork`. Tolerates minor schema drift. + private static func extractCardNetwork(from json: Data) -> String? { + guard + let obj = try? JSONSerialization.jsonObject(with: json) as? [String: Any] + else { return nil } + if let pm = (obj["source"] as? [String: Any]) ?? (obj["paymentSources"] as? [String: Any]) { + if let brand = pm["brand"] as? String { + return brand + } + if let card = pm["card"] as? [String: Any], let brand = card["brand"] as? String { + return brand + } } + return nil } - return nil - } #endif } diff --git a/Sources/PayabliSDKTapToPay/AppAttestService+Activation.swift b/Sources/PayabliSDKTapToPay/AppAttestService+Activation.swift index 5912f18..c55fab1 100644 --- a/Sources/PayabliSDKTapToPay/AppAttestService+Activation.swift +++ b/Sources/PayabliSDKTapToPay/AppAttestService+Activation.swift @@ -3,9 +3,8 @@ import PayabliSDKCore // MARK: - Device activation (PRD §9.7) -extension AppAttestService { - - public func activateDevice(activationCode: String, entry: String) async throws { +public extension AppAttestService { + func activateDevice(activationCode: String, entry: String) async throws { guard let deviceId = storage.string(forKey: PayabliKeychainKey.deviceId) else { throw PayabliTTPError.attestationFailed(reason: "Missing deviceId — run initialize() before activateDevice") } diff --git a/Sources/PayabliSDKTapToPay/AppAttestService+Attest.swift b/Sources/PayabliSDKTapToPay/AppAttestService+Attest.swift index 6807f45..b0302e6 100644 --- a/Sources/PayabliSDKTapToPay/AppAttestService+Attest.swift +++ b/Sources/PayabliSDKTapToPay/AppAttestService+Attest.swift @@ -1,11 +1,10 @@ -import Foundation import CryptoKit +import Foundation import PayabliSDKCore // MARK: - Attestation flow (PRD §18.1) & per-request assertions (PRD §18.2) extension AppAttestService { - /// Apple's DeviceCheck / App Attest error domain (`DCError`). Bridged as a /// string so this file needs no `import DeviceCheck` and stays testable on /// hosts where `DCAppAttestService` is unavailable. @@ -104,7 +103,8 @@ extension AppAttestService { public func generateAssertion() async throws -> AssertionHeaders { guard let storedKeyId = storage.string(forKey: PayabliKeychainKey.keyId), - let deviceId = storage.string(forKey: PayabliKeychainKey.deviceId) else { + let deviceId = storage.string(forKey: PayabliKeychainKey.deviceId) + else { throw PayabliTTPError.attestationFailed(reason: "Missing attestation state") } let keyId = AppAttestKeyId(storedKeyId) diff --git a/Sources/PayabliSDKTapToPay/AppAttestService+Defaults.swift b/Sources/PayabliSDKTapToPay/AppAttestService+Defaults.swift index 69de7c4..fd5c3cc 100644 --- a/Sources/PayabliSDKTapToPay/AppAttestService+Defaults.swift +++ b/Sources/PayabliSDKTapToPay/AppAttestService+Defaults.swift @@ -1,36 +1,36 @@ import Foundation #if canImport(UIKit) -import UIKit + import UIKit #endif // MARK: - Default hardware identifier providers + // // Injectable `@Sendable` closures; macOS test builds fall back to stand-ins. extension AppAttestService { - - internal static var defaultHardwareId: @Sendable () -> String { + static var defaultHardwareId: @Sendable () -> String { { #if canImport(UIKit) - return UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString + return UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString #else - return UUID().uuidString + return UUID().uuidString #endif } } - internal static var defaultDeviceName: @Sendable () -> String { + static var defaultDeviceName: @Sendable () -> String { { #if canImport(UIKit) - return UIDevice.current.name + return UIDevice.current.name #else - return "macOS" + return "macOS" #endif } } - internal static var defaultModel: @Sendable () -> String { + static var defaultModel: @Sendable () -> String { { var sysinfo = utsname() uname(&sysinfo) @@ -41,12 +41,12 @@ extension AppAttestService { } } - internal static var defaultOSVersion: @Sendable () -> String { + static var defaultOSVersion: @Sendable () -> String { { #if canImport(UIKit) - return UIDevice.current.systemVersion + return UIDevice.current.systemVersion #else - return ProcessInfo.processInfo.operatingSystemVersionString + return ProcessInfo.processInfo.operatingSystemVersionString #endif } } diff --git a/Sources/PayabliSDKTapToPay/AppAttestService+Requests.swift b/Sources/PayabliSDKTapToPay/AppAttestService+Requests.swift index ab24979..179577d 100644 --- a/Sources/PayabliSDKTapToPay/AppAttestService+Requests.swift +++ b/Sources/PayabliSDKTapToPay/AppAttestService+Requests.swift @@ -2,13 +2,13 @@ import Foundation import PayabliSDKCore // MARK: - Attestation networking helpers + // // Concrete-endpoint wrappers (`postChallenge`, `postRegister`, `postAttest`) // plus the shared machinery used by every authenticated POST in the // attestation/activation family. extension AppAttestService { - func postChallenge(entry: String) async throws -> ChallengeResponse { try await postAttestationRequest( path: "/api/v2/device/taptopay/challenge", @@ -35,9 +35,9 @@ extension AppAttestService { /// Authenticated POST that expects a non-empty `responseData` of type /// `Payload`. Throws if the envelope is missing it. - func postAttestationRequest( + func postAttestationRequest( path: String, - body: Body, + body: some Encodable, label: String, assertion: AssertionHeaders? = nil, makeDeclineError: @escaping (_ code: Int?, _ reason: String) -> PayabliTTPError = { _, reason in @@ -71,9 +71,9 @@ extension AppAttestService { /// Authenticated POST for endpoints that do not return a `responseData` /// body (only an `isSuccess` acknowledgement). - func postAttestationRequestExpectingNoBody( + func postAttestationRequestExpectingNoBody( path: String, - body: Body, + body: some Encodable, label: String, assertion: AssertionHeaders? = nil, makeDeclineError: @escaping (_ code: Int?, _ reason: String) -> PayabliTTPError = { _, reason in @@ -99,9 +99,9 @@ extension AppAttestService { /// `transport` decorator (`AuthenticatedTransport`); this method only /// appends the App Attest assertion headers that are specific to the /// attestation/activation endpoint family. - private func performAuthenticatedPOST( + private func performAuthenticatedPOST( path: String, - body: Body, + body: some Encodable, label: String, assertion: AssertionHeaders?, makeDeclineError: (_ code: Int?, _ reason: String) -> PayabliTTPError diff --git a/Sources/PayabliSDKTapToPay/AppAttestService.swift b/Sources/PayabliSDKTapToPay/AppAttestService.swift index d1ae433..2d2d6ab 100644 --- a/Sources/PayabliSDKTapToPay/AppAttestService.swift +++ b/Sources/PayabliSDKTapToPay/AppAttestService.swift @@ -29,7 +29,6 @@ import PayabliSDKCore /// - `AppAttestService+Defaults.swift` — hardware-identifier providers /// - `AppAttestWireFormat.swift` — request/response DTOs public final class AppAttestService: DeviceAttestationService, @unchecked Sendable { - let transport: any PayabliTransport let attestor: AppAttestor let storage: SecureStorage @@ -57,7 +56,7 @@ public final class AppAttestService: DeviceAttestationService, @unchecked Sendab ) } - internal init( + init( transport: any PayabliTransport, attestor: AppAttestor, storage: SecureStorage, diff --git a/Sources/PayabliSDKTapToPay/AppAttestWireFormat.swift b/Sources/PayabliSDKTapToPay/AppAttestWireFormat.swift index c4c62d5..2e4ac59 100644 --- a/Sources/PayabliSDKTapToPay/AppAttestWireFormat.swift +++ b/Sources/PayabliSDKTapToPay/AppAttestWireFormat.swift @@ -1,6 +1,7 @@ import Foundation // MARK: - Backend wire types (PRD §8.2) + // // Endpoint-specific DTOs for the attestation family. Generic envelope // scaffolding lives in `PayabliSDKCore.PayabliEnvelope`. diff --git a/Sources/PayabliSDKTapToPay/AppAttestor.swift b/Sources/PayabliSDKTapToPay/AppAttestor.swift index 74ca094..c7bbed9 100644 --- a/Sources/PayabliSDKTapToPay/AppAttestor.swift +++ b/Sources/PayabliSDKTapToPay/AppAttestor.swift @@ -1,7 +1,7 @@ import Foundation #if canImport(DeviceCheck) -import DeviceCheck + import DeviceCheck #endif // MARK: - Domain types @@ -24,20 +24,26 @@ public struct AppAttestKeyId: Hashable, Sendable, Codable, CustomStringConvertib try container.encode(rawValue) } - public var description: String { rawValue } + public var description: String { + rawValue + } } /// SHA-256 hash fed into `attestKey` / `generateAssertion` — 32 bytes. public struct ClientDataHash: Hashable, Sendable { public let rawValue: Data - public init(_ rawValue: Data) { self.rawValue = rawValue } + public init(_ rawValue: Data) { + self.rawValue = rawValue + } } /// CBOR attestation blob returned by `attestKey`. Wire-format: base64 string. public struct AttestationObject: Hashable, Sendable, Codable { public let rawValue: Data - public init(_ rawValue: Data) { self.rawValue = rawValue } + public init(_ rawValue: Data) { + self.rawValue = rawValue + } public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() @@ -56,15 +62,22 @@ public struct AttestationObject: Hashable, Sendable, Codable { try container.encode(rawValue.base64EncodedString()) } - public var base64: String { rawValue.base64EncodedString() } + public var base64: String { + rawValue.base64EncodedString() + } } /// CBOR assertion blob returned by `generateAssertion`. Emitted as base64 /// in the `X-App-Assertion` header. public struct AppAttestAssertion: Hashable, Sendable { public let rawValue: Data - public init(_ rawValue: Data) { self.rawValue = rawValue } - public var base64: String { rawValue.base64EncodedString() } + public init(_ rawValue: Data) { + self.rawValue = rawValue + } + + public var base64: String { + rawValue.base64EncodedString() + } } // MARK: - Protocol @@ -84,61 +97,70 @@ public protocol AppAttestor: Sendable { // MARK: - Production impl #if canImport(DeviceCheck) -/// Production `AppAttestor` backed by `DCAppAttestService`. -/// -/// `DCAppAttestService` itself is iOS 14+ / macOS 11.3+, which is well below -/// this package's declared minimums (iOS 16.7 / macOS 12), so no extra -/// `@available` gate is required. -public final class RealAppAttestor: AppAttestor, @unchecked Sendable { - public init() {} - - public var isSupported: Bool { - DCAppAttestService.shared.isSupported - } + /// Production `AppAttestor` backed by `DCAppAttestService`. + /// + /// `DCAppAttestService` itself is iOS 14+ / macOS 11.3+, which is well below + /// this package's declared minimums (iOS 16.7 / macOS 12), so no extra + /// `@available` gate is required. + public final class RealAppAttestor: AppAttestor, @unchecked Sendable { + public init() {} + + public var isSupported: Bool { + DCAppAttestService.shared.isSupported + } - public func generateKey() async throws -> AppAttestKeyId { - try await withCheckedThrowingContinuation { continuation in - DCAppAttestService.shared.generateKey { keyId, error in - if let error { continuation.resume(throwing: error); return } - guard let keyId else { - continuation.resume(throwing: PayabliTTPError.attestationFailed( - reason: "generateKey returned nil" - )) - return + public func generateKey() async throws -> AppAttestKeyId { + try await withCheckedThrowingContinuation { continuation in + DCAppAttestService.shared.generateKey { keyId, error in + if let error { + continuation.resume(throwing: error) + return + } + guard let keyId else { + continuation.resume(throwing: PayabliTTPError.attestationFailed( + reason: "generateKey returned nil" + )) + return + } + continuation.resume(returning: AppAttestKeyId(keyId)) } - continuation.resume(returning: AppAttestKeyId(keyId)) } } - } - public func attestKey(_ keyId: AppAttestKeyId, clientDataHash: ClientDataHash) async throws -> AttestationObject { - try await withCheckedThrowingContinuation { continuation in - DCAppAttestService.shared.attestKey(keyId.rawValue, clientDataHash: clientDataHash.rawValue) { attestation, error in - if let error { continuation.resume(throwing: error); return } - guard let attestation else { - continuation.resume(throwing: PayabliTTPError.attestationFailed( - reason: "attestKey returned nil" - )) - return + public func attestKey(_ keyId: AppAttestKeyId, clientDataHash: ClientDataHash) async throws -> AttestationObject { + try await withCheckedThrowingContinuation { continuation in + DCAppAttestService.shared.attestKey(keyId.rawValue, clientDataHash: clientDataHash.rawValue) { attestation, error in + if let error { + continuation.resume(throwing: error) + return + } + guard let attestation else { + continuation.resume(throwing: PayabliTTPError.attestationFailed( + reason: "attestKey returned nil" + )) + return + } + continuation.resume(returning: AttestationObject(attestation)) } - continuation.resume(returning: AttestationObject(attestation)) } } - } - public func generateAssertion(_ keyId: AppAttestKeyId, clientDataHash: ClientDataHash) async throws -> AppAttestAssertion { - try await withCheckedThrowingContinuation { continuation in - DCAppAttestService.shared.generateAssertion(keyId.rawValue, clientDataHash: clientDataHash.rawValue) { assertion, error in - if let error { continuation.resume(throwing: error); return } - guard let assertion else { - continuation.resume(throwing: PayabliTTPError.attestationFailed( - reason: "generateAssertion returned nil" - )) - return + public func generateAssertion(_ keyId: AppAttestKeyId, clientDataHash: ClientDataHash) async throws -> AppAttestAssertion { + try await withCheckedThrowingContinuation { continuation in + DCAppAttestService.shared.generateAssertion(keyId.rawValue, clientDataHash: clientDataHash.rawValue) { assertion, error in + if let error { + continuation.resume(throwing: error) + return + } + guard let assertion else { + continuation.resume(throwing: PayabliTTPError.attestationFailed( + reason: "generateAssertion returned nil" + )) + return + } + continuation.resume(returning: AppAttestAssertion(assertion)) } - continuation.resume(returning: AppAttestAssertion(assertion)) } } } -} #endif diff --git a/Sources/PayabliSDKTapToPay/EventMulticasterAlias.swift b/Sources/PayabliSDKTapToPay/EventMulticasterAlias.swift index 3d0be69..dd879c4 100644 --- a/Sources/PayabliSDKTapToPay/EventMulticasterAlias.swift +++ b/Sources/PayabliSDKTapToPay/EventMulticasterAlias.swift @@ -1,4 +1,4 @@ import PayabliSDKCore /// TapToPay convenience alias for `EventMulticaster`. -internal typealias TTPEventMulticaster = EventMulticaster +typealias TTPEventMulticaster = EventMulticaster diff --git a/Sources/PayabliSDKTapToPay/KeychainStorage.swift b/Sources/PayabliSDKTapToPay/KeychainStorage.swift index fc94642..de9a38a 100644 --- a/Sources/PayabliSDKTapToPay/KeychainStorage.swift +++ b/Sources/PayabliSDKTapToPay/KeychainStorage.swift @@ -35,7 +35,7 @@ public struct KeychainStorage: SecureStorage, Sendable { return String(data: data, encoding: .utf8) } - internal func data(forKey key: String) -> Data? { + func data(forKey key: String) -> Data? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, @@ -58,7 +58,7 @@ public struct KeychainStorage: SecureStorage, Sendable { try set(data, forKey: key) } - internal func set(_ data: Data, forKey key: String) throws { + func set(_ data: Data, forKey key: String) throws { let baseQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, @@ -93,7 +93,7 @@ public struct KeychainStorage: SecureStorage, Sendable { _ = SecItemDelete(query as CFDictionary) } - internal func removeAll() { + func removeAll() { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service diff --git a/Sources/PayabliSDKTapToPay/PayabliTTP+Activation.swift b/Sources/PayabliSDKTapToPay/PayabliTTP+Activation.swift index 1162845..a492847 100644 --- a/Sources/PayabliSDKTapToPay/PayabliTTP+Activation.swift +++ b/Sources/PayabliSDKTapToPay/PayabliTTP+Activation.swift @@ -4,8 +4,7 @@ import PayabliSDKCore // MARK: - Device activation (PRD §9.7) @MainActor -extension PayabliTTP { - +public extension PayabliTTP { /// Activate a pending device using an activation code supplied by the /// partner out-of-band (e.g. an admin dashboard). /// @@ -14,7 +13,7 @@ extension PayabliTTP { /// `.attestationRevoked` the session is reset to `.idle` (not `.error`) /// so the caller can immediately re-run `initialize()` for a fresh cold /// attestation — `.sessionExpired` is also emitted in that sub-case. - public func activateDevice(activationCode: String) async throws { + func activateDevice(activationCode: String) async throws { guard sessionState == .pendingActivation else { throw PayabliTTPError.invalidState( current: sessionState, @@ -59,7 +58,7 @@ extension PayabliTTP { /// /// The completion handler is always invoked on the main thread because /// the entire `PayabliTTP` surface is `@MainActor`. - @objc public func activateDevice( + @objc func activateDevice( activationCode: String, completion: @escaping (NSError?) -> Void ) { diff --git a/Sources/PayabliSDKTapToPay/PayabliTTP+Charge.swift b/Sources/PayabliSDKTapToPay/PayabliTTP+Charge.swift index 3e1cd8e..0ddbbc3 100644 --- a/Sources/PayabliSDKTapToPay/PayabliTTP+Charge.swift +++ b/Sources/PayabliSDKTapToPay/PayabliTTP+Charge.swift @@ -12,7 +12,6 @@ private enum TTPUpdateOutcome { @MainActor extension PayabliTTP { - /// Charge a transaction. v1.0 supports `.sale` only (FR-11D.1). /// /// Threads the `paymentDetails` / `customer` / `invoice` / `orderDescription` @@ -94,7 +93,8 @@ extension PayabliTTP { // would kill a healthy session over a dead one's failure. if generation == readerSessionGeneration, readerFailureInvalidatesSession(error), - sessionManager.transition(to: .sessionExpired) { + sessionManager.transition(to: .sessionExpired) + { syncPublished() multicaster.emit(.sessionExpired) } @@ -116,7 +116,7 @@ extension PayabliTTP { case .succeeded: multicaster.emit(.updateCompleted(paymentTransId: paymentTransId)) return TransactionResult(paymentTransId: paymentTransId) - case .failed(let reason): + case let .failed(reason): throw PayabliTTPError.updateFailed(reason: reason) } } @@ -226,7 +226,9 @@ extension PayabliTTP { try await Retry.run(policy: retryPolicy) { [retryPolicy] attempt in let response = try await performOnce(attempt: String(attempt)) - if (200..<300).contains(response.statusCode) { return } + if (200 ..< 300).contains(response.statusCode) { + return + } if retryPolicy.isRetryable(statusCode: response.statusCode) { throw RetryableError(PayabliTTPError.updateFailed( reason: "HTTP \(response.statusCode)" @@ -254,14 +256,14 @@ extension PayabliTTP { ) { logger.info( "[charge] → amount=\(paymentDetails.amount) serviceFee=\(paymentDetails.serviceFee) " + - "currency=\(paymentDetails.currency ?? "") " + - "customer={firstName=\(customer.firstName ?? "") " + - "lastName=\(customer.lastName ?? "") " + - "customerNumber=\(customer.customerNumber ?? "") " + - "customerId=\(customer.customerId.map(String.init) ?? "") " + - "company=\(customer.company ?? "")} " + - "invoice={invoiceNumber=\(invoice.invoiceNumber ?? "")} " + - "orderDescription=\(orderDescription ?? "")" + "currency=\(paymentDetails.currency ?? "") " + + "customer={firstName=\(customer.firstName ?? "") " + + "lastName=\(customer.lastName ?? "") " + + "customerNumber=\(customer.customerNumber ?? "") " + + "customerId=\(customer.customerId.map(String.init) ?? "") " + + "company=\(customer.company ?? "")} " + + "invoice={invoiceNumber=\(invoice.invoiceNumber ?? "")} " + + "orderDescription=\(orderDescription ?? "")" ) guard !customer.isEmpty else { return } diff --git a/Sources/PayabliSDKTapToPay/PayabliTTP+Initialize.swift b/Sources/PayabliSDKTapToPay/PayabliTTP+Initialize.swift index 91fa027..523b829 100644 --- a/Sources/PayabliSDKTapToPay/PayabliTTP+Initialize.swift +++ b/Sources/PayabliSDKTapToPay/PayabliTTP+Initialize.swift @@ -5,7 +5,6 @@ import PayabliSDKCore @MainActor extension PayabliTTP { - // MARK: - Public entrypoints /// One-call startup: @@ -45,11 +44,17 @@ extension PayabliTTP { let task = Task { @MainActor in // Its failure belongs to its own caller. - if let previous { _ = try? await previous.value } + if let previous { + _ = try? await previous.value + } try await work() } inFlightSessionSetup = (kind, task, id) - defer { if inFlightSessionSetup?.id == id { inFlightSessionSetup = nil } } + defer { + if inFlightSessionSetup?.id == id { + inFlightSessionSetup = nil + } + } try await task.value } @@ -160,7 +165,7 @@ extension PayabliTTP { // MARK: - Phase 0 — eligibility private func runEligibility() async throws { - guard case .failure(let err) = await provider.checkEligibility() else { return } + guard case let .failure(err) = await provider.checkEligibility() else { return } sessionManager.markError(err) syncPublished() throw err diff --git a/Sources/PayabliSDKTapToPay/PayabliTTP.swift b/Sources/PayabliSDKTapToPay/PayabliTTP.swift index b3547dd..41e1d92 100644 --- a/Sources/PayabliSDKTapToPay/PayabliTTP.swift +++ b/Sources/PayabliSDKTapToPay/PayabliTTP.swift @@ -37,7 +37,6 @@ import PayabliSDKCore @objc(PayabliTTP) @MainActor public final class PayabliTTP: NSObject, ObservableObject { - // MARK: - Dependencies let entryPoint: String @@ -55,10 +54,10 @@ public final class PayabliTTP: NSObject, ObservableObject { let transactionClient: TTPTransactionClient let configClient: TTPConfigClient - // Deviceid cached from attestation state (used as initiate `device:`). + /// Deviceid cached from attestation state (used as initiate `device:`). var cachedDeviceId: String? - // Session state + /// Session state let sessionManager = SessionManager() /// Bumped every time a reader is prepared. A charge captures it before the @@ -131,116 +130,116 @@ public final class PayabliTTP: NSObject, ObservableObject { ) } - /// PRD §19.1 convenience init. Wires the default `FiservCardReader` - /// provider and a real `AppAttestService` with Keychain-backed storage. - /// - /// The host supplies the server-minted `accessToken` and an optional - /// `tokenProvider` callback for refreshes (see `PayabliConfig`). - /// - /// Only available where Apple's `DeviceCheck` framework is importable. - /// The package minimums (iOS 16.7 from PayabliCardReaderCore / ProximityReader, - /// and macOS 12 from `Package.swift`) are both well above `DCAppAttestService`'s - /// own floor (iOS 14 / macOS 11.3), so no extra `@available` gate is needed. - /// Platforms without `DeviceCheck` must use the designated init with a - /// custom `DeviceAttestationService`. + // PRD §19.1 convenience init. Wires the default `FiservCardReader` + // provider and a real `AppAttestService` with Keychain-backed storage. + // + // The host supplies the server-minted `accessToken` and an optional + // `tokenProvider` callback for refreshes (see `PayabliConfig`). + // + // Only available where Apple's `DeviceCheck` framework is importable. + // The package minimums (iOS 16.7 from PayabliCardReaderCore / ProximityReader, + // and macOS 12 from `Package.swift`) are both well above `DCAppAttestService`'s + // own floor (iOS 14 / macOS 11.3), so no extra `@available` gate is needed. + // Platforms without `DeviceCheck` must use the designated init with a + // custom `DeviceAttestationService`. #if canImport(DeviceCheck) - public convenience init( - accessToken: String, - tokenProvider: PayabliTokenRefresh? = nil, - entryPoint: String, - appId: String, - environment: PayabliEnvironment - ) { - let config = PayabliConfig( - accessToken: accessToken, - tokenProvider: tokenProvider, - entryPoint: entryPoint, - environment: environment - ) - let payabliSession = PayabliSession(config: config) - let storage: SecureStorage = KeychainStorage() - let attestation = AppAttestService( - transport: payabliSession.transport, - attestor: RealAppAttestor(), - storage: storage - ) - self.init( - session: payabliSession, - appId: appId, - provider: FiservCardReader(), - attestation: attestation - ) - } + public convenience init( + accessToken: String, + tokenProvider: PayabliTokenRefresh? = nil, + entryPoint: String, + appId: String, + environment: PayabliEnvironment + ) { + let config = PayabliConfig( + accessToken: accessToken, + tokenProvider: tokenProvider, + entryPoint: entryPoint, + environment: environment + ) + let payabliSession = PayabliSession(config: config) + let storage: SecureStorage = KeychainStorage() + let attestation = AppAttestService( + transport: payabliSession.transport, + attestor: RealAppAttestor(), + storage: storage + ) + self.init( + session: payabliSession, + appId: appId, + provider: FiservCardReader(), + attestation: attestation + ) + } #endif - /// `@objc`-friendly convenience init for ObjC / MAUI / sharpie consumers - /// that can't represent the Swift `PayabliTokenRefresh` (`@Sendable () async - /// throws -> String`) closure. - /// - /// Token refresh is exposed here as a completion-style block: - /// `tokenRefreshHandler` receives a `(token, error) -> Void` callback that - /// the host invokes exactly once with either a fresh access token or an - /// `NSError` — the SDK bridges that to the underlying async closure - /// internally. Pass `nil` to disable silent refresh; the SDK will surface - /// `tokenExpired` instead. - /// - /// All other parameters mirror the Swift convenience init exactly. Swift - /// callers that need an `async throws` token provider should keep using - /// the Swift-only convenience init above. + // `@objc`-friendly convenience init for ObjC / MAUI / sharpie consumers + // that can't represent the Swift `PayabliTokenRefresh` (`@Sendable () async + // throws -> String`) closure. + // + // Token refresh is exposed here as a completion-style block: + // `tokenRefreshHandler` receives a `(token, error) -> Void` callback that + // the host invokes exactly once with either a fresh access token or an + // `NSError` — the SDK bridges that to the underlying async closure + // internally. Pass `nil` to disable silent refresh; the SDK will surface + // `tokenExpired` instead. + // + // All other parameters mirror the Swift convenience init exactly. Swift + // callers that need an `async throws` token provider should keep using + // the Swift-only convenience init above. #if canImport(DeviceCheck) - @objc public convenience init( - accessToken: String, - tokenRefreshHandler: ((@escaping (String?, NSError?) -> Void) -> Void)?, - entryPoint: String, - appId: String, - environment: PayabliEnvironment - ) { - let bridged: PayabliTokenRefresh? = tokenRefreshHandler.map { handler in - // ObjC blocks are heap-allocated and copy-on-capture, so the - // bridged closure can safely be `@Sendable` even though Swift - // does not infer `@Sendable` for the input handler type. - let sendable = UncheckedSendableBox(handler) - return { @Sendable in - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - // Guard against the ObjC host invoking the completion - // block more than once — that would resume the same - // continuation twice and crash. We honor only the first - // invocation (success or failure) and silently drop any - // subsequent calls. A `Locked` keeps this thread- - // safe in case the host dispatches the callback from a - // background queue. - let resumed = Locked(false) - sendable.value { token, error in - let firstCall = resumed.withLock { hasResumed in - guard !hasResumed else { return false } - hasResumed = true - return true - } - guard firstCall else { return } - if let error { - continuation.resume(throwing: error) - } else if let token { - continuation.resume(returning: token) - } else { - continuation.resume(throwing: NSError( - domain: "com.payabli.ttp", - code: -1, - userInfo: [NSLocalizedDescriptionKey: - "tokenRefreshHandler returned nil token and nil error"] - )) + @objc public convenience init( + accessToken: String, + tokenRefreshHandler: ((@escaping (String?, NSError?) -> Void) -> Void)?, + entryPoint: String, + appId: String, + environment: PayabliEnvironment + ) { + let bridged: PayabliTokenRefresh? = tokenRefreshHandler.map { handler in + // ObjC blocks are heap-allocated and copy-on-capture, so the + // bridged closure can safely be `@Sendable` even though Swift + // does not infer `@Sendable` for the input handler type. + let sendable = UncheckedSendableBox(handler) + return { @Sendable in + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + // Guard against the ObjC host invoking the completion + // block more than once — that would resume the same + // continuation twice and crash. We honor only the first + // invocation (success or failure) and silently drop any + // subsequent calls. A `Locked` keeps this thread- + // safe in case the host dispatches the callback from a + // background queue. + let resumed = Locked(false) + sendable.value { token, error in + let firstCall = resumed.withLock { hasResumed in + guard !hasResumed else { return false } + hasResumed = true + return true + } + guard firstCall else { return } + if let error { + continuation.resume(throwing: error) + } else if let token { + continuation.resume(returning: token) + } else { + continuation.resume(throwing: NSError( + domain: "com.payabli.ttp", + code: -1, + userInfo: [NSLocalizedDescriptionKey: + "tokenRefreshHandler returned nil token and nil error"] + )) + } } } } } + self.init( + accessToken: accessToken, + tokenProvider: bridged, + entryPoint: entryPoint, + appId: appId, + environment: environment + ) } - self.init( - accessToken: accessToken, - tokenProvider: bridged, - entryPoint: entryPoint, - appId: appId, - environment: environment - ) - } #endif // MARK: - Events diff --git a/Sources/PayabliSDKTapToPay/PayabliTTPEvent.swift b/Sources/PayabliSDKTapToPay/PayabliTTPEvent.swift index e89174c..864f726 100644 --- a/Sources/PayabliSDKTapToPay/PayabliTTPEvent.swift +++ b/Sources/PayabliSDKTapToPay/PayabliTTPEvent.swift @@ -72,10 +72,10 @@ public enum PayabliTTPError: Error, Sendable { case activationFailed = 17 } -extension PayabliTTPEvent { +public extension PayabliTTPEvent { /// Returns the `PayabliTTPEventCode` for this event — used by the /// `addEventListener(handler:)` ObjC bridge. - public var code: PayabliTTPEventCode { + var code: PayabliTTPEventCode { switch self { case .attestationStarted: return .attestationStarted case .attestationCompleted: return .attestationCompleted @@ -106,15 +106,15 @@ extension PayabliTTPEvent { /// - `.nfcFailed`, `.activationFailed` → `["error": String]` /// - `.updateFailed` → `["paymentTransId": String, "error": String]` /// - all other cases → empty `[:]` - public var payload: [String: Any] { + var payload: [String: Any] { switch self { - case .chargeInitiated(let paymentTransId), - .updateCompleted(let paymentTransId): + case let .chargeInitiated(paymentTransId), + let .updateCompleted(paymentTransId): return ["paymentTransId": paymentTransId] - case .nfcFailed(let error), - .activationFailed(let error): + case let .nfcFailed(error), + let .activationFailed(error): return ["error": error] - case .updateFailed(let paymentTransId, let error): + case let .updateFailed(paymentTransId, error): return ["paymentTransId": paymentTransId, "error": error] case .attestationStarted, .attestationCompleted, @@ -142,7 +142,9 @@ extension PayabliTTPEvent { /// part of the public API: do not reorder or renumber. New cases must be /// appended at the end with a new code. extension PayabliTTPError: CustomNSError, LocalizedError { - public static var errorDomain: String { "com.payabli.ttp" } + public static var errorDomain: String { + "com.payabli.ttp" + } public var errorCode: Int { switch self { @@ -167,23 +169,23 @@ extension PayabliTTPError: CustomNSError, LocalizedError { switch self { case .notInitialized: return [NSLocalizedDescriptionKey: "PayabliTTP has not been initialized"] - case .invalidState(let current, let attempted): + case let .invalidState(current, attempted): return [NSLocalizedDescriptionKey: "Invalid state \(current) for \(attempted)"] - case .notReady(let current): + case let .notReady(current): return [NSLocalizedDescriptionKey: "Reader not ready (state: \(current))"] case .devicePendingActivation: return [NSLocalizedDescriptionKey: "Device is pending activation"] case .tokenExpired: return [NSLocalizedDescriptionKey: "Access token expired"] - case .attestationRevoked(let reason), - .attestationFailed(let reason), - .configFailed(let reason), - .readerSetupFailed(let reason), - .nfcFailed(let reason), - .initiateFailed(let reason), - .updateFailed(let reason), - .activationFailed(let reason), - .networkError(let reason): + case let .attestationRevoked(reason), + let .attestationFailed(reason), + let .configFailed(reason), + let .readerSetupFailed(reason), + let .nfcFailed(reason), + let .initiateFailed(reason), + let .updateFailed(reason), + let .activationFailed(reason), + let .networkError(reason): return [NSLocalizedDescriptionKey: reason] } } diff --git a/Sources/PayabliSDKTapToPay/PayabliTTPTransactionData+ObjC.swift b/Sources/PayabliSDKTapToPay/PayabliTTPTransactionData+ObjC.swift index c4c464e..8da8e07 100644 --- a/Sources/PayabliSDKTapToPay/PayabliTTPTransactionData+ObjC.swift +++ b/Sources/PayabliSDKTapToPay/PayabliTTPTransactionData+ObjC.swift @@ -1,6 +1,7 @@ import Foundation // MARK: - ObjC companions for transaction-data structs + // // These `*ObjC` classes are thin wrappers around the Swift value types in // `PayabliTTPTransactionData.swift` / `PayabliTTPTypes.swift` so ObjC, MAUI diff --git a/Sources/PayabliSDKTapToPay/PayabliTTPTransactionData.swift b/Sources/PayabliSDKTapToPay/PayabliTTPTransactionData.swift index b148b3c..1002642 100644 --- a/Sources/PayabliSDKTapToPay/PayabliTTPTransactionData.swift +++ b/Sources/PayabliSDKTapToPay/PayabliTTPTransactionData.swift @@ -188,7 +188,9 @@ public struct PayabliTTPInvoiceData: Sendable, Equatable { self.invoiceNumber = PayabliTTPInvoiceData.sanitize(invoiceNumber) } - public var isEmpty: Bool { invoiceNumber == nil } + public var isEmpty: Bool { + invoiceNumber == nil + } private static func sanitize(_ value: String?) -> String? { guard let value else { return nil } diff --git a/Sources/PayabliSDKTapToPay/ReaderFailureClassification.swift b/Sources/PayabliSDKTapToPay/ReaderFailureClassification.swift index fe691fe..bf229bf 100644 --- a/Sources/PayabliSDKTapToPay/ReaderFailureClassification.swift +++ b/Sources/PayabliSDKTapToPay/ReaderFailureClassification.swift @@ -1,6 +1,6 @@ import Foundation #if canImport(ProximityReader) -import ProximityReader + import ProximityReader #endif /// Whether the reader session is gone, or only this read failed. A dead session @@ -13,9 +13,9 @@ import ProximityReader /// only the case name arrives, in text. func readerFailureInvalidatesSession(_ error: Error) -> Bool { #if canImport(ProximityReader) - if let readError = error as? PaymentCardReaderSession.ReadError { - return sessionLevelReadErrorNames.contains(String(describing: readError)) - } + if let readError = error as? PaymentCardReaderSession.ReadError { + return sessionLevelReadErrorNames.contains(String(describing: readError)) + } #endif let text = failureText(of: error) @@ -43,8 +43,8 @@ private let sessionLevelReadErrorNames: Set = [ private func failureText(of error: Error) -> String { if let ttpError = error as? PayabliTTPError { switch ttpError { - case .nfcFailed(let reason), - .readerSetupFailed(let reason): + case let .nfcFailed(reason), + let .readerSetupFailed(reason): return reason default: return String(describing: ttpError) diff --git a/Sources/PayabliSDKTapToPay/SessionManager.swift b/Sources/PayabliSDKTapToPay/SessionManager.swift index dbf2513..d1cdd4b 100644 --- a/Sources/PayabliSDKTapToPay/SessionManager.swift +++ b/Sources/PayabliSDKTapToPay/SessionManager.swift @@ -1,5 +1,5 @@ -import Foundation import Combine +import Foundation /// Manages the 9-state TTP session lifecycle (PRD §17). /// @@ -8,13 +8,13 @@ import Combine /// SwiftUI observation (§17.4). /// Which entry point is building the session, so the two can be told apart when /// one is already running. -internal enum SessionSetupKind { +enum SessionSetupKind { case initialize case reinitialize } @MainActor -internal final class SessionManager: ObservableObject { +final class SessionManager: ObservableObject { @Published private(set) var sessionState: PayabliTTPSessionState = .idle @Published private(set) var isReady: Bool = false private(set) var lastError: Error? @@ -64,7 +64,9 @@ internal final class SessionManager: ObservableObject { to target: PayabliTTPSessionState ) -> Bool { // Identity (re-entering same state) is allowed but not counted. - if current == target { return true } + if current == target { + return true + } switch (current, target) { // Starting over is always reachable. `initialize()` is the documented diff --git a/Sources/PayabliSDKTapToPay/TTPConfigClient.swift b/Sources/PayabliSDKTapToPay/TTPConfigClient.swift index 0d52c79..9e5e47c 100644 --- a/Sources/PayabliSDKTapToPay/TTPConfigClient.swift +++ b/Sources/PayabliSDKTapToPay/TTPConfigClient.swift @@ -60,7 +60,9 @@ public final class TTPConfigClient: Sendable { logger.info("[config] ← [\(response.statusCode)] body: \(responseBody)") try mapPayabliHTTPError(response: response) { code in - if code == 403 { return PayabliTTPError.devicePendingActivation } + if code == 403 { + return PayabliTTPError.devicePendingActivation + } return nil } @@ -81,10 +83,11 @@ public final class TTPConfigClient: Sendable { let decoder = JSONDecoder() guard let envelope = try? decoder.decode( - PayabliEnvelope.Success.self, - from: response.body - ), - let credentials = envelope.responseData?.credentials else { + PayabliEnvelope.Success.self, + from: response.body + ), + let credentials = envelope.responseData?.credentials + else { logger.error("[config] payload decode failed") throw PayabliTTPError.configFailed(reason: "Invalid config envelope") } diff --git a/Sources/PayabliSDKTapToPay/TTPConfigWireFormat.swift b/Sources/PayabliSDKTapToPay/TTPConfigWireFormat.swift index 7cec1c3..1596128 100644 --- a/Sources/PayabliSDKTapToPay/TTPConfigWireFormat.swift +++ b/Sources/PayabliSDKTapToPay/TTPConfigWireFormat.swift @@ -1,6 +1,7 @@ import Foundation // MARK: - /config wire types (PRD §8.2) + // // Generic envelope scaffolding (`Status`, `DeclineEnvelope`, `Success`, // `declineOutcome(from:)`) lives in `PayabliSDKCore/Networking/ResponseEnvelope.swift` diff --git a/Sources/PayabliSDKTapToPay/TTPTransactionClient.swift b/Sources/PayabliSDKTapToPay/TTPTransactionClient.swift index 93b0980..7158b9d 100644 --- a/Sources/PayabliSDKTapToPay/TTPTransactionClient.swift +++ b/Sources/PayabliSDKTapToPay/TTPTransactionClient.swift @@ -66,7 +66,10 @@ public final class TTPTransactionClient: Sendable { throw error } - logger.info("[initiate] ← isApproved=\(envelope.isApproved) code=\(envelope.code) paymentTransId=\(envelope.data?.paymentTransId ?? "")") + logger + .info( + "[initiate] ← isApproved=\(envelope.isApproved) code=\(envelope.code) paymentTransId=\(envelope.data?.paymentTransId ?? "")" + ) guard envelope.isApproved, let data = envelope.data else { throw PayabliTTPError.initiateFailed(reason: envelope.reason ?? envelope.code) @@ -121,11 +124,11 @@ public final class TTPTransactionClient: Sendable { static func updateBody(for payload: TTPUpdatePayload) -> Data { let encoder = JSONEncoder() switch payload { - case .success(let result): + case let .success(result): let body = UpdateSuccessBody(providerResponse: providerResponse(from: result)) return (try? encoder.encode(body)) ?? Data() - case .nfcFailure(let description): + case let .nfcFailure(description): let body = UpdateErrorBody(error: .init( title: "NFC Tap Failed", description: description, diff --git a/Sources/PayabliSDKTapToPay/TTPTransactionWireFormat.swift b/Sources/PayabliSDKTapToPay/TTPTransactionWireFormat.swift index e8a1abf..a0c38f0 100644 --- a/Sources/PayabliSDKTapToPay/TTPTransactionWireFormat.swift +++ b/Sources/PayabliSDKTapToPay/TTPTransactionWireFormat.swift @@ -87,8 +87,8 @@ struct InitiateCustomerData: Encodable { } struct InitiatePaymentMethod: Encodable { - let method: String // "device" (POI-device-backed TTP flow) - let device: String // Payabli deviceId (from /attest or /activate) + let method: String // "device" (POI-device-backed TTP flow) + let device: String // Payabli deviceId (from /attest or /activate) } struct InitiateInvoiceData: Encodable { @@ -155,6 +155,7 @@ struct UpdateErrorBody: Encodable { let description: String let failureReason: String } + let error: ErrorDetail } @@ -175,13 +176,13 @@ enum ProviderResponsePayload: Encodable { func encode(to encoder: Encoder) throws { switch self { - case .opaqueJSON(let json): + case let .opaqueJSON(json): // Re-parse the provider's JSON bytes into a `JSONValue` tree so it // merges cleanly into the outer encoder (same output as writing // the bytes directly, but type-safe). let value = try JSONDecoder().decode(JSONValue.self, from: json) try value.encode(to: encoder) - case .payloadOnly(let payload): + case let .payloadOnly(payload): try payload.encode(to: encoder) } } @@ -190,7 +191,7 @@ enum ProviderResponsePayload: Encodable { /// Structured shape sent by payload-only adapters. struct PayloadOnlyProviderResponse: Encodable { let provider: String - let encryptedPayload: String // base64 + let encryptedPayload: String // base64 let cardNetwork: String? let providerMetadata: [String: String] } @@ -208,12 +209,30 @@ private enum JSONValue: Codable { init(from decoder: Decoder) throws { let c = try decoder.singleValueContainer() - if c.decodeNil() { self = .null; return } - if let v = try? c.decode(Bool.self) { self = .bool(v); return } - if let v = try? c.decode(Double.self) { self = .number(v); return } - if let v = try? c.decode(String.self) { self = .string(v); return } - if let v = try? c.decode([JSONValue].self) { self = .array(v); return } - if let v = try? c.decode([String: JSONValue].self) { self = .object(v); return } + if c.decodeNil() { + self = .null + return + } + if let v = try? c.decode(Bool.self) { + self = .bool(v) + return + } + if let v = try? c.decode(Double.self) { + self = .number(v) + return + } + if let v = try? c.decode(String.self) { + self = .string(v) + return + } + if let v = try? c.decode([JSONValue].self) { + self = .array(v) + return + } + if let v = try? c.decode([String: JSONValue].self) { + self = .object(v) + return + } throw DecodingError.dataCorruptedError( in: c, debugDescription: "Unknown JSON value" ) @@ -223,11 +242,11 @@ private enum JSONValue: Codable { var c = encoder.singleValueContainer() switch self { case .null: try c.encodeNil() - case .bool(let v): try c.encode(v) - case .number(let v): try c.encode(v) - case .string(let v): try c.encode(v) - case .array(let v): try c.encode(v) - case .object(let v): try c.encode(v) + case let .bool(v): try c.encode(v) + case let .number(v): try c.encode(v) + case let .string(v): try c.encode(v) + case let .array(v): try c.encode(v) + case let .object(v): try c.encode(v) } } } diff --git a/Sources/PayabliSDKTapToPay/_ObjCBridging.swift b/Sources/PayabliSDKTapToPay/_ObjCBridging.swift index b822c81..1c49910 100644 --- a/Sources/PayabliSDKTapToPay/_ObjCBridging.swift +++ b/Sources/PayabliSDKTapToPay/_ObjCBridging.swift @@ -11,7 +11,9 @@ import Foundation /// the check explicitly at the boundary. struct UncheckedSendableBox: @unchecked Sendable { let value: Value - init(_ value: Value) { self.value = value } + init(_ value: Value) { + self.value = value + } } /// Tiny `NSLock`-backed reference cell used as a one-shot guard when @@ -24,7 +26,9 @@ final class Locked: @unchecked Sendable { private let lock = NSLock() private var value: Value - init(_ value: Value) { self.value = value } + init(_ value: Value) { + self.value = value + } /// Mutates and returns whatever the caller derives from the protected /// state, atomically. Use the inout argument to read+write. diff --git a/Sources/PayabliSDKTestUtils/InMemorySecureStorage.swift b/Sources/PayabliSDKTestUtils/InMemorySecureStorage.swift index fccc01f..667f395 100644 --- a/Sources/PayabliSDKTestUtils/InMemorySecureStorage.swift +++ b/Sources/PayabliSDKTestUtils/InMemorySecureStorage.swift @@ -12,17 +12,20 @@ public final class InMemorySecureStorage: SecureStorage, @unchecked Sendable { public init() {} public func string(forKey key: String) -> String? { - lock.lock(); defer { lock.unlock() } + lock.lock() + defer { lock.unlock() } return store[key] } public func set(_ value: String, forKey key: String) throws { - lock.lock(); defer { lock.unlock() } + lock.lock() + defer { lock.unlock() } store[key] = value } public func remove(forKey key: String) { - lock.lock(); defer { lock.unlock() } + lock.lock() + defer { lock.unlock() } store.removeValue(forKey: key) } } diff --git a/Sources/PayabliSDKTestUtils/MockAppAttestor.swift b/Sources/PayabliSDKTestUtils/MockAppAttestor.swift index fdcdc99..fb35182 100644 --- a/Sources/PayabliSDKTestUtils/MockAppAttestor.swift +++ b/Sources/PayabliSDKTestUtils/MockAppAttestor.swift @@ -71,7 +71,9 @@ public final class MockAppAttestor: AppAttestor, @unchecked Sendable { public func generateKey() async throws -> AppAttestKeyId { return try lock.withLock { _generateKeyCalls += 1 - if let error = _generateKeyError { throw error } + if let error = _generateKeyError { + throw error + } return _generatedKeyId } } @@ -79,7 +81,9 @@ public final class MockAppAttestor: AppAttestor, @unchecked Sendable { public func attestKey(_ keyId: AppAttestKeyId, clientDataHash: ClientDataHash) async throws -> AttestationObject { return try lock.withLock { _attestKeyCalls += 1 - if let error = _attestKeyError { throw error } + if let error = _attestKeyError { + throw error + } return _attestationPayload } } @@ -87,7 +91,9 @@ public final class MockAppAttestor: AppAttestor, @unchecked Sendable { public func generateAssertion(_ keyId: AppAttestKeyId, clientDataHash: ClientDataHash) async throws -> AppAttestAssertion { return try lock.withLock { _generateAssertionCalls += 1 - if let error = _generateAssertionError { throw error } + if let error = _generateAssertionError { + throw error + } return _assertionPayload } } diff --git a/Sources/PayabliSDKTestUtils/MockDeviceAttestationService.swift b/Sources/PayabliSDKTestUtils/MockDeviceAttestationService.swift index 19ad7bc..c7b78fc 100644 --- a/Sources/PayabliSDKTestUtils/MockDeviceAttestationService.swift +++ b/Sources/PayabliSDKTestUtils/MockDeviceAttestationService.swift @@ -56,13 +56,13 @@ public final class MockDeviceAttestationService: DeviceAttestationService, @unch return _attestResult } switch result { - case .success(let value): + case let .success(value): lock.withLock { _isAlreadyAttested = true _cachedDeviceId = value.deviceId } return value - case .failure(let err): + case let .failure(err): throw err } } @@ -82,7 +82,9 @@ public final class MockDeviceAttestationService: DeviceAttestationService, @unch _activateCalls += 1 return _activationResult } - if case .failure(let err) = result { throw err } + if case let .failure(err) = result { + throw err + } } public func clearCache() { diff --git a/Sources/PayabliSDKTestUtils/MockTapToPayProvider.swift b/Sources/PayabliSDKTestUtils/MockTapToPayProvider.swift index 313b613..5f64fea 100644 --- a/Sources/PayabliSDKTestUtils/MockTapToPayProvider.swift +++ b/Sources/PayabliSDKTestUtils/MockTapToPayProvider.swift @@ -4,7 +4,9 @@ import PayabliSDKTapToPay /// Mock `TapToPayProvider` for unit tests that exercise the TTP session and /// charge flows without requiring a physical NFC reader. public final class MockTapToPayProvider: TapToPayProvider, @unchecked Sendable { - public static var providerId: String { "mock" } + public static var providerId: String { + "mock" + } private let lock = NSLock() @@ -82,7 +84,9 @@ public final class MockTapToPayProvider: TapToPayProvider, @unchecked Sendable { _lastConfiguredCredentials = credentials return _configureResult } - if case .failure(let err) = result { throw err } + if case let .failure(err) = result { + throw err + } } public func prepareReader() async throws { @@ -90,7 +94,9 @@ public final class MockTapToPayProvider: TapToPayProvider, @unchecked Sendable { _prepareReaderCalls += 1 return _prepareReaderResult } - if case .failure(let err) = result { throw err } + if case let .failure(err) = result { + throw err + } } public func startReading(_ request: CardReadRequest) async throws -> CardReadResult { @@ -99,8 +105,8 @@ public final class MockTapToPayProvider: TapToPayProvider, @unchecked Sendable { return _readingResult } switch result { - case .success(let value): return value - case .failure(let err): throw err + case let .success(value): return value + case let .failure(err): throw err } } diff --git a/Sources/PayabliSDKTestUtils/StubURLProtocol.swift b/Sources/PayabliSDKTestUtils/StubURLProtocol.swift index 7f13a39..7fd50e1 100644 --- a/Sources/PayabliSDKTestUtils/StubURLProtocol.swift +++ b/Sources/PayabliSDKTestUtils/StubURLProtocol.swift @@ -18,8 +18,13 @@ public final class StubURLProtocol: URLProtocol { public nonisolated(unsafe) static var handler: Handler? - override public class func canInit(with request: URLRequest) -> Bool { true } - override public class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override public class func canInit(with request: URLRequest) -> Bool { + true + } + + override public class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } override public func startLoading() { guard let handler = Self.handler else { @@ -61,7 +66,9 @@ public final class StubURLProtocol: URLProtocol { var buffer = [UInt8](repeating: 0, count: 4096) while stream.hasBytesAvailable { let read = stream.read(&buffer, maxLength: buffer.count) - if read <= 0 { break } + if read <= 0 { + break + } data.append(buffer, count: read) } return data diff --git a/Tests/PayabliSDKCoreTests/AuthenticatedTransportTests.swift b/Tests/PayabliSDKCoreTests/AuthenticatedTransportTests.swift index c8bb9b5..7d54453 100644 --- a/Tests/PayabliSDKCoreTests/AuthenticatedTransportTests.swift +++ b/Tests/PayabliSDKCoreTests/AuthenticatedTransportTests.swift @@ -1,8 +1,7 @@ -import XCTest @testable import PayabliSDKCore +import XCTest final class AuthenticatedTransportTests: XCTestCase { - func testInjectsBearerHeaderOnEveryRequest() async throws { let mock = MockTransport(scripted: [ .response(statusCode: 200, body: Data("{}".utf8)) @@ -104,7 +103,9 @@ actor MockTransport: PayabliTransport { self.scripted = scripted } - func captured() -> [PayabliRequest] { requests } + func captured() -> [PayabliRequest] { + requests + } func perform(_ request: PayabliRequest) async throws -> PayabliResponse { requests.append(request) diff --git a/Tests/PayabliSDKCoreTests/EventMulticasterTests.swift b/Tests/PayabliSDKCoreTests/EventMulticasterTests.swift index 077fa00..2660124 100644 --- a/Tests/PayabliSDKCoreTests/EventMulticasterTests.swift +++ b/Tests/PayabliSDKCoreTests/EventMulticasterTests.swift @@ -1,8 +1,7 @@ -import XCTest import PayabliSDKCore +import XCTest final class EventMulticasterTests: XCTestCase { - func testSingleSubscriberReceivesEvents() async { let multicaster = EventMulticaster() let stream = multicaster.stream() @@ -31,12 +30,16 @@ final class EventMulticasterTests: XCTestCase { let collect1 = Task { var events: [Int] = [] - for await evt in stream1 { events.append(evt) } + for await evt in stream1 { + events.append(evt) + } return events } let collect2 = Task { var events: [Int] = [] - for await evt in stream2 { events.append(evt) } + for await evt in stream2 { + events.append(evt) + } return events } @@ -58,7 +61,9 @@ final class EventMulticasterTests: XCTestCase { let collectTask = Task { var events: [String] = [] - for await evt in stream { events.append(evt) } + for await evt in stream { + events.append(evt) + } return events } diff --git a/Tests/PayabliSDKCoreTests/PayabliAuthTests.swift b/Tests/PayabliSDKCoreTests/PayabliAuthTests.swift index 97f0df3..7ab3cef 100644 --- a/Tests/PayabliSDKCoreTests/PayabliAuthTests.swift +++ b/Tests/PayabliSDKCoreTests/PayabliAuthTests.swift @@ -1,8 +1,7 @@ -import XCTest import PayabliSDKCore +import XCTest final class PayabliAuthTests: XCTestCase { - // MARK: - Helpers private func makeConfig( @@ -19,7 +18,7 @@ final class PayabliAuthTests: XCTestCase { // MARK: - Initial token - func testInitialTokenComesFromConfig() async throws { + func testInitialTokenComesFromConfig() async { let auth = PayabliAuth(config: makeConfig(accessToken: "seed")) let token = await auth.currentAccessToken() XCTAssertEqual(token, "seed") @@ -122,5 +121,7 @@ final class PayabliAuthTests: XCTestCase { /// Simple actor counter for tracking concurrent calls in tests. private actor Counter { private(set) var value: Int = 0 - func increment() { value += 1 } + func increment() { + value += 1 + } } diff --git a/Tests/PayabliSDKCoreTests/PayabliEnvironmentTests.swift b/Tests/PayabliSDKCoreTests/PayabliEnvironmentTests.swift index ba2c664..9ec03bd 100644 --- a/Tests/PayabliSDKCoreTests/PayabliEnvironmentTests.swift +++ b/Tests/PayabliSDKCoreTests/PayabliEnvironmentTests.swift @@ -1,10 +1,10 @@ -import XCTest import PayabliSDKCore +import XCTest final class PayabliEnvironmentTests: XCTestCase { func testBaseURLsMatchPRD() { #if DEBUG - XCTAssertEqual(PayabliEnvironment.local.baseURL.absoluteString, "https://wallets-test.ngrok.app") + XCTAssertEqual(PayabliEnvironment.local.baseURL.absoluteString, "https://wallets-test.ngrok.app") #endif XCTAssertEqual(PayabliEnvironment.qa.baseURL.absoluteString, "https://api-qa.payabli.com") XCTAssertEqual(PayabliEnvironment.sandbox.baseURL.absoluteString, "https://api-sandbox.payabli.com") diff --git a/Tests/PayabliSDKCoreTests/PayabliErrorCodeMappingTests.swift b/Tests/PayabliSDKCoreTests/PayabliErrorCodeMappingTests.swift index e006332..fb3ce9c 100644 --- a/Tests/PayabliSDKCoreTests/PayabliErrorCodeMappingTests.swift +++ b/Tests/PayabliSDKCoreTests/PayabliErrorCodeMappingTests.swift @@ -1,8 +1,7 @@ -import XCTest import PayabliSDKCore +import XCTest final class PayabliErrorCodeMappingTests: XCTestCase { - func testValidationErrorCodeMapsToValidation() throws { // Construct a PayabliValidationError via JSON decoding (the only // way to build one — it has no public memberwise init). diff --git a/Tests/PayabliSDKCoreTests/PayabliSDKCoreTests.swift b/Tests/PayabliSDKCoreTests/PayabliSDKCoreTests.swift index da693e2..b84b867 100644 --- a/Tests/PayabliSDKCoreTests/PayabliSDKCoreTests.swift +++ b/Tests/PayabliSDKCoreTests/PayabliSDKCoreTests.swift @@ -1,5 +1,5 @@ -import XCTest import PayabliSDKCore +import XCTest final class PayabliSDKCoreTests: XCTestCase { func testVersionIsPopulated() { diff --git a/Tests/PayabliSDKCoreTests/PayabliServiceTests.swift b/Tests/PayabliSDKCoreTests/PayabliServiceTests.swift index aee8cc0..2c276a1 100644 --- a/Tests/PayabliSDKCoreTests/PayabliServiceTests.swift +++ b/Tests/PayabliSDKCoreTests/PayabliServiceTests.swift @@ -1,6 +1,6 @@ -import XCTest import PayabliSDKCore import PayabliSDKTestUtils +import XCTest private struct FakeData: Decodable, Sendable { let paymentTransId: String @@ -60,7 +60,7 @@ final class PayabliServiceTests: XCTestCase { do { _ = try await service().performV2(request, decoding: FakeData.self) XCTFail("Expected error") - } catch PayabliPaymentError.validation(let err) { + } catch let PayabliPaymentError.validation(err) { XCTAssertEqual(err.status, 400) XCTAssertNotNil(err.errors?["paymentMethod.cardnumber"]) } catch { @@ -104,7 +104,7 @@ final class PayabliServiceTests: XCTestCase { do { _ = try await service().performV2(request, decoding: FakeData.self) XCTFail("Expected error") - } catch PayabliPaymentError.decline(let err) { + } catch let PayabliPaymentError.decline(err) { XCTAssertEqual(err.rawCode, "D0001") XCTAssertEqual(err.reason, "Card Declined") } catch { @@ -125,7 +125,7 @@ final class PayabliServiceTests: XCTestCase { do { _ = try await service().performV2(request, decoding: FakeData.self) XCTFail("Expected error") - } catch PayabliPaymentError.server(let err) { + } catch let PayabliPaymentError.server(err) { XCTAssertEqual(err.status, 500) } catch { XCTFail("Wrong error: \(error)") diff --git a/Tests/PayabliSDKCoreTests/PayabliSessionTests.swift b/Tests/PayabliSDKCoreTests/PayabliSessionTests.swift index 1353cae..695d9b1 100644 --- a/Tests/PayabliSDKCoreTests/PayabliSessionTests.swift +++ b/Tests/PayabliSDKCoreTests/PayabliSessionTests.swift @@ -1,6 +1,6 @@ -import XCTest import PayabliSDKCore import PayabliSDKTestUtils +import XCTest final class PayabliSessionTests: XCTestCase { func testSessionExposesAuthAndService() async { diff --git a/Tests/PayabliSDKCoreTests/PayabliTransportTests.swift b/Tests/PayabliSDKCoreTests/PayabliTransportTests.swift index 434bf55..a93805a 100644 --- a/Tests/PayabliSDKCoreTests/PayabliTransportTests.swift +++ b/Tests/PayabliSDKCoreTests/PayabliTransportTests.swift @@ -1,10 +1,10 @@ -import XCTest import PayabliSDKCore +import XCTest final class PayabliTransportTests: XCTestCase { func testPayabliServiceConformsToPayabliTransport() { let service = PayabliService(environment: .sandbox) - let _: any PayabliTransport = service // compile-time conformance proof + let _: any PayabliTransport = service // compile-time conformance proof XCTAssertTrue(true) } } diff --git a/Tests/PayabliSDKCoreTests/RetryPolicyTests.swift b/Tests/PayabliSDKCoreTests/RetryPolicyTests.swift index d2c9949..1bb69a9 100644 --- a/Tests/PayabliSDKCoreTests/RetryPolicyTests.swift +++ b/Tests/PayabliSDKCoreTests/RetryPolicyTests.swift @@ -1,8 +1,7 @@ -import XCTest import PayabliSDKCore +import XCTest final class RetryPolicyTests: XCTestCase { - func testDefaultParameters() { let policy = RetryPolicy.default XCTAssertEqual(policy.maxAttempts, 3) @@ -33,7 +32,7 @@ final class RetryPolicyTests: XCTestCase { func testJitterBounds() { let policy = RetryPolicy(maxAttempts: 3, baseDelay: 1, maxDelay: 8, multiplier: 2, maxJitter: 0.5) - for _ in 0..<20 { + for _ in 0 ..< 20 { let delay = policy.delay(forAttempt: 2) XCTAssertTrue(delay >= 1.0 && delay <= 1.5) } @@ -57,7 +56,13 @@ final class RetryPolicyTests: XCTestCase { func testRetryRunSucceedsOnFirstAttempt() async throws { var attempts = 0 - let result = try await Retry.run(policy: RetryPolicy(maxAttempts: 3, baseDelay: 0, maxDelay: 0, multiplier: 1, maxJitter: 0)) { attempt in + let result = try await Retry.run(policy: RetryPolicy( + maxAttempts: 3, + baseDelay: 0, + maxDelay: 0, + multiplier: 1, + maxJitter: 0 + )) { attempt in attempts += 1 return attempt } @@ -67,7 +72,13 @@ final class RetryPolicyTests: XCTestCase { func testRetryRunRetriesRetryableErrors() async throws { var attempts = 0 - let result = try await Retry.run(policy: RetryPolicy(maxAttempts: 3, baseDelay: 0, maxDelay: 0, multiplier: 1, maxJitter: 0)) { attempt in + let result = try await Retry.run(policy: RetryPolicy( + maxAttempts: 3, + baseDelay: 0, + maxDelay: 0, + multiplier: 1, + maxJitter: 0 + )) { attempt in attempts += 1 if attempt < 3 { throw RetryableError(NSError(domain: "test", code: 500)) diff --git a/Tests/PayabliSDKCoreTests/TelemetryClientTests.swift b/Tests/PayabliSDKCoreTests/TelemetryClientTests.swift index cca5689..12381b5 100644 --- a/Tests/PayabliSDKCoreTests/TelemetryClientTests.swift +++ b/Tests/PayabliSDKCoreTests/TelemetryClientTests.swift @@ -1,9 +1,8 @@ -import XCTest import PayabliSDKCore import PayabliSDKTestUtils +import XCTest final class TelemetryClientTests: XCTestCase { - private func makeClient( enabled: Bool = true, batchSize: Int = 20 diff --git a/Tests/PayabliSDKPayInPaymentFlowTests/PayabliPaymentCaptureTests.swift b/Tests/PayabliSDKPayInPaymentFlowTests/PayabliPaymentCaptureTests.swift index b8b541e..b29bb33 100644 --- a/Tests/PayabliSDKPayInPaymentFlowTests/PayabliPaymentCaptureTests.swift +++ b/Tests/PayabliSDKPayInPaymentFlowTests/PayabliPaymentCaptureTests.swift @@ -1,6 +1,5 @@ @testable import PayabliSDKCore @testable import PayabliSDKPayInPaymentFlow -import PayabliSDKPayInPaymentFlow import SwiftUI import XCTest diff --git a/Tests/PayabliSDKPayInPaymentFlowTests/PaymentCaptureClientTests.swift b/Tests/PayabliSDKPayInPaymentFlowTests/PaymentCaptureClientTests.swift index 4952f46..c003c1a 100644 --- a/Tests/PayabliSDKPayInPaymentFlowTests/PaymentCaptureClientTests.swift +++ b/Tests/PayabliSDKPayInPaymentFlowTests/PaymentCaptureClientTests.swift @@ -1,6 +1,5 @@ @testable import PayabliSDKCore @testable import PayabliSDKPayInPaymentFlow -import PayabliSDKPayInPaymentFlow import XCTest final class PayInPaymentFlowClientTests: XCTestCase { diff --git a/Tests/PayabliSDKTapToPayTests/AppAttestServiceTests.swift b/Tests/PayabliSDKTapToPayTests/AppAttestServiceTests.swift index dcf4a93..ad1ff07 100644 --- a/Tests/PayabliSDKTapToPayTests/AppAttestServiceTests.swift +++ b/Tests/PayabliSDKTapToPayTests/AppAttestServiceTests.swift @@ -1,7 +1,7 @@ -import XCTest @testable import PayabliSDKCore @testable import PayabliSDKTapToPay import PayabliSDKTestUtils +import XCTest final class AppAttestServiceTests: XCTestCase { override func tearDown() { @@ -35,8 +35,12 @@ final class AppAttestServiceTests: XCTestCase { } private func response(_ status: Int, body: Data, url: URL) -> (HTTPURLResponse, Data) { - (HTTPURLResponse(url: url, statusCode: status, httpVersion: "HTTP/1.1", - headerFields: ["Content-Type": "application/json"])!, body) + (HTTPURLResponse( + url: url, + statusCode: status, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )!, body) } private static func envelope(responseData: [String: Any]) -> Data { @@ -57,14 +61,20 @@ final class AppAttestServiceTests: XCTestCase { pathsBox.append(request.url!.path) switch request.url!.path { case "/api/v2/device/taptopay/challenge": - return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, - Self.envelope(responseData: ["challengeId": "c_1", "challenge": "Y2hhbGxlbmdl"])) + return ( + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, + Self.envelope(responseData: ["challengeId": "c_1", "challenge": "Y2hhbGxlbmdl"]) + ) case "/api/v2/device/taptopay/register": - return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, - Self.envelope(responseData: ["deviceId": "dev_1"])) + return ( + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, + Self.envelope(responseData: ["deviceId": "dev_1"]) + ) case "/api/v2/device/taptopay/attest": - return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, - Self.envelope(responseData: ["ok": true])) + return ( + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, + Self.envelope(responseData: ["ok": true]) + ) default: XCTFail("unexpected path: \(request.url!.path)") return (HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)!, Data()) @@ -101,14 +111,20 @@ final class AppAttestServiceTests: XCTestCase { pathsBox.append(request.url!.path) switch request.url!.path { case "/api/v2/device/taptopay/challenge": - return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, - Self.envelope(responseData: ["challengeId": "c", "challenge": "Y2hhbGxlbmdl"])) + return ( + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, + Self.envelope(responseData: ["challengeId": "c", "challenge": "Y2hhbGxlbmdl"]) + ) case "/api/v2/device/taptopay/register": - return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, - Self.envelope(responseData: ["deviceId": "dev_pending", "status": "pending"])) + return ( + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, + Self.envelope(responseData: ["deviceId": "dev_pending", "status": "pending"]) + ) case "/api/v2/device/taptopay/attest": - return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, - Self.envelope(responseData: ["ok": true])) + return ( + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, + Self.envelope(responseData: ["ok": true]) + ) default: XCTFail("unexpected path: \(request.url!.path)") return (HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)!, Data()) @@ -147,11 +163,15 @@ final class AppAttestServiceTests: XCTestCase { StubURLProtocol.handler = { request in switch request.url!.path { case "/api/v2/device/taptopay/challenge": - return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, - Self.envelope(responseData: ["challengeId": "c_1", "challenge": "Y2hhbGxlbmdl"])) + return ( + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, + Self.envelope(responseData: ["challengeId": "c_1", "challenge": "Y2hhbGxlbmdl"]) + ) case "/api/v2/device/taptopay/register": - return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, - Self.envelope(responseData: ["deviceId": "dev_1"])) + return ( + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, + Self.envelope(responseData: ["deviceId": "dev_1"]) + ) default: XCTFail("attest must not reach \(request.url!.path) once attestKey fails") return (HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)!, Data()) @@ -185,18 +205,24 @@ final class AppAttestServiceTests: XCTestCase { StubURLProtocol.handler = { request in switch request.url!.path { case "/api/v2/device/taptopay/challenge": - return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, - Self.envelope(responseData: ["challengeId": "c_1", "challenge": "Y2hhbGxlbmdl"])) + return ( + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, + Self.envelope(responseData: ["challengeId": "c_1", "challenge": "Y2hhbGxlbmdl"]) + ) case "/api/v2/device/taptopay/register": // Fail the first /register (pre-attest); succeed the second. if registerAttempts.increment() == 1 { return (HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: "HTTP/1.1", headerFields: nil)!, Data()) } - return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, - Self.envelope(responseData: ["deviceId": "dev_1"])) + return ( + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, + Self.envelope(responseData: ["deviceId": "dev_1"]) + ) case "/api/v2/device/taptopay/attest": - return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, - Self.envelope(responseData: ["ok": true])) + return ( + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, + Self.envelope(responseData: ["ok": true]) + ) default: XCTFail("unexpected path: \(request.url!.path)") return (HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)!, Data()) @@ -215,8 +241,11 @@ final class AppAttestServiceTests: XCTestCase { } XCTAssertEqual(attestor.generateKeyCalls, 1) XCTAssertFalse(sut.isAlreadyAttested) - XCTAssertEqual(storage.string(forKey: PayabliKeychainKey.pendingKeyId), "mock_keyId", - "a pre-attest failure must keep the generated key for reuse") + XCTAssertEqual( + storage.string(forKey: PayabliKeychainKey.pendingKeyId), + "mock_keyId", + "a pre-attest failure must keep the generated key for reuse" + ) // Attempt 2 succeeds and must REUSE the pending key — no new generateKey. let result = try await sut.attest(entry: "myEntry", appId: "x") @@ -224,8 +253,10 @@ final class AppAttestServiceTests: XCTestCase { XCTAssertEqual(attestor.generateKeyCalls, 1, "the pending key should be reused, not regenerated") XCTAssertEqual(attestor.attestKeyCalls, 1) XCTAssertTrue(sut.isAlreadyAttested) - XCTAssertNil(storage.string(forKey: PayabliKeychainKey.pendingKeyId), - "pending slot must be cleared once attestation completes") + XCTAssertNil( + storage.string(forKey: PayabliKeychainKey.pendingKeyId), + "pending slot must be cleared once attestation completes" + ) } // MARK: - Assertion generation @@ -299,7 +330,7 @@ final class AppAttestServiceTests: XCTestCase { // MARK: - clearCache - func testClearCacheRemovesKeychainState() async throws { + func testClearCacheRemovesKeychainState() throws { let storage = InMemorySecureStorage() try storage.set("a", forKey: PayabliKeychainKey.keyId) try storage.set("b", forKey: PayabliKeychainKey.deviceId) @@ -315,11 +346,14 @@ private final class PathsBox: @unchecked Sendable { private let lock = NSLock() private var storage: [String] = [] var values: [String] { - lock.lock(); defer { lock.unlock() } + lock.lock() + defer { lock.unlock() } return storage } + func append(_ s: String) { - lock.lock(); defer { lock.unlock() } + lock.lock() + defer { lock.unlock() } storage.append(s) } } @@ -331,7 +365,8 @@ private final class CountBox: @unchecked Sendable { private var count = 0 /// Increments and returns the new value (first call returns 1). func increment() -> Int { - lock.lock(); defer { lock.unlock() } + lock.lock() + defer { lock.unlock() } count += 1 return count } diff --git a/Tests/PayabliSDKTapToPayTests/FiservCardReaderTests.swift b/Tests/PayabliSDKTapToPayTests/FiservCardReaderTests.swift index 721e7f0..b46af98 100644 --- a/Tests/PayabliSDKTapToPayTests/FiservCardReaderTests.swift +++ b/Tests/PayabliSDKTapToPayTests/FiservCardReaderTests.swift @@ -1,8 +1,7 @@ -import XCTest @testable import PayabliSDKTapToPay +import XCTest final class FiservCardReaderTests: XCTestCase { - func testProviderId() { XCTAssertEqual(FiservCardReader.providerId, "fiserv") } @@ -10,29 +9,29 @@ final class FiservCardReaderTests: XCTestCase { /// Eligibility is platform/hardware-only (PRD FR-11J.2) and is called before /// `/config` delivers credentials — so a fresh reader on a supported device /// must report `success`. - func testEligibilityIsPlatformOnly() async throws { + func testEligibilityIsPlatformOnly() async { let reader = FiservCardReader() let result = await reader.checkEligibility() #if os(iOS) - if #available(iOS 16.7, *) { - // On a real iPhone `success`; on an incompatible device - // `readerSetupFailed` — both are acceptable. We just assert the - // error (if any) is not about missing credentials. - if case .failure(let err) = result, case .readerSetupFailed(let reason) = err { - XCTAssertFalse( - reason.lowercased().contains("credentials"), - "eligibility should not require credentials" - ) + if #available(iOS 16.7, *) { + // On a real iPhone `success`; on an incompatible device + // `readerSetupFailed` — both are acceptable. We just assert the + // error (if any) is not about missing credentials. + if case let .failure(err) = result, case let .readerSetupFailed(reason) = err { + XCTAssertFalse( + reason.lowercased().contains("credentials"), + "eligibility should not require credentials" + ) + } + } else { + if case .success = result { + XCTFail("iOS < 16.7 must fail eligibility") + } } - } else { + #else if case .success = result { - XCTFail("iOS < 16.7 must fail eligibility") + XCTFail("non-iOS must fail eligibility") } - } - #else - if case .success = result { - XCTFail("non-iOS must fail eligibility") - } #endif } @@ -41,7 +40,7 @@ final class FiservCardReaderTests: XCTestCase { do { try await reader.prepareReader() XCTFail("expected readerSetupFailed") - } catch PayabliTTPError.readerSetupFailed(let reason) { + } catch let PayabliTTPError.readerSetupFailed(reason) { XCTAssertTrue( reason.lowercased().contains("credentials") || reason.lowercased().contains("ios-only"), "unexpected reason: \(reason)" @@ -76,7 +75,7 @@ final class FiservCardReaderTests: XCTestCase { "apiKey": "a" ]) XCTFail("expected readerSetupFailed") - } catch PayabliTTPError.readerSetupFailed(let reason) { + } catch let PayabliTTPError.readerSetupFailed(reason) { XCTAssertTrue(reason.contains("merchantId")) XCTAssertTrue(reason.contains("terminalId")) } catch { @@ -94,7 +93,7 @@ final class FiservCardReaderTests: XCTestCase { "terminalId": "t" ]) XCTFail("expected readerSetupFailed") - } catch PayabliTTPError.readerSetupFailed(let reason) { + } catch let PayabliTTPError.readerSetupFailed(reason) { XCTAssertTrue(reason.contains("merchantId")) } catch { XCTFail("wrong error: \(error)") diff --git a/Tests/PayabliSDKTapToPayTests/PayabliTTPErrorNSErrorTests.swift b/Tests/PayabliSDKTapToPayTests/PayabliTTPErrorNSErrorTests.swift index d8440da..eb50c0a 100644 --- a/Tests/PayabliSDKTapToPayTests/PayabliTTPErrorNSErrorTests.swift +++ b/Tests/PayabliSDKTapToPayTests/PayabliTTPErrorNSErrorTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import PayabliSDKTapToPay +import XCTest /// Verifies that every `PayabliTTPError` case bridges to an `NSError` with /// the documented domain `"com.payabli.ttp"`, the documented stable per-case @@ -9,7 +9,6 @@ import XCTest /// in the middle of `PayabliTTPError` would silently renumber the rest, so /// these tests fail loudly to remind us to append-only. final class PayabliTTPErrorNSErrorTests: XCTestCase { - // MARK: - Domain func testAllErrorsUseTheTTPDomain() { @@ -41,8 +40,10 @@ final class PayabliTTPErrorNSErrorTests: XCTestCase { let nsError = sample.error as NSError let description = nsError.userInfo[NSLocalizedDescriptionKey] as? String XCTAssertNotNil(description, "Missing description for \(sample.error)") - XCTAssertFalse(description?.isEmpty ?? true, - "Empty description for \(sample.error)") + XCTAssertFalse( + description?.isEmpty ?? true, + "Empty description for \(sample.error)" + ) } } @@ -50,8 +51,10 @@ final class PayabliTTPErrorNSErrorTests: XCTestCase { let err = PayabliTTPError.invalidState(current: .ready, attempted: "charge") let description = (err as NSError).userInfo[NSLocalizedDescriptionKey] as? String XCTAssertNotNil(description) - XCTAssertTrue(description?.contains("charge") ?? false, - "Expected description to include attempted operation; got: \(description ?? "")") + XCTAssertTrue( + description?.contains("charge") ?? false, + "Expected description to include attempted operation; got: \(description ?? "")" + ) XCTAssertTrue(description?.contains("Invalid state") ?? false) } @@ -59,8 +62,10 @@ final class PayabliTTPErrorNSErrorTests: XCTestCase { let err = PayabliTTPError.notReady(current: .attestingDevice) let description = (err as NSError).userInfo[NSLocalizedDescriptionKey] as? String XCTAssertNotNil(description) - XCTAssertTrue(description?.contains("not ready") ?? false, - "Expected description to mention reader not ready; got: \(description ?? "")") + XCTAssertTrue( + description?.contains("not ready") ?? false, + "Expected description to mention reader not ready; got: \(description ?? "")" + ) } func testReasonBearingErrorsForwardTheirReason() { diff --git a/Tests/PayabliSDKTapToPayTests/PayabliTTPEventCodeMappingTests.swift b/Tests/PayabliSDKTapToPayTests/PayabliTTPEventCodeMappingTests.swift index 0f39209..e14dafa 100644 --- a/Tests/PayabliSDKTapToPayTests/PayabliTTPEventCodeMappingTests.swift +++ b/Tests/PayabliSDKTapToPayTests/PayabliTTPEventCodeMappingTests.swift @@ -1,5 +1,5 @@ -import XCTest import PayabliSDKTapToPay +import XCTest /// Guards the public mapping between `PayabliTTPEvent` (Swift enum with /// associated values) and `PayabliTTPEventCode` (`@objc Int` enum) plus the @@ -11,7 +11,6 @@ import PayabliSDKTapToPay /// renumber the rest, breaking ObjC consumers that compare against literal /// codes — these tests fail loudly if that happens. final class PayabliTTPEventCodeMappingTests: XCTestCase { - func testEventCodeRawValuesAreStable() { XCTAssertEqual(PayabliTTPEventCode.attestationStarted.rawValue, 0) XCTAssertEqual(PayabliTTPEventCode.attestationCompleted.rawValue, 1) @@ -66,8 +65,10 @@ final class PayabliTTPEventCodeMappingTests: XCTestCase { .devicePendingActivation, .activationStarted, .activationCompleted ] for event in emptyCases { - XCTAssertTrue(event.payload.isEmpty, - "Expected empty payload for \(event.code)") + XCTAssertTrue( + event.payload.isEmpty, + "Expected empty payload for \(event.code)" + ) } } diff --git a/Tests/PayabliSDKTapToPayTests/PayabliTTPObjCInteropTests.swift b/Tests/PayabliSDKTapToPayTests/PayabliTTPObjCInteropTests.swift index e240f4f..9831252 100644 --- a/Tests/PayabliSDKTapToPayTests/PayabliTTPObjCInteropTests.swift +++ b/Tests/PayabliSDKTapToPayTests/PayabliTTPObjCInteropTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import PayabliSDKTapToPay +import XCTest /// Round-trip tests for the ObjC companion classes that wrap /// `PayabliTTPCustomerData`, `PayabliTTPPaymentDetails`, @@ -10,7 +10,6 @@ import XCTest /// hold the same fields with the same labels as the Swift structs and that /// `toSwift()` produces an equivalent value. final class PayabliTTPObjCInteropTests: XCTestCase { - // MARK: - PayabliTTPCustomerDataObjC func testCustomerDataObjCRoundTripPreservesAllFields() { @@ -319,7 +318,7 @@ final class PayabliTTPObjCInteropTests: XCTestCase { /// truncate any customer record id above `Int32.max`, attaching the /// charge to the wrong customer. func testCustomerDataObjCCustomerIdPreserves64BitValue() { - let big = Int64(Int32.max) + 1 // 2_147_483_648 — overflows Int32 + let big = Int64(Int32.max) + 1 // 2_147_483_648 — overflows Int32 let objc = PayabliTTPCustomerDataObjC( firstName: nil, lastName: nil, customerNumber: nil, email: nil, phone: nil, diff --git a/Tests/PayabliSDKTapToPayTests/PayabliTTPReaderSessionRecoveryTests.swift b/Tests/PayabliSDKTapToPayTests/PayabliTTPReaderSessionRecoveryTests.swift index d52e553..664ebda 100644 --- a/Tests/PayabliSDKTapToPayTests/PayabliTTPReaderSessionRecoveryTests.swift +++ b/Tests/PayabliSDKTapToPayTests/PayabliTTPReaderSessionRecoveryTests.swift @@ -1,10 +1,10 @@ -import XCTest import PayabliSDKCore @testable import PayabliSDKTapToPay import PayabliSDKTestUtils +import XCTest #if canImport(ProximityReader) -import ProximityReader + import ProximityReader #endif /// A reader session that dies must leave the session repairable. @@ -13,7 +13,6 @@ import ProximityReader /// state says `.ready`, so a failure that leaves it there wedges the session. @MainActor final class PayabliTTPReaderSessionRecoveryTests: XCTestCase { - override func setUp() { super.setUp() StubURLProtocol.handler = Self.chargeStubHandler @@ -89,19 +88,19 @@ final class PayabliTTPReaderSessionRecoveryTests: XCTestCase { // MARK: - Both classification tiers - /// The typed tier. Reachable when the raw error propagates to the facade. + // The typed tier. Reachable when the raw error propagates to the facade. #if canImport(ProximityReader) - func testTypedReadErrorIsClassified() { - XCTAssertTrue(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.noReaderSession)) - XCTAssertTrue(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.readerSessionExpired)) - XCTAssertTrue(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.readerTokenExpired)) - XCTAssertTrue(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.readerSessionAuthenticationError)) - - XCTAssertFalse(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.readCancelled)) - XCTAssertFalse(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.cardReadFailed)) - XCTAssertFalse(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.paymentCardDeclined)) - XCTAssertFalse(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.readerSessionBusy)) - } + func testTypedReadErrorIsClassified() { + XCTAssertTrue(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.noReaderSession)) + XCTAssertTrue(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.readerSessionExpired)) + XCTAssertTrue(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.readerTokenExpired)) + XCTAssertTrue(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.readerSessionAuthenticationError)) + + XCTAssertFalse(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.readCancelled)) + XCTAssertFalse(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.cardReadFailed)) + XCTAssertFalse(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.paymentCardDeclined)) + XCTAssertFalse(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.readerSessionBusy)) + } #endif /// The text tier, which is the one that actually fires on device: the @@ -241,7 +240,9 @@ final class PayabliTTPReaderSessionRecoveryTests: XCTestCase { await Task.yield() async let initializing: Void = ttp.initialize() - for _ in 0 ..< 20 { await Task.yield() } + for _ in 0 ..< 20 { + await Task.yield() + } provider.openGate() // The charge may fail: a host that re-initializes mid-charge takes @@ -338,7 +339,9 @@ final class PayabliTTPReaderSessionRecoveryTests: XCTestCase { /// How long a call may take before it is treated as never returning. Sized /// for the slowest machine that runs this, not the fastest: an existing test /// in this suite takes eighteen seconds on CI and milliseconds locally. - private var boundSeconds: UInt64 { 60 } + private var boundSeconds: UInt64 { + 60 + } /// Every SDK call in this file goes through here. These tests cover a wedge /// and two concurrency guards, so their failure mode is not returning, and @@ -358,14 +361,23 @@ final class PayabliTTPReaderSessionRecoveryTests: XCTestCase { let workTask = Task { @MainActor in do { let value = try await work() - if !once.done { once.done = true; continuation.resume(returning: value) } + if !once.done { + once.done = true + continuation.resume(returning: value) + } } catch { - if !once.done { once.done = true; continuation.resume(throwing: error) } + if !once.done { + once.done = true + continuation.resume(throwing: error) + } } } Task { @MainActor in try? await Task.sleep(nanoseconds: 5_000_000_000) - if !once.done { once.done = true; continuation.resume(throwing: ChargeTimedOut()) } + if !once.done { + once.done = true + continuation.resume(throwing: ChargeTimedOut()) + } workTask.cancel() } } @@ -410,17 +422,15 @@ final class PayabliTTPReaderSessionRecoveryTests: XCTestCase { /// cannot decode. private static let chargeStubHandler: StubURLProtocol.Handler = { request in let path = request.url?.path ?? "" - let body: [String: Any] - - if path.contains("/MoneyIn/initiate") { - body = [ + let body: [String: Any] = if path.contains("/MoneyIn/initiate") { + [ "code": "A01", "data": ["paymentTransId": PayabliTTPReaderSessionRecoveryTests.paymentTransId] ] } else if path.contains("/MoneyIn/update/") { - body = ["code": "A01", "data": ["paymentTransId": PayabliTTPReaderSessionRecoveryTests.paymentTransId]] + ["code": "A01", "data": ["paymentTransId": PayabliTTPReaderSessionRecoveryTests.paymentTransId]] } else { - body = [ + [ "responseCode": 1, "isSuccess": true, "responseData": [ @@ -463,8 +473,8 @@ private final class ResumeOnce { /// `XCTAssertThrowsError` has no async form, and an autoclosure cannot be /// awaited, so the expression is taken as an async closure. -private func XCTAssertThrowsErrorAsync( - _ expression: @autoclosure () async throws -> T, +private func XCTAssertThrowsErrorAsync( + _ expression: @autoclosure () async throws -> some Any, _ message: String = "expected an error", file: StaticString = #filePath, line: UInt = #line @@ -481,7 +491,6 @@ private func XCTAssertThrowsErrorAsync( } } - /// A provider that runs a caller-supplied step while a read is in flight, so the /// interleaving is deterministic instead of timing-dependent. /// `@unchecked` because every caller is on the main actor: the SDK surface is @@ -489,7 +498,9 @@ private func XCTAssertThrowsErrorAsync( /// instead would put the conformance across an isolation boundary, which is an /// error under the Swift 6 language mode. private final class InterleavingProvider: TapToPayProvider, @unchecked Sendable { - static var providerId: String { "interleaving" } + static var providerId: String { + "interleaving" + } var readingResult: Result = .failure( PayabliTTPError.nfcFailed(reason: "not configured") @@ -501,25 +512,35 @@ private final class InterleavingProvider: TapToPayProvider, @unchecked Sendable private var gate: CheckedContinuation? private var gateArmed = false - func armGate() { gateArmed = true } + func armGate() { + gateArmed = true + } func openGate() { gateArmed = false gate?.resume() gate = nil } + private(set) var prepareReaderCalls = 0 private(set) var configureCalls = 0 /// True if a second setup entered while one was still inside the provider. private(set) var sawOverlap = false private var inProvider = false - func checkEligibility() async -> Result { .success(()) } - func configure(credentials: [String: String]) throws { configureCalls += 1 } + func checkEligibility() async -> Result { + .success(()) + } + + func configure(credentials: [String: String]) throws { + configureCalls += 1 + } func prepareReader() async throws { prepareReaderCalls += 1 - if inProvider { sawOverlap = true } + if inProvider { + sawOverlap = true + } inProvider = true defer { inProvider = false } if gateArmed { @@ -528,6 +549,7 @@ private final class InterleavingProvider: TapToPayProvider, @unchecked Sendable await Task.yield() } } + func cancelReading() async {} func cleanUp() async {} diff --git a/Tests/PayabliSDKTapToPayTests/PayabliTTPSessionInitTests.swift b/Tests/PayabliSDKTapToPayTests/PayabliTTPSessionInitTests.swift index 561ca52..659f22d 100644 --- a/Tests/PayabliSDKTapToPayTests/PayabliTTPSessionInitTests.swift +++ b/Tests/PayabliSDKTapToPayTests/PayabliTTPSessionInitTests.swift @@ -1,11 +1,11 @@ -import XCTest -@testable import PayabliSDKTapToPay @testable import PayabliSDKCore +@testable import PayabliSDKTapToPay import PayabliSDKTestUtils +import XCTest @MainActor final class PayabliTTPSessionInitTests: XCTestCase { - func testTwoFacadesShareTheSameSession() async { + func testTwoFacadesShareTheSameSession() { let config = PayabliConfig( accessToken: "shared-token", entryPoint: "demo", diff --git a/Tests/PayabliSDKTapToPayTests/PayabliTTPTests.swift b/Tests/PayabliSDKTapToPayTests/PayabliTTPTests.swift index d56ff59..c0d5164 100644 --- a/Tests/PayabliSDKTapToPayTests/PayabliTTPTests.swift +++ b/Tests/PayabliSDKTapToPayTests/PayabliTTPTests.swift @@ -1,11 +1,10 @@ -import XCTest import PayabliSDKCore @testable import PayabliSDKTapToPay import PayabliSDKTestUtils +import XCTest @MainActor final class PayabliTTPTests: XCTestCase { - override func setUp() { super.setUp() // Default config stub — individual tests may override. @@ -34,9 +33,12 @@ final class PayabliTTPTests: XCTestCase { "paymentToken": "payment_tok" ] let data = try JSONSerialization.data(withJSONObject: body) - return (HTTPURLResponse(url: request.url!, statusCode: 200, - httpVersion: "HTTP/1.1", - headerFields: ["Content-Type": "application/json"])!, data) + return (HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )!, data) } private func makeTTP( diff --git a/Tests/PayabliSDKTapToPayTests/SecureStorageTests.swift b/Tests/PayabliSDKTapToPayTests/SecureStorageTests.swift index b8f1f49..691dce9 100644 --- a/Tests/PayabliSDKTapToPayTests/SecureStorageTests.swift +++ b/Tests/PayabliSDKTapToPayTests/SecureStorageTests.swift @@ -1,9 +1,8 @@ -import XCTest @testable import PayabliSDKTapToPay import PayabliSDKTestUtils +import XCTest final class SecureStorageTests: XCTestCase { - // MARK: - InMemorySecureStorage func testInMemoryRoundTrip() throws { @@ -35,24 +34,24 @@ final class SecureStorageTests: XCTestCase { /// round-trip is covered by on-device QA (PRD §12.3). func testKeychainRoundTripIfAvailable() throws { #if os(macOS) && !targetEnvironment(simulator) - throw XCTSkip("Keychain services require a running keychaind; covered by device QA (§12.3).") + throw XCTSkip("Keychain services require a running keychaind; covered by device QA (§12.3).") #else - let storage = KeychainStorage(service: "com.payabli.tests.\(UUID().uuidString)") - defer { storage.removeAll() } + let storage = KeychainStorage(service: "com.payabli.tests.\(UUID().uuidString)") + defer { storage.removeAll() } - do { - try storage.set("hello_keychain", forKey: "sample_key") - } catch KeychainStorage.KeychainError.underlying(let status) { - throw XCTSkip(""" + do { + try storage.set("hello_keychain", forKey: "sample_key") + } catch let KeychainStorage.KeychainError.underlying(status) { + throw XCTSkip(""" Keychain unavailable in this test host (OSStatus \(status)); \ covered by device QA (§12.3). """) - } + } - XCTAssertEqual(storage.string(forKey: "sample_key"), "hello_keychain") + XCTAssertEqual(storage.string(forKey: "sample_key"), "hello_keychain") - storage.remove(forKey: "sample_key") - XCTAssertNil(storage.string(forKey: "sample_key")) + storage.remove(forKey: "sample_key") + XCTAssertNil(storage.string(forKey: "sample_key")) #endif } diff --git a/Tests/PayabliSDKTapToPayTests/SessionManagerTests.swift b/Tests/PayabliSDKTapToPayTests/SessionManagerTests.swift index 46c27a3..fef46c4 100644 --- a/Tests/PayabliSDKTapToPayTests/SessionManagerTests.swift +++ b/Tests/PayabliSDKTapToPayTests/SessionManagerTests.swift @@ -1,9 +1,8 @@ -import XCTest @testable import PayabliSDKTapToPay +import XCTest @MainActor final class SessionManagerTests: XCTestCase { - func testInitialStateIsIdle() { let sm = SessionManager() XCTAssertEqual(sm.sessionState, .idle) diff --git a/Tests/PayabliSDKTapToPayTests/TTPTransactionWireFormatTests.swift b/Tests/PayabliSDKTapToPayTests/TTPTransactionWireFormatTests.swift index 7d48e08..b7b9c88 100644 --- a/Tests/PayabliSDKTapToPayTests/TTPTransactionWireFormatTests.swift +++ b/Tests/PayabliSDKTapToPayTests/TTPTransactionWireFormatTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import PayabliSDKTapToPay +import XCTest /// Contract tests for the backend `PATCH /MoneyIn/update/{id}` wire format. /// The backend still expects the top-level key `fiservResponse`; the SDK's @@ -7,7 +7,6 @@ import XCTest /// `UpdateSuccessBody.CodingKeys`. These tests fail loudly if someone /// removes or changes the mapping without a coordinated backend rollout. final class TTPTransactionWireFormatTests: XCTestCase { - func test_updateSuccessBody_serializesOpaqueJSONUnderFiservResponseKey() throws { let innerJSON = Data(#"{"transactionId":"abc","status":"approved"}"#.utf8) let payload = ProviderResponsePayload.opaqueJSON(innerJSON) diff --git a/Tests/PayabliSDKTelemetryTests/PayabliSDKTelemetryTests.swift b/Tests/PayabliSDKTelemetryTests/PayabliSDKTelemetryTests.swift index 05e67fa..fe06ae6 100644 --- a/Tests/PayabliSDKTelemetryTests/PayabliSDKTelemetryTests.swift +++ b/Tests/PayabliSDKTelemetryTests/PayabliSDKTelemetryTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import PayabliSDKTelemetry +import XCTest final class PayabliSDKTelemetryTests: XCTestCase { func testVersionIsPopulated() { diff --git a/Tests/PayabliSDKTelemetryTests/TelemetryTransportTests.swift b/Tests/PayabliSDKTelemetryTests/TelemetryTransportTests.swift index 2a5eec0..691bce7 100644 --- a/Tests/PayabliSDKTelemetryTests/TelemetryTransportTests.swift +++ b/Tests/PayabliSDKTelemetryTests/TelemetryTransportTests.swift @@ -1,9 +1,8 @@ -import XCTest import PayabliSDKCore @testable import PayabliSDKTelemetry +import XCTest final class TelemetryTransportTests: XCTestCase { - // MARK: - Sentry bridge final class CapturingSentryBridge: PayabliSentryBridge, @unchecked Sendable { @@ -12,6 +11,7 @@ final class TelemetryTransportTests: XCTestCase { func addBreadcrumb(_ category: String, data: [String: Any]) { breadcrumbs.append((category, data)) } + func captureError(_ message: String, tags: [String: String], extra: [String: Any]) { errors.append((message, tags, extra)) } @@ -21,10 +21,24 @@ final class TelemetryTransportTests: XCTestCase { let bridge = CapturingSentryBridge() let transport = SentryTelemetryTransport(bridge: bridge) await transport.send([ - TelemetryEvent(sdkVersion: "1", sessionId: "s", deviceIdHash: nil, entry: "e", environment: "sandbox", - event: "ttp.charge.failed", properties: ["errorCode": "X"]), - TelemetryEvent(sdkVersion: "1", sessionId: "s", deviceIdHash: nil, entry: "e", environment: "sandbox", - event: "ttp.charge.started", properties: ["amount": "10"]) + TelemetryEvent( + sdkVersion: "1", + sessionId: "s", + deviceIdHash: nil, + entry: "e", + environment: "sandbox", + event: "ttp.charge.failed", + properties: ["errorCode": "X"] + ), + TelemetryEvent( + sdkVersion: "1", + sessionId: "s", + deviceIdHash: nil, + entry: "e", + environment: "sandbox", + event: "ttp.charge.started", + properties: ["amount": "10"] + ) ]) XCTAssertEqual(bridge.errors.count, 1) XCTAssertEqual(bridge.errors.first?.0, "ttp.charge.failed") @@ -40,15 +54,25 @@ final class TelemetryTransportTests: XCTestCase { func capture(_ event: String, distinctId: String, properties: [String: Any]) { captured.append((event, distinctId, properties)) } - func flush() { flushCount += 1 } + + func flush() { + flushCount += 1 + } } func testPostHogTransportCapturesWithHashedEntryAsDistinctId() async { let bridge = CapturingPostHogBridge() let transport = PostHogTelemetryTransport(bridge: bridge) await transport.send([ - TelemetryEvent(sdkVersion: "1", sessionId: "s", deviceIdHash: nil, entry: "partner_entry", - environment: "sandbox", event: "tokenization.started", properties: ["method": "card"]) + TelemetryEvent( + sdkVersion: "1", + sessionId: "s", + deviceIdHash: nil, + entry: "partner_entry", + environment: "sandbox", + event: "tokenization.started", + properties: ["method": "card"] + ) ]) XCTAssertEqual(bridge.captured.count, 1) let (name, distinctId, props) = bridge.captured.first! diff --git a/Tests/PayabliSDKTestUtilsTests/PayabliSDKTestUtilsTests.swift b/Tests/PayabliSDKTestUtilsTests/PayabliSDKTestUtilsTests.swift index e731013..f4eae72 100644 --- a/Tests/PayabliSDKTestUtilsTests/PayabliSDKTestUtilsTests.swift +++ b/Tests/PayabliSDKTestUtilsTests/PayabliSDKTestUtilsTests.swift @@ -1,5 +1,5 @@ -import XCTest import PayabliSDKTestUtils +import XCTest final class PayabliSDKTestUtilsTests: XCTestCase { func testStubURLProtocolMakeSessionReturnsConfiguredSession() { From 12ee549ed9196e236b9c0917184375a4f4192006 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Mon, 10 Aug 2026 19:49:06 -0700 Subject: [PATCH 04/57] Lint and analyse in CI, and run the demo's step sequences there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI ran one xcodebuild and nothing else. It now runs, in order: swiftlint and swiftformat --lint, the SDK suite, the demo's step sequences, and SonarCloud. swiftlint is invoked with no --config. Naming a config file makes SwiftLint ignore nested ones, and Tests/.swiftlint.yml is what relaxes the rules XCTest fixtures break: with --config the count goes from 29 warnings to 104 with one serious, and the step fails. The config's own header claimed CI already ran `swiftlint --strict`; it ran no lint at all, and --strict exits 2 on the warnings in the tree today. The header now says what CI does. The demo's tests need their own scheme and their own invocation: the bundle has no host application, because Secrets.swift is gitignored and belongs to the app target. Proved by running it with Secrets.swift moved aside. Sonar follows sdk-android: SonarCloud, organization payabli, key payabli_sdk-ios. Both prerequisites are already in place, checked rather than assumed — the project is live under that key and SONAR_TOKEN has been a repository secret since 28 July. Swift coverage has no native importer, so Scripts/xccov-to-sonarqube-generic.sh converts both .xcresult bundles into the generic format — 120 files, 16,142 lines, 12,393 covered. It exits non-zero when it finds no covered files, because an empty report reaches Sonar as 0% coverage and reads like a measurement. Paths are repo-relative so the report survives the hand-off between jobs. Coverage exclusions mirror Android's reasoning: a SwiftUI view needs a rendering pass, so the sample app's view directories are excluded and Flow/ is not. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 67 ++++++++++++++++++++++++++- .gitignore | 4 ++ .swiftlint.yml | 6 +-- Scripts/xccov-to-sonarqube-generic.sh | 62 +++++++++++++++++++++++++ sonar-project.properties | 37 +++++++++++++++ 5 files changed, 172 insertions(+), 4 deletions(-) create mode 100755 Scripts/xccov-to-sonarqube-generic.sh create mode 100644 sonar-project.properties diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53e0cb4..850db7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,13 +30,21 @@ jobs: - name: Install xcpretty run: gem install xcpretty --no-document + # No `--config`: naming a config file makes SwiftLint ignore nested ones, + # and Tests/.swiftlint.yml is what relaxes the rules XCTest fixtures break. + - name: Lint + run: | + brew install swiftlint swiftformat + swiftlint + swiftformat --lint . + - name: Resolve dependencies run: | xcodebuild -resolvePackageDependencies \ -scheme PayabliSDK-Package \ -clonedSourcePackagesDirPath .build/checkouts - - name: Test + - name: Select simulator run: | SIMULATOR_ID=$(xcrun simctl list devices available -j \ | python3 -c " @@ -54,9 +62,66 @@ jobs: print(sorted(iphones, key=lambda x: x['name'])[-1]['udid']) ") echo "Using simulator: $SIMULATOR_ID" + echo "SIMULATOR_ID=$SIMULATOR_ID" >> "$GITHUB_ENV" + + - name: Test the SDK + run: | xcodebuild test \ -scheme PayabliSDK-Package \ -destination "platform=iOS Simulator,id=$SIMULATOR_ID" \ -clonedSourcePackagesDirPath .build/checkouts \ + -enableCodeCoverage YES \ + -resultBundlePath SDKTests.xcresult \ + CODE_SIGNING_ALLOWED=NO \ + | xcpretty && exit ${PIPESTATUS[0]} + + # The sample app's step sequences, which decide what each screen offers + # next. A separate scheme, because this bundle has no host application: + # Secrets.swift is gitignored and is a member of the app target, so the app + # itself cannot be built here. + - name: Test the sample app's step sequences + run: | + xcodebuild test \ + -project Example/PayabliDemo/PayabliDemo.xcodeproj \ + -scheme PayabliDemoFlowTests \ + -destination "platform=iOS Simulator,id=$SIMULATOR_ID" \ + -clonedSourcePackagesDirPath .build/checkouts \ + -enableCodeCoverage YES \ + -resultBundlePath DemoFlowTests.xcresult \ CODE_SIGNING_ALLOWED=NO \ | xcpretty && exit ${PIPESTATUS[0]} + + - name: Convert coverage + run: | + ./Scripts/xccov-to-sonarqube-generic.sh \ + SDKTests.xcresult DemoFlowTests.xcresult > coverage.xml + + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: coverage + path: coverage.xml + + sonar: + name: SonarCloud + runs-on: macos-15 + needs: [test] + # A fork's pull request has no access to the token, and the scan would fail + # rather than be skipped. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + steps: + # Analysis reads the git history to decide what is new code. + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download coverage + uses: actions/download-artifact@v4 + with: + name: coverage + + - name: Analyze + uses: SonarSource/sonarqube-scan-action@v5 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.gitignore b/.gitignore index f2f7c92..6ba7644 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,7 @@ docs/ # Claude AI settings, and the worktree location every repo in this workspace uses. .claude/settings.local.json .claude/worktrees/ + +# Written by a test run with coverage, and by the script that feeds Sonar. +*.xcresult/ +coverage.xml diff --git a/.swiftlint.yml b/.swiftlint.yml index 2fa9748..6672774 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -6,9 +6,9 @@ # relaxes rules that conflict with idiomatic XCTest patterns (force # unwraps on fixtures, large fixture tuples, etc.). # -# CI runs `swiftlint --strict`, so every warning is treated as an error. -# Keep warning thresholds calibrated to the real shape of the codebase so -# strict mode stays useful instead of getting disabled. +# CI runs `swiftlint`, which fails on a serious violation and reports the rest. +# `--strict`, which would treat every warning as an error, exits 2 on the 29 +# warnings currently in the tree. disabled_rules: - trailing_whitespace diff --git a/Scripts/xccov-to-sonarqube-generic.sh b/Scripts/xccov-to-sonarqube-generic.sh new file mode 100755 index 0000000..508a02b --- /dev/null +++ b/Scripts/xccov-to-sonarqube-generic.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# Converts an .xcresult coverage report into SonarQube's generic test coverage +# format, which is what `sonar.coverageReportPaths` reads. +# +# ./Scripts/xccov-to-sonarqube-generic.sh TestResults.xcresult > coverage.xml +# +# Adapted from SonarSource's reference script for Xcode projects. Nothing else +# reads xccov, so a change to Xcode's output shows up here first: the script +# exits non-zero if it produced no element, rather than handing Sonar an +# empty report that reads as zero coverage. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +function convert_file { + local xccovarchive_file="$1" + local file_name="$2" + local xccov_options="$3" + + # Relative to the repo root, so the report survives being written in one CI + # job and read in another. + local relative_name="${file_name#"$REPO_ROOT"/}" + + echo " " + xcrun xccov view $xccov_options --file "$file_name" "$xccovarchive_file" \ + | sed -n ' + s/^ *\([0-9][0-9]*\): *0.*$/ /p; + s/^ *\([0-9][0-9]*\): *[1-9].*$/ /p + ' + echo ' ' +} + +function xccov_to_generic { + local files=0 + echo '' + for xcresult in "$@"; do + local xccov_options="" + if [[ $xcresult == *".xcresult"* ]]; then + xccov_options="--archive" + fi + while read -r file_name; do + [ -z "$file_name" ] && continue + convert_file "$xcresult" "$file_name" "$xccov_options" + files=$((files + 1)) + done < <(xcrun xccov view $xccov_options --file-list "$xcresult") + done + echo '' + + if [ "$files" -eq 0 ]; then + echo "error: no covered files found in $*" >&2 + return 1 + fi +} + +if [ $# -eq 0 ]; then + echo "usage: $0 [...]" >&2 + exit 2 +fi + +xccov_to_generic "$@" diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..bda9495 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,37 @@ +sonar.projectKey=payabli_sdk-ios +sonar.organization=payabli +sonar.host.url=https://sonarcloud.io +sonar.sourceEncoding=UTF-8 + +sonar.sources=Sources,Example/PayabliDemo +sonar.tests=Tests,Example/PayabliDemo/FlowTests + +# Vendored Fiserv/TTPPackage source, byte-identical to upstream. Analysing it +# would report issues nobody here may fix: the vendoring contract in +# ThirdParty/PayabliCardReaderCoreSource/README.md forbids editing the file. +# +# Bridges/ holds the Flutter, MAUI and React Native wrappers, which SPM does not +# build and CI does not compile. +sonar.exclusions=ThirdParty/**,Bridges/**,**/build/**,.build/** + +# Coverage only, so these still get issue detection. +# +# A SwiftUI View needs a rendering pass, so no unit test reaches its body, and +# the sample app is views apart from Flow/. Flow/ is where every step decision +# lives and is covered by PayabliDemoFlowTests; the directories below are what +# is left, which is why this is a directory rule rather than a list of files. +# The SDK's own form component is the same case. +sonar.coverage.exclusions=\ + Example/PayabliDemo/App/**,\ + Example/PayabliDemo/Configuration/**,\ + Example/PayabliDemo/Debug/**,\ + Example/PayabliDemo/Diagnostics/**,\ + Example/PayabliDemo/PayIn/**,\ + Example/PayabliDemo/Shared/**,\ + Example/PayabliDemo/TapToPay/**,\ + Example/PayabliDemo/Theme/**,\ + Sources/PayabliSDKPayInPaymentFlow/**View.swift,\ + Sources/PayabliSDKTestUtils/** + +# Written by Scripts/xccov-to-sonarqube-generic.sh from both .xcresult bundles. +sonar.coverageReportPaths=coverage.xml From 3aee13cc977874b65e689e439028807ded64320a Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Mon, 10 Aug 2026 20:24:19 -0700 Subject: [PATCH 05/57] Client(iOS) - Let the activation step report the failure the session records for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `activateDevice` calls markError when it is refused, so the session reads `.error` for a failure that belongs to activation. The enable step took that `.error` as its own, and the guard added in the previous commit then blocked activation — hiding the reason and the retry, which is what the code this replaced was avoiding by reading the outcome first. The enable step now hands the `.error` on when an activation failure is recorded. Expiry is not activation's doing, so `.sessionExpired` still belongs to enable whatever a stale outcome says, and a test holds that line. Three tests, two of which fail before this: the point case, an invariant over every `.error` combination that a recorded activation failure is answered by the activation step, and the expiry case that stops the fix over-reaching. The 144-combination sweep did not catch this. Its invariants asked whether two steps could speak at once, never whether a recorded failure still had a voice. Co-Authored-By: Claude Opus 5 (1M context) --- Example/PayabliDemo/Flow/TapToPaySteps.swift | 12 ++++-- .../FlowTests/TapToPayStepsTests.swift | 39 +++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/Example/PayabliDemo/Flow/TapToPaySteps.swift b/Example/PayabliDemo/Flow/TapToPaySteps.swift index 58ec271..eeb2495 100644 --- a/Example/PayabliDemo/Flow/TapToPaySteps.swift +++ b/Example/PayabliDemo/Flow/TapToPaySteps.swift @@ -65,7 +65,13 @@ enum TapToPaySteps { case .attestingDevice, .fetchingConfig, .initializingReader, .reinitializing: return .inProgress // Activation is a separate step, so reaching it means this one finished. case .pendingActivation: return .done - case .error, .sessionExpired: return .failed + // `activateDevice` calls markError when it is refused, so the session + // reads `.error` for a failure that belongs to the step after this + // one. Taking it here would block activation, which is where the + // reason and the retry are rendered. Expiry is not activation's + // doing, so a stale outcome does not move it. + case .error: return outcome == .activationFailed ? .done : .failed + case .sessionExpired: return .failed case .idle: return .current @unknown default: return .current } @@ -84,8 +90,8 @@ enum TapToPaySteps { if session == .ready { return .notNeeded } - // `.ready` and `.pendingActivation` are the two states that finish the - // step before, so this is `.pendingActivation`. + // What is left is `.pendingActivation`, or the `.error` the step + // before handed on because a refused activation put it there. return outcome == .activationFailed ? .failed : .current }() diff --git a/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift b/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift index 56e192a..8139330 100644 --- a/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift +++ b/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift @@ -159,6 +159,45 @@ final class TapToPayStepsTests: XCTestCase { XCTAssertEqual(sequence.charge.status, .blocked) } + func testARefusedActivationIsReportedByTheActivationStepWhenTheSessionRecordsTheError() { + // `activateDevice` calls markError on failure, so the session reads + // `.error` while the step that failed is activation. Blaming the enable + // step for it blocks activation, which is where the reason and the retry + // are rendered. + let sequence = TapToPaySteps.forCharging( + tokenCheck: .reachable, session: .error, activation: .activationFailed + ) + XCTAssertEqual(sequence.enable.status, .done) + XCTAssertEqual(sequence.activation.status, .failed) + XCTAssertTrue(sequence.activation.status.showsContent) + XCTAssertEqual(sequence.charge.status, .blocked) + } + + func testNoRecordedActivationFailureIsAnsweredByAnEarlierStep() { + for combination in everyCombination + where combination.outcome == .activationFailed && combination.session == .error + { + let sequence = steps(combination) + // A probe that failed legitimately holds the whole sequence. + guard sequence.token.status.isFinished else { continue } + XCTAssertEqual( + sequence.activation.status, .failed, + "\(combination) answered an activation failure somewhere else" + ) + XCTAssertTrue(sequence.activation.status.showsContent, "\(combination)") + } + } + + func testAnExpiredSessionIsStillTheEnableStepsFailure() { + // Session expiry is not activation's doing, so a stale outcome must not + // move that failure onto the step after it. + let sequence = TapToPaySteps.forCharging( + tokenCheck: .reachable, session: .sessionExpired, activation: .activationFailed + ) + XCTAssertEqual(sequence.enable.status, .failed) + XCTAssertEqual(sequence.activation.status, .blocked) + } + func testARefusedActivationKeepsItsOwnStepAndBlocksTheCharge() { let sequence = TapToPaySteps.forCharging( tokenCheck: .reachable, session: .pendingActivation, activation: .activationFailed From 9bfeee69e7fde99498e09be9aea7ab8278cd5702 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Mon, 10 Aug 2026 20:24:37 -0700 Subject: [PATCH 06/57] Stop Sonar indexing the demo's tests as both source and test The scanner failed the run outright: ERROR File Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift can't be indexed twice. Please check that inclusion/exclusion patterns produce disjoint sets for main and test files FlowTests lives inside Example/PayabliDemo, which sonar.sources names, while sonar.tests names it too. Excluding it from sources leaves sonar.tests to claim it. Tests/ needed no equivalent because it is not under a source root. Co-Authored-By: Claude Opus 5 (1M context) --- sonar-project.properties | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/sonar-project.properties b/sonar-project.properties index bda9495..d691fbe 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -12,7 +12,12 @@ sonar.tests=Tests,Example/PayabliDemo/FlowTests # # Bridges/ holds the Flutter, MAUI and React Native wrappers, which SPM does not # build and CI does not compile. -sonar.exclusions=ThirdParty/**,Bridges/**,**/build/**,.build/** +# +# FlowTests sits inside Example/PayabliDemo, so sonar.sources above would claim +# it as main code while sonar.tests below claims it as test code, and the scanner +# refuses to index a file twice. Excluding it from sources leaves sonar.tests to +# pick it up. Tests/ needs no such line because it is not under a source root. +sonar.exclusions=ThirdParty/**,Bridges/**,**/build/**,.build/**,Example/PayabliDemo/FlowTests/** # Coverage only, so these still get issue detection. # From 0558f4644fc92cff0a1005daf30c073a2c094a83 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Mon, 10 Aug 2026 20:27:35 -0700 Subject: [PATCH 07/57] Keep the customer and the processor response out of the public log PayabliLogger's single-argument overload renders the whole message `.public`; the two-argument `info(_:private:)` is what marks a value `.private`. Three call sites used the first for data the logging contract names as never-log. PayabliTTP+Charge.swift first name, last name, customerNumber, customerId and company on the charge-start line. They move to the `private:` call the same function already makes for the rest of the customer's details. FiservCardReader.swift the same fields again, on a second line that duplicated the first. Removed rather than moved: the line above it already carries the invoice number, which is the part that is safe to publish. FiservCardReader.swift the pretty-printed CommerceHubResponse. That body carries paymentTokens.tokenData and the card's expiry. Removed, with prettyPrintJSON, which had no other caller. The summary line above it reports elapsed time, byte count and card network, which is what diagnosing a charge actually needs. Pre-existing, and surfaced because this branch reformatted the files. Co-Authored-By: Claude Opus 5 (1M context) --- .../Adapters/FiservCardReader.swift | 32 +++++-------------- .../PayabliTTP+Charge.swift | 15 +++++---- 2 files changed, 17 insertions(+), 30 deletions(-) diff --git a/Sources/PayabliSDKTapToPay/Adapters/FiservCardReader.swift b/Sources/PayabliSDKTapToPay/Adapters/FiservCardReader.swift index 1f735bd..fff0a82 100644 --- a/Sources/PayabliSDKTapToPay/Adapters/FiservCardReader.swift +++ b/Sources/PayabliSDKTapToPay/Adapters/FiservCardReader.swift @@ -197,14 +197,10 @@ public final class FiservCardReader: TapToPayProvider, @unchecked Sendable { "merchantOrderId=\(request.merchantOrderId ?? "") " + "invoice=\(request.merchantInvoiceNumber ?? "")" ) - logger.info( - "[fiserv.charges] customer={firstName=\(request.customer.firstName ?? "") " + - "lastName=\(request.customer.lastName ?? "") " + - "customerNumber=\(request.customer.customerNumber ?? "")} " + - "invoice={invoiceNumber=\(request.invoice.invoiceNumber ?? "")}" - ) - // The atomic card-reader API has no slot for customer data; it - // ships only at /initiate and in the logs above. + // The atomic card-reader API has no slot for customer data; it ships + // at /initiate only. It is not logged here: this logger renders a + // single-argument message `.public`, and the invoice number the line + // above carries is the part that is safe to publish. let started = Date() let response: Models.CommerceHubResponse @@ -221,10 +217,11 @@ public final class FiservCardReader: TapToPayProvider, @unchecked Sendable { let elapsedMs = Int(Date().timeIntervalSince(started) * 1000) let responseJSON = try Self.encode(response) let cardNetwork = Self.extractCardNetwork(from: responseJSON) + // Shape, not contents. `CommerceHubResponse` carries + // `paymentTokens.tokenData` and the card's expiry, so the body never + // reaches the log; elapsed time, size and network are what a reader + // diagnosing a charge actually needs. logger.info("[fiserv.charges] ← OK (\(elapsedMs)ms) bytes=\(responseJSON.count) cardNetwork=\(cardNetwork ?? "")") - if let pretty = Self.prettyPrintJSON(responseJSON) { - logger.info("[fiserv.charges] responseBody:\n\(pretty)") - } return CardReadResult( provider: Self.providerId, encryptedPayload: Data(), @@ -313,19 +310,6 @@ public final class FiservCardReader: TapToPayProvider, @unchecked Sendable { } } - /// Re-encodes `json` with `.prettyPrinted` + `.sortedKeys` for log output. - /// Returns `nil` if `json` isn't valid JSON. - private static func prettyPrintJSON(_ json: Data) -> String? { - guard - let obj = try? JSONSerialization.jsonObject(with: json), - let pretty = try? JSONSerialization.data( - withJSONObject: obj, - options: [.prettyPrinted, .sortedKeys] - ) - else { return nil } - return String(data: pretty, encoding: .utf8) - } - /// Pulls `card.brand` out of the CommerceHub response for /// `CardReadResult.cardNetwork`. Tolerates minor schema drift. private static func extractCardNetwork(from json: Data) -> String? { diff --git a/Sources/PayabliSDKTapToPay/PayabliTTP+Charge.swift b/Sources/PayabliSDKTapToPay/PayabliTTP+Charge.swift index 0ddbbc3..631d86c 100644 --- a/Sources/PayabliSDKTapToPay/PayabliTTP+Charge.swift +++ b/Sources/PayabliSDKTapToPay/PayabliTTP+Charge.swift @@ -254,20 +254,23 @@ extension PayabliTTP { invoice: PayabliTTPInvoiceData, orderDescription: String? ) { + // Charge metadata only. The single-argument overload renders the whole + // string `.public`, so anything naming the customer belongs in the + // `private:` call below rather than here. logger.info( "[charge] → amount=\(paymentDetails.amount) serviceFee=\(paymentDetails.serviceFee) " + "currency=\(paymentDetails.currency ?? "") " + - "customer={firstName=\(customer.firstName ?? "") " + - "lastName=\(customer.lastName ?? "") " + - "customerNumber=\(customer.customerNumber ?? "") " + - "customerId=\(customer.customerId.map(String.init) ?? "") " + - "company=\(customer.company ?? "")} " + "invoice={invoiceNumber=\(invoice.invoiceNumber ?? "")} " + "orderDescription=\(orderDescription ?? "")" ) guard !customer.isEmpty else { return } - let pii = "email=\(customer.email ?? "") " + + let pii = "firstName=\(customer.firstName ?? "") " + + "lastName=\(customer.lastName ?? "") " + + "customerNumber=\(customer.customerNumber ?? "") " + + "customerId=\(customer.customerId.map(String.init) ?? "") " + + "company=\(customer.company ?? "") " + + "email=\(customer.email ?? "") " + "phone=\(customer.phone ?? "") " + "billing.address1=\(customer.billingAddress1 ?? "") " + "billing.address2=\(customer.billingAddress2 ?? "") " + From c94ec79e246469d0983513297d81453a5d0eb1e0 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Mon, 10 Aug 2026 20:28:30 -0700 Subject: [PATCH 08/57] Stop publishing the card reader's credentials to the log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swept for the shape the review found, and this is the worst instance of it. `/config` returns ConfigCredentialsPayload, whose credentials block becomes FiservCardReader.Credentials — secretKey and apiKey among them — and the whole body went to the log through the overload that renders it `.public`. Status and byte count replace it, as on the charge response. Not the headers line above it, which was checked and is not the same problem: the bearer is added by AuthenticatedTransport after this point, so those headers hold the App Attest assertion rather than a credential. Co-Authored-By: Claude Opus 5 (1M context) --- Sources/PayabliSDKTapToPay/TTPConfigClient.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/PayabliSDKTapToPay/TTPConfigClient.swift b/Sources/PayabliSDKTapToPay/TTPConfigClient.swift index 9e5e47c..3a3c628 100644 --- a/Sources/PayabliSDKTapToPay/TTPConfigClient.swift +++ b/Sources/PayabliSDKTapToPay/TTPConfigClient.swift @@ -56,8 +56,9 @@ public final class TTPConfigClient: Sendable { let response = try await transport.perform(request) - let responseBody = String(data: response.body, encoding: .utf8) ?? "" - logger.info("[config] ← [\(response.statusCode)] body: \(responseBody)") + // Shape, not contents. This body is `ConfigCredentialsPayload`, whose + // `credentials` block carries the card reader's secretKey and apiKey. + logger.info("[config] ← [\(response.statusCode)] bytes=\(response.body.count)") try mapPayabliHTTPError(response: response) { code in if code == 403 { From 91c8d685a5f0bccd6c6e791345642333a80df2fa Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Mon, 10 Aug 2026 20:52:28 -0700 Subject: [PATCH 09/57] Client(iOS) - Offer the activation code only where the SDK accepts it `activateDevice` throws `.invalidState` unless the session is `.pendingActivation`, and a refused activation leaves it `.error`. The activation step rendered its code control there anyway, beside the Re-initialize the same session state puts on screen: two next actions, one of which throws on the first tap. The sequence now carries `acceptsActivationCode` and `offersRecovery`, and the screen reads both. The failure still reports its reason; what goes away is the control that cannot run. Recovery was deciding this for itself in the view, which is why no test could see it. Two invariants over the 144 combinations: the code control appears only for `.pendingActivation`, and never alongside Re-initialize. Co-Authored-By: Claude Opus 5 (1M context) --- Example/PayabliDemo/Flow/TapToPaySteps.swift | 17 +++++++++++- .../FlowTests/TapToPayStepsTests.swift | 27 +++++++++++++++++++ .../TapToPay/PaymentTapToPayQAView.swift | 25 +++++++++-------- 3 files changed, 55 insertions(+), 14 deletions(-) diff --git a/Example/PayabliDemo/Flow/TapToPaySteps.swift b/Example/PayabliDemo/Flow/TapToPaySteps.swift index eeb2495..943562f 100644 --- a/Example/PayabliDemo/Flow/TapToPaySteps.swift +++ b/Example/PayabliDemo/Flow/TapToPaySteps.swift @@ -19,6 +19,19 @@ struct TapToPayFlowSteps { let activation: FlowStep let charge: FlowStep + /// Whether the activation step offers its code control. + /// + /// `activateDevice` throws `.invalidState` for any session but + /// `.pendingActivation`, and a refused activation leaves the session + /// `.error`. The step still reports the failure; what it stops offering is a + /// control the SDK would reject, which the screen shows beside the + /// Re-initialize the same session state puts on screen. + let acceptsActivationCode: Bool + + /// Whether the screen offers Re-initialize. An action outside the sequence, + /// so the steps have to know it exists. + let offersRecovery: Bool + var all: [FlowStep] { [token, enable, activation, charge] } @@ -123,7 +136,9 @@ enum TapToPaySteps { title: "Charge a card", detail: "Presents Apple's Tap to Pay sheet. Hold a card to the top of the phone.", status: charge - ) + ), + acceptsActivationCode: session == .pendingActivation, + offersRecovery: session == .error || session == .sessionExpired ) } } diff --git a/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift b/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift index 8139330..b1d606f 100644 --- a/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift +++ b/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift @@ -103,6 +103,29 @@ final class TapToPayStepsTests: XCTestCase { } } + func testTheActivationCodeIsOfferedOnlyWhereTheSDKAcceptsIt() { + // `activateDevice` throws `.invalidState` for any session but + // `.pendingActivation`, so offering the control anywhere else hands over + // a button that cannot work. + for combination in everyCombination where steps(combination).acceptsActivationCode { + XCTAssertEqual( + combination.session, .pendingActivation, + "\(combination) offered the activation code" + ) + } + } + + func testRecoveryIsNeverOfferedBesideAControlThatCannotRun() { + for combination in everyCombination { + let sequence = steps(combination) + guard sequence.offersRecovery else { continue } + XCTAssertFalse( + sequence.acceptsActivationCode, + "\(combination) offered Re-initialize and the activation code together" + ) + } + } + func testEveryStepSaysWhatItIs() { for combination in everyCombination { let all = steps(combination).all @@ -171,6 +194,10 @@ final class TapToPayStepsTests: XCTestCase { XCTAssertEqual(sequence.activation.status, .failed) XCTAssertTrue(sequence.activation.status.showsContent) XCTAssertEqual(sequence.charge.status, .blocked) + // The reason shows; the control that would throw does not, and Recovery + // is the one way forward. + XCTAssertFalse(sequence.acceptsActivationCode) + XCTAssertTrue(sequence.offersRecovery) } func testNoRecordedActivationFailureIsAnsweredByAnEarlierStep() { diff --git a/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift b/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift index 918a424..082fe56 100644 --- a/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift +++ b/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift @@ -114,12 +114,18 @@ struct PaymentTapToPayQAView: View { StepRow(index: 3, step: steps.activation) { VStack(alignment: .leading, spacing: 6) { - Button { isActivationPresented = true } label: { - Label("Enter activation code", systemImage: "checkmark.shield") - .frame(maxWidth: .infinity) + // `activateDevice` throws `.invalidState` unless the session + // is `.pendingActivation`, so a refused activation reports + // its reason without the control that would throw. The + // recovery section owns the way forward from there. + if steps.acceptsActivationCode { + Button { isActivationPresented = true } label: { + Label("Enter activation code", systemImage: "checkmark.shield") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(isWorking) } - .buttonStyle(.borderedProminent) - .disabled(isWorking) stepOutcome(activationMessage) } } @@ -149,7 +155,7 @@ struct PaymentTapToPayQAView: View { /// is in a state it can actually repair. @ViewBuilder private var recoverySection: some View { - if isRecoverable { + if steps.offersRecovery { VStack(alignment: .leading, spacing: 8) { Text("Recovery") .font(.headline) @@ -169,13 +175,6 @@ struct PaymentTapToPayQAView: View { } } - private var isRecoverable: Bool { - switch terminal.sessionState { - case .sessionExpired, .error: return true - default: return false - } - } - private var activationSheet: some View { NavigationStack { Form { From f46158533344a3b5e0b1c9b9d9a54bb1d110b805 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Mon, 10 Aug 2026 20:53:33 -0700 Subject: [PATCH 10/57] Say what the code does, without arguing with the alternative Six comments stated a fact and then defended it against a choice nobody had proposed: "Shape, not contents", "rather than here", "not the session", "not from `hasResult`". The fact is the part that survives; the contrast reads as a reply to a reviewer who is not there. Co-Authored-By: Claude Opus 5 (1M context) --- Example/PayabliDemo/Flow/PayInSteps.swift | 4 ++-- Example/PayabliDemo/Flow/TapToPaySteps.swift | 2 +- .../Adapters/FiservCardReader.swift | 12 +++++------- Sources/PayabliSDKTapToPay/PayabliTTP+Charge.swift | 6 +++--- Sources/PayabliSDKTapToPay/TTPConfigClient.swift | 4 ++-- 5 files changed, 13 insertions(+), 15 deletions(-) diff --git a/Example/PayabliDemo/Flow/PayInSteps.swift b/Example/PayabliDemo/Flow/PayInSteps.swift index 329ebdd..c20be5c 100644 --- a/Example/PayabliDemo/Flow/PayInSteps.swift +++ b/Example/PayabliDemo/Flow/PayInSteps.swift @@ -81,8 +81,8 @@ enum PayInSteps { return showingFinishedResult ? .done : .current }() - // From the step before, not from `hasResult`, which can be true while the - // form is still asking for something. + // From the step before. `hasResult` can be true while the form is still + // asking for something. let result: StepStatus = form.isFinished ? .current : .blocked return PayInFlowSteps( diff --git a/Example/PayabliDemo/Flow/TapToPaySteps.swift b/Example/PayabliDemo/Flow/TapToPaySteps.swift index 943562f..4f91a61 100644 --- a/Example/PayabliDemo/Flow/TapToPaySteps.swift +++ b/Example/PayabliDemo/Flow/TapToPaySteps.swift @@ -108,7 +108,7 @@ enum TapToPaySteps { return outcome == .activationFailed ? .failed : .current }() - // From the step before, not the session. + // From the step before. let charge: StepStatus = { guard activation.isFinished else { return .blocked } return session == .ready ? .current : .blocked diff --git a/Sources/PayabliSDKTapToPay/Adapters/FiservCardReader.swift b/Sources/PayabliSDKTapToPay/Adapters/FiservCardReader.swift index fff0a82..e80360e 100644 --- a/Sources/PayabliSDKTapToPay/Adapters/FiservCardReader.swift +++ b/Sources/PayabliSDKTapToPay/Adapters/FiservCardReader.swift @@ -198,9 +198,9 @@ public final class FiservCardReader: TapToPayProvider, @unchecked Sendable { "invoice=\(request.merchantInvoiceNumber ?? "")" ) // The atomic card-reader API has no slot for customer data; it ships - // at /initiate only. It is not logged here: this logger renders a - // single-argument message `.public`, and the invoice number the line - // above carries is the part that is safe to publish. + // at /initiate only. A single-argument message renders `.public`, so + // the line above carries the invoice number and nothing naming the + // customer. let started = Date() let response: Models.CommerceHubResponse @@ -217,10 +217,8 @@ public final class FiservCardReader: TapToPayProvider, @unchecked Sendable { let elapsedMs = Int(Date().timeIntervalSince(started) * 1000) let responseJSON = try Self.encode(response) let cardNetwork = Self.extractCardNetwork(from: responseJSON) - // Shape, not contents. `CommerceHubResponse` carries - // `paymentTokens.tokenData` and the card's expiry, so the body never - // reaches the log; elapsed time, size and network are what a reader - // diagnosing a charge actually needs. + // `CommerceHubResponse` carries `paymentTokens.tokenData` and the + // card's expiry, so the log gets the shape of the response. logger.info("[fiserv.charges] ← OK (\(elapsedMs)ms) bytes=\(responseJSON.count) cardNetwork=\(cardNetwork ?? "")") return CardReadResult( provider: Self.providerId, diff --git a/Sources/PayabliSDKTapToPay/PayabliTTP+Charge.swift b/Sources/PayabliSDKTapToPay/PayabliTTP+Charge.swift index 631d86c..4a67192 100644 --- a/Sources/PayabliSDKTapToPay/PayabliTTP+Charge.swift +++ b/Sources/PayabliSDKTapToPay/PayabliTTP+Charge.swift @@ -254,9 +254,9 @@ extension PayabliTTP { invoice: PayabliTTPInvoiceData, orderDescription: String? ) { - // Charge metadata only. The single-argument overload renders the whole - // string `.public`, so anything naming the customer belongs in the - // `private:` call below rather than here. + // Charge metadata. The single-argument overload renders the whole string + // `.public`; anything naming the customer goes to the `private:` call + // below. logger.info( "[charge] → amount=\(paymentDetails.amount) serviceFee=\(paymentDetails.serviceFee) " + "currency=\(paymentDetails.currency ?? "") " + diff --git a/Sources/PayabliSDKTapToPay/TTPConfigClient.swift b/Sources/PayabliSDKTapToPay/TTPConfigClient.swift index 3a3c628..9423f45 100644 --- a/Sources/PayabliSDKTapToPay/TTPConfigClient.swift +++ b/Sources/PayabliSDKTapToPay/TTPConfigClient.swift @@ -56,8 +56,8 @@ public final class TTPConfigClient: Sendable { let response = try await transport.perform(request) - // Shape, not contents. This body is `ConfigCredentialsPayload`, whose - // `credentials` block carries the card reader's secretKey and apiKey. + // This body is `ConfigCredentialsPayload`, whose `credentials` block + // carries the card reader's secretKey and apiKey. The log gets its shape. logger.info("[config] ← [\(response.statusCode)] bytes=\(response.body.count)") try mapPayabliHTTPError(response: response) { code in From f65dad5a34872a9b686245724179ce9e138d6a5d Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Mon, 10 Aug 2026 21:07:31 -0700 Subject: [PATCH 11/57] Client(iOS) - Derive the one control the screen offers, and let the view read it Recovery was a flag of its own, keyed on the session alone. With a failed probe and a broken session the token step offered its retry while Recovery offered Re-initialize: two next actions, and Re-initialize re-runs config, which a backend known to be down cannot answer. That is the third variant of one defect in three rounds, each time from adding a boolean beside the steps instead of ordering the actions the way the steps are ordered. `acceptsActivationCode` and `offersRecovery` are replaced by `nextAction`, derived in the same pass and in the same order, and every control on the screen renders only when it is that action. A step still reports where it has got to; the two are separate because a step reports failures it cannot retry. A refused activation shows its reason while Re-initialize is the way forward. Four invariants over the 144 combinations: the activation code only for `.pendingActivation`, a charge only for `.ready`, Re-initialize only once the token step has finished, and a failed probe holding the action on the probe whatever the session says. Co-Authored-By: Claude Opus 5 (1M context) --- Example/PayabliDemo/Flow/TapToPaySteps.swift | 58 ++++++++++++++----- .../FlowTests/TapToPayStepsTests.swift | 46 ++++++++++----- .../TapToPay/PaymentTapToPayQAView.swift | 32 +++++----- 3 files changed, 94 insertions(+), 42 deletions(-) diff --git a/Example/PayabliDemo/Flow/TapToPaySteps.swift b/Example/PayabliDemo/Flow/TapToPaySteps.swift index 4f91a61..1c6e042 100644 --- a/Example/PayabliDemo/Flow/TapToPaySteps.swift +++ b/Example/PayabliDemo/Flow/TapToPaySteps.swift @@ -19,24 +19,34 @@ struct TapToPayFlowSteps { let activation: FlowStep let charge: FlowStep - /// Whether the activation step offers its code control. + /// The one control the screen offers, or none while the SDK is working. /// - /// `activateDevice` throws `.invalidState` for any session but - /// `.pendingActivation`, and a refused activation leaves the session - /// `.error`. The step still reports the failure; what it stops offering is a - /// control the SDK would reject, which the screen shows beside the - /// Re-initialize the same session state puts on screen. - let acceptsActivationCode: Bool - - /// Whether the screen offers Re-initialize. An action outside the sequence, - /// so the steps have to know it exists. - let offersRecovery: Bool + /// A step reports where it has got to; this says which control a person can + /// press. They are separate because a step reports a failure it cannot + /// retry: a refused activation leaves the session `.error`, and + /// `activateDevice` throws `.invalidState` for anything but + /// `.pendingActivation`, so the activation step shows the reason while + /// Re-initialize is the way forward. + /// + /// Recovery lives here for the same reason. It is not a step, and while it + /// was a flag of its own it could appear beside a step still asking for + /// something. + let nextAction: TapToPayAction? var all: [FlowStep] { [token, enable, activation, charge] } } +/// A control on the Tap to Pay screen. +enum TapToPayAction { + case checkToken + case enableTerminal + case enterActivationCode + case charge + case reinitialize +} + /// What taking a contactless payment asks for. enum TapToPaySteps { /// - Parameters: @@ -114,6 +124,29 @@ enum TapToPaySteps { return session == .ready ? .current : .blocked }() + // Ordered like the steps, and for the same reason: the first thing that + // wants attention is the only thing offered. Re-initialize sits behind + // the token step because it re-runs config, which a backend known to be + // down cannot answer. + let nextAction: TapToPayAction? = { + if token.isActionable { + return .checkToken + } + guard token.isFinished else { return nil } + if session == .error || session == .sessionExpired { + return .reinitialize + } + if enable == .current { + return .enableTerminal + } + guard enable.isFinished else { return nil } + if activation == .current { + return .enterActivationCode + } + guard activation.isFinished else { return nil } + return charge == .current ? .charge : nil + }() + return TapToPayFlowSteps( token: FlowStep( title: "Reach the token backend", @@ -137,8 +170,7 @@ enum TapToPaySteps { detail: "Presents Apple's Tap to Pay sheet. Hold a card to the top of the phone.", status: charge ), - acceptsActivationCode: session == .pendingActivation, - offersRecovery: session == .error || session == .sessionExpired + nextAction: nextAction ) } } diff --git a/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift b/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift index b1d606f..c876459 100644 --- a/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift +++ b/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift @@ -105,9 +105,10 @@ final class TapToPayStepsTests: XCTestCase { func testTheActivationCodeIsOfferedOnlyWhereTheSDKAcceptsIt() { // `activateDevice` throws `.invalidState` for any session but - // `.pendingActivation`, so offering the control anywhere else hands over - // a button that cannot work. - for combination in everyCombination where steps(combination).acceptsActivationCode { + // `.pendingActivation`. + for combination in everyCombination + where steps(combination).nextAction == .enterActivationCode + { XCTAssertEqual( combination.session, .pendingActivation, "\(combination) offered the activation code" @@ -115,13 +116,34 @@ final class TapToPayStepsTests: XCTestCase { } } - func testRecoveryIsNeverOfferedBesideAControlThatCannotRun() { - for combination in everyCombination { + func testAChargeIsOfferedOnlyByAReadyTerminal() { + for combination in everyCombination where steps(combination).nextAction == .charge { + XCTAssertEqual(combination.session, .ready, "\(combination) offered a charge") + } + } + + func testRecoveryWaitsForTheTokenStepLikeEverythingElse() { + // Re-initialize re-runs config, which a backend known to be down cannot + // answer, and offering it beside the probe's own retry is two next + // actions again. + for combination in everyCombination where steps(combination).nextAction == .reinitialize { let sequence = steps(combination) - guard sequence.offersRecovery else { continue } - XCTAssertFalse( - sequence.acceptsActivationCode, - "\(combination) offered Re-initialize and the activation code together" + XCTAssertTrue(sequence.token.status.isFinished, "\(combination) offered Re-initialize") + XCTAssertTrue( + combination.session == .error || combination.session == .sessionExpired, + "\(combination) offered Re-initialize" + ) + } + } + + func testAFailedProbeKeepsTheOnlyActionOnTheProbe() { + for session in everyTapToPaySession { + let sequence = TapToPaySteps.forCharging( + tokenCheck: .unreachable, session: session, activation: .activationFailed + ) + XCTAssertEqual( + sequence.nextAction, .checkToken, + "session \(sessionName(session)) moved past a failed probe" ) } } @@ -194,10 +216,8 @@ final class TapToPayStepsTests: XCTestCase { XCTAssertEqual(sequence.activation.status, .failed) XCTAssertTrue(sequence.activation.status.showsContent) XCTAssertEqual(sequence.charge.status, .blocked) - // The reason shows; the control that would throw does not, and Recovery - // is the one way forward. - XCTAssertFalse(sequence.acceptsActivationCode) - XCTAssertTrue(sequence.offersRecovery) + // The reason shows; Re-initialize is the way forward. + XCTAssertEqual(sequence.nextAction, .reinitialize) } func testNoRecordedActivationFailureIsAnsweredByAnEarlierStep() { diff --git a/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift b/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift index 082fe56..20d573a 100644 --- a/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift +++ b/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift @@ -102,23 +102,21 @@ struct PaymentTapToPayQAView: View { StepRow(index: 2, step: steps.enable) { VStack(alignment: .leading, spacing: 6) { - Button { runEnableTerminal() } label: { - Label("Enable Terminal", systemImage: "wave.3.right") - .frame(maxWidth: .infinity) + if steps.nextAction == .enableTerminal { + Button { runEnableTerminal() } label: { + Label("Enable Terminal", systemImage: "wave.3.right") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(isWorking) } - .buttonStyle(.borderedProminent) - .disabled(isWorking) stepOutcome(enableMessage) } } StepRow(index: 3, step: steps.activation) { VStack(alignment: .leading, spacing: 6) { - // `activateDevice` throws `.invalidState` unless the session - // is `.pendingActivation`, so a refused activation reports - // its reason without the control that would throw. The - // recovery section owns the way forward from there. - if steps.acceptsActivationCode { + if steps.nextAction == .enterActivationCode { Button { isActivationPresented = true } label: { Label("Enter activation code", systemImage: "checkmark.shield") .frame(maxWidth: .infinity) @@ -139,12 +137,14 @@ struct PaymentTapToPayQAView: View { .focused($focusedField, equals: .amount) .textFieldStyle(.roundedBorder) } - Button { runCharge() } label: { - Label("Charge (tap card)", systemImage: "creditcard.and.123") - .frame(maxWidth: .infinity) + if steps.nextAction == .charge { + Button { runCharge() } label: { + Label("Charge (tap card)", systemImage: "creditcard.and.123") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(isWorking) } - .buttonStyle(.borderedProminent) - .disabled(isWorking) stepOutcome(chargeMessage) } } @@ -155,7 +155,7 @@ struct PaymentTapToPayQAView: View { /// is in a state it can actually repair. @ViewBuilder private var recoverySection: some View { - if steps.offersRecovery { + if steps.nextAction == .reinitialize { VStack(alignment: .leading, spacing: 8) { Text("Recovery") .font(.headline) From 9b8ac657837d5b1a32baaf41b95e4af6bc67446e Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Mon, 10 Aug 2026 21:09:22 -0700 Subject: [PATCH 12/57] Log the shape of the attestation and update calls, never their contents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two request bodies and three header dumps went to the log through the overload that renders a message `.public`. AppAttestService+Requests every attestation call. `/activate` carries the activation code; the others carry the App Attest key, the attestation and the assertion. The response bodies went the same way. PayabliTTP+Charge the `/MoneyIn/update` request body, which is the provider's whole response — the same `paymentTokens.tokenData` and card expiry removed from the reader log one call earlier. TTPConfigClient, the assertion headers: `X-App-Assertion`, TTPTransactionClient `X-App-KeyId`, `X-Device-Id`. Endpoint, status and byte count remain. `[initiate] body` already used the `private:` overload and is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .../AppAttestService+Requests.swift | 15 +++++---------- .../PayabliSDKTapToPay/PayabliTTP+Charge.swift | 14 ++++---------- Sources/PayabliSDKTapToPay/TTPConfigClient.swift | 6 +----- .../PayabliSDKTapToPay/TTPTransactionClient.swift | 6 +----- 4 files changed, 11 insertions(+), 30 deletions(-) diff --git a/Sources/PayabliSDKTapToPay/AppAttestService+Requests.swift b/Sources/PayabliSDKTapToPay/AppAttestService+Requests.swift index 179577d..58a2e56 100644 --- a/Sources/PayabliSDKTapToPay/AppAttestService+Requests.swift +++ b/Sources/PayabliSDKTapToPay/AppAttestService+Requests.swift @@ -117,14 +117,10 @@ extension AppAttestService { jsonBody: body ) - let headersDump = request.headers - .map { "\($0.key): \($0.value)" } - .sorted() - .joined(separator: " | ") - let bodyDump = request.body.flatMap { String(data: $0, encoding: .utf8) } ?? "" - logger.info("[\(label)] → POST \(request.path)") - logger.info("[\(label)] headers: \(headersDump)") - logger.info("[\(label)] body: \(bodyDump)") + // These bodies and headers are authentication material: `/activate` + // carries the activation code, the others carry the App Attest key, + // attestation and assertion. Endpoint and size only. + logger.info("[\(label)] → POST \(request.path) bytes=\(request.body?.count ?? 0)") let response: PayabliResponse do { @@ -134,8 +130,7 @@ extension AppAttestService { throw error } - let responseBody = String(data: response.body, encoding: .utf8) ?? "" - logger.info("[\(label)] ← [\(response.statusCode)] body: \(responseBody)") + logger.info("[\(label)] ← [\(response.statusCode)] bytes=\(response.body.count)") do { try mapPayabliHTTPError(response: response) diff --git a/Sources/PayabliSDKTapToPay/PayabliTTP+Charge.swift b/Sources/PayabliSDKTapToPay/PayabliTTP+Charge.swift index 4a67192..3b74ed5 100644 --- a/Sources/PayabliSDKTapToPay/PayabliTTP+Charge.swift +++ b/Sources/PayabliSDKTapToPay/PayabliTTP+Charge.swift @@ -198,7 +198,6 @@ extension PayabliTTP { ) async -> TTPUpdateOutcome { let body = TTPTransactionClient.updateBody(for: payload) let logger = self.logger - let bodyDump = String(data: body, encoding: .utf8) ?? "" let path = "/api/v2/MoneyIn/update/\(paymentTransId)" let transport = self.session.transport @@ -209,16 +208,11 @@ extension PayabliTTP { headers: ["Content-Type": "application/json"], body: body ) - let headersDump = request.headers - .map { "\($0.key): \($0.value)" } - .sorted() - .joined(separator: " | ") - logger.info("[update/\(attempt)] → PATCH \(path)") - logger.info("[update/\(attempt)] headers: \(headersDump)") - logger.info("[update/\(attempt)] body: \(bodyDump)") + // The success body carries the provider's whole response, and with + // it `paymentTokens.tokenData` and the card's expiry. Size only. + logger.info("[update/\(attempt)] → PATCH \(path) bytes=\(body.count)") let response = try await transport.perform(request) - let responseBody = String(data: response.body, encoding: .utf8) ?? "" - logger.info("[update/\(attempt)] ← [\(response.statusCode)] body: \(responseBody)") + logger.info("[update/\(attempt)] ← [\(response.statusCode)] bytes=\(response.body.count)") return response } diff --git a/Sources/PayabliSDKTapToPay/TTPConfigClient.swift b/Sources/PayabliSDKTapToPay/TTPConfigClient.swift index 9423f45..702b042 100644 --- a/Sources/PayabliSDKTapToPay/TTPConfigClient.swift +++ b/Sources/PayabliSDKTapToPay/TTPConfigClient.swift @@ -47,12 +47,8 @@ public final class TTPConfigClient: Sendable { headers: headers.asDictionary ) - let headersDump = headers.asDictionary - .map { "\($0.key): \($0.value)" } - .sorted() - .joined(separator: " | ") + // The headers carry the App Attest assertion, key id and device id. logger.info("[config] → GET \(request.path)") - logger.info("[config] headers: \(headersDump)") let response = try await transport.perform(request) diff --git a/Sources/PayabliSDKTapToPay/TTPTransactionClient.swift b/Sources/PayabliSDKTapToPay/TTPTransactionClient.swift index 7158b9d..f72be25 100644 --- a/Sources/PayabliSDKTapToPay/TTPTransactionClient.swift +++ b/Sources/PayabliSDKTapToPay/TTPTransactionClient.swift @@ -47,13 +47,9 @@ public final class TTPTransactionClient: Sendable { jsonBody: body ) - let headersDump = request.headers - .map { "\($0.key): \($0.value)" } - .sorted() - .joined(separator: " | ") let bodyDump = request.body.flatMap { String(data: $0, encoding: .utf8) } ?? "" + // The headers carry the App Attest assertion, key id and device id. logger.info("[initiate] → POST \(request.path)") - logger.info("[initiate] headers: \(headersDump)") // Body carries PII (billing/shipping/email/phone) — keep redacted in // shared OS logs. logger.info("[initiate] body", private: bodyDump) From bcd36e5bd2fe9900b941d0be5fbeb487aef11255 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Mon, 10 Aug 2026 21:50:24 -0700 Subject: [PATCH 13/57] Client(iOS) - Let the enable step report the failure that follows an activation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Activating writes `.enableFailed` when the code is accepted and the `initialize()` after it is not, and it puts the reason in the enable step's message: "Activated. Enabling the terminal failed — see step 2." When `/config` answers 403 again the session goes back to `.pendingActivation`, which this step read as finished. Step 2 was `.done` and hid the message it had just been told to show, and step 3 asked for another code for a device already activated. `.pendingActivation` now finishes the enable step only when no enable failure is recorded, and the enable retry is offered from `.failed` as well as `.current`. Two tests: the point case, and every combination carrying `.enableFailed` past a finished token step. `.enableFailed` was listed as written-and-never-read when this branch started, and left that way on purpose. Reading it is what this needed. Co-Authored-By: Claude Opus 5 (1M context) --- Example/PayabliDemo/Flow/TapToPaySteps.swift | 13 +++++++--- .../FlowTests/TapToPayStepsTests.swift | 26 +++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/Example/PayabliDemo/Flow/TapToPaySteps.swift b/Example/PayabliDemo/Flow/TapToPaySteps.swift index 1c6e042..61f177f 100644 --- a/Example/PayabliDemo/Flow/TapToPaySteps.swift +++ b/Example/PayabliDemo/Flow/TapToPaySteps.swift @@ -86,8 +86,12 @@ enum TapToPaySteps { switch session { case .ready: return .done case .attestingDevice, .fetchingConfig, .initializingReader, .reinitializing: return .inProgress - // Activation is a separate step, so reaching it means this one finished. - case .pendingActivation: return .done + // Activation is a separate step, so reaching it means this one + // finished — unless the enable that follows a successful activation + // is what failed. `/config` answering 403 again puts the session back + // to `.pendingActivation`, and the reason for that failure is written + // to this step. + case .pendingActivation: return outcome == .enableFailed ? .failed : .done // `activateDevice` calls markError when it is refused, so the session // reads `.error` for a failure that belongs to the step after this // one. Taking it here would block activation, which is where the @@ -136,7 +140,10 @@ enum TapToPaySteps { if session == .error || session == .sessionExpired { return .reinitialize } - if enable == .current { + // `.failed` as well as `.current`: an enable that failed is retried + // from its own row, and this is the state a broken session does not + // cover. + if enable.isActionable { return .enableTerminal } guard enable.isFinished else { return nil } diff --git a/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift b/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift index c876459..01f4503 100644 --- a/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift +++ b/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift @@ -148,6 +148,32 @@ final class TapToPayStepsTests: XCTestCase { } } + func testAnEnableThatFailedAfterActivationKeepsItsOwnStep() { + // Activation succeeded and the enable that follows it did not. `/config` + // answering 403 again leaves the session `.pendingActivation`, and the + // reason is written to the enable step, which says so: "see step 2". + let sequence = TapToPaySteps.forCharging( + tokenCheck: .reachable, session: .pendingActivation, activation: .enableFailed + ) + XCTAssertEqual(sequence.enable.status, .failed) + XCTAssertTrue(sequence.enable.status.showsContent) + XCTAssertEqual(sequence.activation.status, .blocked) + XCTAssertEqual(sequence.nextAction, .enableTerminal) + } + + func testARecordedEnableFailureIsAnsweredByTheEnableStep() { + for combination in everyCombination + where combination.outcome == .enableFailed && steps(combination).token.status.isFinished + { + let sequence = steps(combination) + guard sequence.enable.status.isFinished else { continue } + XCTAssertNotEqual( + combination.session, .pendingActivation, + "\(combination) finished the enable step over a recorded enable failure" + ) + } + } + func testEveryStepSaysWhatItIs() { for combination in everyCombination { let all = steps(combination).all From ef45b1d1aa0c02b70c74229ca24bc05f2b8ca08b Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Mon, 10 Aug 2026 21:57:28 -0700 Subject: [PATCH 14/57] Use the bound this suite already declared `testCardReadFailureKeepsTheSessionReady` failed on CI and passed on a re-run of the same commit. The cause is not the runner: `bounded` slept a hardcoded five seconds while `boundSeconds` sat beside it, declared, documented and never referenced. Its own comment says what the number is for: for the slowest machine that runs this, not the fastest: an existing test in this suite takes eighteen seconds on CI and milliseconds locally. Five seconds is under a third of the eighteen that comment records. Measured here, the test runs in two to nine milliseconds, so the bound was three orders of magnitude tighter than the work and still short of what CI does. What the bound catches is a call that never returns, and sixty seconds catches that as surely as five while leaving room for a machine that stalls. Co-Authored-By: Claude Opus 5 (1M context) --- .../PayabliTTPReaderSessionRecoveryTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/PayabliSDKTapToPayTests/PayabliTTPReaderSessionRecoveryTests.swift b/Tests/PayabliSDKTapToPayTests/PayabliTTPReaderSessionRecoveryTests.swift index 664ebda..e294e5f 100644 --- a/Tests/PayabliSDKTapToPayTests/PayabliTTPReaderSessionRecoveryTests.swift +++ b/Tests/PayabliSDKTapToPayTests/PayabliTTPReaderSessionRecoveryTests.swift @@ -373,7 +373,7 @@ final class PayabliTTPReaderSessionRecoveryTests: XCTestCase { } } Task { @MainActor in - try? await Task.sleep(nanoseconds: 5_000_000_000) + try? await Task.sleep(nanoseconds: boundSeconds * 1_000_000_000) if !once.done { once.done = true continuation.resume(throwing: ChargeTimedOut()) From ffbbed74ed9be33bcb00b29283b6637185785cb3 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Mon, 10 Aug 2026 22:00:58 -0700 Subject: [PATCH 15/57] Client(iOS) - Let the enable step report a revoked attestation `activateDevice` resets the session to `.idle` for `.attestationRevoked` and marks an error for every other refusal, so `.idle` with a recorded activation failure is a live revocation rather than a stale outcome. The enable step read `.idle` as its own turn, and the guard then blocked the row holding the reason: on screen it looked like an ordinary first-time enable. The way out of a revocation is a fresh cold attestation, which is the enable step's own action, so the failure belongs to it. `runActivate` now records `.attestationRevoked` and writes the reason to the enable step's message, the way it already does when the enable after an activation fails. The round-1 test that asserted `.idle` keeps the enable step `.current` was encoding this same mistake. It now stands on `.attestingDevice`, where an activation outcome genuinely is stale. 180 combinations, up from 144. Co-Authored-By: Claude Opus 5 (1M context) --- Example/PayabliDemo/Flow/TapToPaySteps.swift | 12 +++++++- .../FlowTests/TapToPayStepsTests.swift | 28 ++++++++++++++++--- .../TapToPay/PaymentTapToPayQAView.swift | 13 +++++++++ 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/Example/PayabliDemo/Flow/TapToPaySteps.swift b/Example/PayabliDemo/Flow/TapToPaySteps.swift index 61f177f..3f1b6ab 100644 --- a/Example/PayabliDemo/Flow/TapToPaySteps.swift +++ b/Example/PayabliDemo/Flow/TapToPaySteps.swift @@ -7,6 +7,10 @@ import PayabliSDKTapToPay enum TapToPayActivationOutcome { case none case activationFailed + /// The attestation behind the activation was revoked. `activateDevice` + /// resets the session to `.idle` for this case alone, and the way out is a + /// fresh cold attestation, which is the enable step's job. + case attestationRevoked case enableFailed case succeeded } @@ -99,7 +103,13 @@ enum TapToPaySteps { // doing, so a stale outcome does not move it. case .error: return outcome == .activationFailed ? .done : .failed case .sessionExpired: return .failed - case .idle: return .current + // A recorded activation failure at `.idle` is a revoked attestation: + // it is the one failure `activateDevice` resets rather than marks, + // and re-attesting from scratch is this step's own action. + case .idle: + return outcome == .attestationRevoked || outcome == .activationFailed + ? .failed + : .current @unknown default: return .current } }() diff --git a/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift b/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift index 01f4503..0d1112d 100644 --- a/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift +++ b/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift @@ -16,7 +16,8 @@ final class TapToPayStepsTests: XCTestCase { private let everyCombination: [Combination] = { let checks: [TokenCheck] = [.notRun, .checking, .reachable, .unreachable] - let outcomes: [TapToPayActivationOutcome] = [.none, .activationFailed, .enableFailed, .succeeded] + let outcomes: [TapToPayActivationOutcome] = + [.none, .activationFailed, .attestationRevoked, .enableFailed, .succeeded] return checks.flatMap { check in everyTapToPaySession.flatMap { session in outcomes.map { Combination(tokenCheck: check, session: session, outcome: $0) } @@ -33,7 +34,7 @@ final class TapToPayStepsTests: XCTestCase { } func testTheSpaceIsTheSizeItClaims() { - XCTAssertEqual(everyCombination.count, 4 * 9 * 4) + XCTAssertEqual(everyCombination.count, 4 * 9 * 5) } // MARK: - Invariants, over the whole space @@ -174,6 +175,21 @@ final class TapToPayStepsTests: XCTestCase { } } + func testARevokedAttestationIsTheEnableStepsFailure() { + // `activateDevice` resets to `.idle` for a revoked attestation and marks + // an error for every other refusal, so this is the one activation + // failure whose remedy is a fresh cold attestation. + for outcome in [TapToPayActivationOutcome.attestationRevoked, .activationFailed] { + let sequence = TapToPaySteps.forCharging( + tokenCheck: .reachable, session: .idle, activation: outcome + ) + XCTAssertEqual(sequence.enable.status, .failed, "\(outcome)") + XCTAssertTrue(sequence.enable.status.showsContent, "\(outcome)") + XCTAssertEqual(sequence.activation.status, .blocked, "\(outcome)") + XCTAssertEqual(sequence.nextAction, .enableTerminal, "\(outcome)") + } + } + func testEveryStepSaysWhatItIs() { for combination in everyCombination { let all = steps(combination).all @@ -281,11 +297,15 @@ final class TapToPayStepsTests: XCTestCase { } func testARecordedActivationFailureStaysQuietUntilTheSequenceReachesActivation() { + // Stale here: the terminal is starting, so the outcome describes a + // session that is gone. `.idle` is not stale and is covered separately — + // it is the state a revoked attestation leaves behind. let sequence = TapToPaySteps.forCharging( - tokenCheck: .reachable, session: .idle, activation: .activationFailed + tokenCheck: .reachable, session: .attestingDevice, activation: .activationFailed ) - XCTAssertEqual(sequence.enable.status, .current) + XCTAssertEqual(sequence.enable.status, .inProgress) XCTAssertEqual(sequence.activation.status, .blocked) + XCTAssertNil(sequence.nextAction) } func testAnActivationThatSucceededReadsAsDoneNotAsOneThatNeverApplied() { diff --git a/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift b/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift index 20d573a..58e7d79 100644 --- a/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift +++ b/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift @@ -408,6 +408,19 @@ struct PaymentTapToPayQAView: View { defer { isWorking = false } do { try await terminal.activateDevice(activationCode: code) + } catch let error as PayabliTTPError { + // A revoked attestation resets the session to `.idle`, and the + // way out is a fresh cold attestation. The reason goes to the + // step that offers it. + if case .attestationRevoked = error { + activationOutcome = .attestationRevoked + enableMessage = "✗ \(error.localizedDescription)" + activationMessage = "✗ Attestation revoked — re-enable the terminal, see step 2." + } else { + activationOutcome = .activationFailed + activationMessage = "✗ \(error.localizedDescription)" + } + return } catch { activationOutcome = .activationFailed activationMessage = "✗ \(error.localizedDescription)" From e57a2c7e0421dbb76d0d3054931082ffd1f41ad1 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Mon, 10 Aug 2026 22:29:52 -0700 Subject: [PATCH 16/57] Measure the SDK in Sonar, not the sample app sonar.sources named Example/PayabliDemo, so the sample app's issues and its uncovered lines counted toward the SDK's numbers. Sources and Tests are what this project measures. Bridges/ and ThirdParty/ fall outside those two roots, so their exclusions go with it, as do the demo view directories the coverage exclusions listed. The demo's tests still build and run in CI; they no longer report into the SDK. Coverage is converted from the SDK bundle alone, since demo files would name paths the analysis does not know. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 6 ++--- sonar-project.properties | 47 +++++++++++----------------------------- 2 files changed, 16 insertions(+), 37 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 850db7b..1305ae1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,10 +91,10 @@ jobs: CODE_SIGNING_ALLOWED=NO \ | xcpretty && exit ${PIPESTATUS[0]} + # The SDK bundle only: sonar-project.properties measures Sources and Tests, + # so demo coverage would name files the analysis does not know about. - name: Convert coverage - run: | - ./Scripts/xccov-to-sonarqube-generic.sh \ - SDKTests.xcresult DemoFlowTests.xcresult > coverage.xml + run: ./Scripts/xccov-to-sonarqube-generic.sh SDKTests.xcresult > coverage.xml - name: Upload coverage uses: actions/upload-artifact@v4 diff --git a/sonar-project.properties b/sonar-project.properties index d691fbe..18d3028 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,40 +3,19 @@ sonar.organization=payabli sonar.host.url=https://sonarcloud.io sonar.sourceEncoding=UTF-8 -sonar.sources=Sources,Example/PayabliDemo -sonar.tests=Tests,Example/PayabliDemo/FlowTests +# The SDK, and nothing else. Example/ is the sample app, Bridges/ holds the +# Flutter, MAUI and React Native wrappers, and ThirdParty/ is vendored +# Fiserv/TTPPackage source that is byte-identical to upstream and may not be +# edited. None of them is what this project measures, and naming Sources and +# Tests alone keeps their issues and their uncovered lines out of the SDK's +# numbers. +sonar.sources=Sources +sonar.tests=Tests -# Vendored Fiserv/TTPPackage source, byte-identical to upstream. Analysing it -# would report issues nobody here may fix: the vendoring contract in -# ThirdParty/PayabliCardReaderCoreSource/README.md forbids editing the file. -# -# Bridges/ holds the Flutter, MAUI and React Native wrappers, which SPM does not -# build and CI does not compile. -# -# FlowTests sits inside Example/PayabliDemo, so sonar.sources above would claim -# it as main code while sonar.tests below claims it as test code, and the scanner -# refuses to index a file twice. Excluding it from sources leaves sonar.tests to -# pick it up. Tests/ needs no such line because it is not under a source root. -sonar.exclusions=ThirdParty/**,Bridges/**,**/build/**,.build/**,Example/PayabliDemo/FlowTests/** +# Coverage only, so these still get issue detection. A SwiftUI view needs a +# rendering pass, so no unit test reaches its body, and TestUtils is fixtures +# that the suites exercise rather than target. +sonar.coverage.exclusions=Sources/PayabliSDKPayInPaymentFlow/**View.swift,Sources/PayabliSDKTestUtils/** -# Coverage only, so these still get issue detection. -# -# A SwiftUI View needs a rendering pass, so no unit test reaches its body, and -# the sample app is views apart from Flow/. Flow/ is where every step decision -# lives and is covered by PayabliDemoFlowTests; the directories below are what -# is left, which is why this is a directory rule rather than a list of files. -# The SDK's own form component is the same case. -sonar.coverage.exclusions=\ - Example/PayabliDemo/App/**,\ - Example/PayabliDemo/Configuration/**,\ - Example/PayabliDemo/Debug/**,\ - Example/PayabliDemo/Diagnostics/**,\ - Example/PayabliDemo/PayIn/**,\ - Example/PayabliDemo/Shared/**,\ - Example/PayabliDemo/TapToPay/**,\ - Example/PayabliDemo/Theme/**,\ - Sources/PayabliSDKPayInPaymentFlow/**View.swift,\ - Sources/PayabliSDKTestUtils/** - -# Written by Scripts/xccov-to-sonarqube-generic.sh from both .xcresult bundles. +# Written by Scripts/xccov-to-sonarqube-generic.sh from the SDK .xcresult bundle. sonar.coverageReportPaths=coverage.xml From e63324c0e343bf3dcc77e9ca7aae71242da0a26f Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Mon, 10 Aug 2026 22:39:03 -0700 Subject: [PATCH 17/57] Keep the public initializers' documentation as documentation The reformat turned two `///` blocks into `//`. Both sit above `#if canImport(DeviceCheck)` rather than above the declaration they describe, so the formatter read them as ordinary comments, and the convenience init and its `@objc` companion lost their API documentation from generated symbols. The conditional moves above the comment so each block attaches to the initializer it documents. The Swift block also claimed a macOS floor. `Package.swift` declares `.iOS("16.7")` and nothing else, so the sentence now names the one platform this package has. Machine code is unaffected either way; comments do not compile. What changed is what a consumer sees in completion and generated docs. Co-Authored-By: Claude Opus 5 (1M context) --- Sources/PayabliSDKTapToPay/PayabliTTP.swift | 52 ++++++++++----------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/Sources/PayabliSDKTapToPay/PayabliTTP.swift b/Sources/PayabliSDKTapToPay/PayabliTTP.swift index 41e1d92..85c021a 100644 --- a/Sources/PayabliSDKTapToPay/PayabliTTP.swift +++ b/Sources/PayabliSDKTapToPay/PayabliTTP.swift @@ -130,19 +130,19 @@ public final class PayabliTTP: NSObject, ObservableObject { ) } - // PRD §19.1 convenience init. Wires the default `FiservCardReader` - // provider and a real `AppAttestService` with Keychain-backed storage. - // - // The host supplies the server-minted `accessToken` and an optional - // `tokenProvider` callback for refreshes (see `PayabliConfig`). - // - // Only available where Apple's `DeviceCheck` framework is importable. - // The package minimums (iOS 16.7 from PayabliCardReaderCore / ProximityReader, - // and macOS 12 from `Package.swift`) are both well above `DCAppAttestService`'s - // own floor (iOS 14 / macOS 11.3), so no extra `@available` gate is needed. - // Platforms without `DeviceCheck` must use the designated init with a - // custom `DeviceAttestationService`. #if canImport(DeviceCheck) + /// PRD §19.1 convenience init. Wires the default `FiservCardReader` + /// provider and a real `AppAttestService` with Keychain-backed storage. + /// + /// The host supplies the server-minted `accessToken` and an optional + /// `tokenProvider` callback for refreshes (see `PayabliConfig`). + /// + /// Only available where Apple's `DeviceCheck` framework is importable. + /// The package minimum of iOS 16.7, set by PayabliCardReaderCore and + /// ProximityReader, is well above `DCAppAttestService`'s own floor of + /// iOS 14, so no extra `@available` gate is needed. A platform without + /// `DeviceCheck` uses the designated init with a custom + /// `DeviceAttestationService`. public convenience init( accessToken: String, tokenProvider: PayabliTokenRefresh? = nil, @@ -172,21 +172,21 @@ public final class PayabliTTP: NSObject, ObservableObject { } #endif - // `@objc`-friendly convenience init for ObjC / MAUI / sharpie consumers - // that can't represent the Swift `PayabliTokenRefresh` (`@Sendable () async - // throws -> String`) closure. - // - // Token refresh is exposed here as a completion-style block: - // `tokenRefreshHandler` receives a `(token, error) -> Void` callback that - // the host invokes exactly once with either a fresh access token or an - // `NSError` — the SDK bridges that to the underlying async closure - // internally. Pass `nil` to disable silent refresh; the SDK will surface - // `tokenExpired` instead. - // - // All other parameters mirror the Swift convenience init exactly. Swift - // callers that need an `async throws` token provider should keep using - // the Swift-only convenience init above. #if canImport(DeviceCheck) + /// `@objc`-friendly convenience init for ObjC / MAUI / sharpie consumers + /// that can't represent the Swift `PayabliTokenRefresh` (`@Sendable () + /// async throws -> String`) closure. + /// + /// Token refresh is exposed here as a completion-style block: + /// `tokenRefreshHandler` receives a `(token, error) -> Void` callback + /// that the host invokes exactly once with either a fresh access token + /// or an `NSError` — the SDK bridges that to the underlying async + /// closure internally. Pass `nil` to disable silent refresh; the SDK + /// surfaces `tokenExpired` instead. + /// + /// All other parameters mirror the Swift convenience init exactly. A + /// Swift caller that needs an `async throws` token provider uses the + /// Swift-only convenience init above. @objc public convenience init( accessToken: String, tokenRefreshHandler: ((@escaping (String?, NSError?) -> Void) -> Void)?, From ec0f30efbd89d9f9cf24315b2a9e8d45f7a8df46 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Mon, 10 Aug 2026 22:39:03 -0700 Subject: [PATCH 18/57] Cover the text-to-state boundary the screens depend on The combinatorial suites construct `TokenCheck` values directly, so nothing exercised `classify`, which is where all three screens turn the probe's display text into the state the sequence reads. A changed prefix would have moved every screen to the wrong step with 180 combinations still green. Six tests: the four strings the screens write, and the near-misses. Co-Authored-By: Claude Opus 5 (1M context) --- .../FlowTests/StepStatusTests.swift | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Example/PayabliDemo/FlowTests/StepStatusTests.swift b/Example/PayabliDemo/FlowTests/StepStatusTests.swift index 97c7c66..f069a57 100644 --- a/Example/PayabliDemo/FlowTests/StepStatusTests.swift +++ b/Example/PayabliDemo/FlowTests/StepStatusTests.swift @@ -12,6 +12,26 @@ final class StepStatusTests: XCTestCase { XCTAssertFalse(StepStatus.failed.isFinished) } + // MARK: - The text the screens actually hold + + func testClassifyReadsTheStringsTheScreensWrite() { + // Every screen stores the probe's outcome as display text and passes it + // through here. The combinatorial suites construct `TokenCheck` values + // directly, so this boundary is the one place a changed prefix would go + // unnoticed while every invariant stayed green. + XCTAssertEqual(TokenCheck.classify(""), .notRun) + XCTAssertEqual(TokenCheck.classify("Checking…"), .checking) + XCTAssertEqual(TokenCheck.classify("✓ Token endpoint returned a token"), .reachable) + XCTAssertEqual(TokenCheck.classify("✗ Token endpoint failed: timed out"), .unreachable) + } + + func testClassifyTreatsAnythingElseAsNotRun() { + for text in ["checking", "Checked", "OK", "✓", "✗", " ✓ leading space", "error"] { + let expected: TokenCheck = text == "✓" ? .reachable : text == "✗" ? .unreachable : .notRun + XCTAssertEqual(TokenCheck.classify(text), expected, "\(text)") + } + } + func testTheDemoKnowsEverySessionStateTheSDKHas() { // `PayabliTTPSessionState` is `@objc`, so it cannot be `CaseIterable` and // the list below is written out. A tenth state fails here first. From aba6a867cf91b226d55ea0a81afc7dd1a526ce72 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Mon, 10 Aug 2026 22:39:03 -0700 Subject: [PATCH 19/57] Pin the lint tools CI gates on `brew install swiftlint swiftformat` takes whatever the formula points at today. `.swiftformat` names the version its four disabled rules were checked against, and a formula bump could change what the formatter does to the tree or redden every pull request without a commit touching the repository. swiftformat 0.62.1 and swiftlint 0.65.0, from the release binaries. Raising either is a deliberate change, made with `swiftformat .` re-run in the same commit. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1305ae1..ea8b247 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,11 +30,32 @@ jobs: - name: Install xcpretty run: gem install xcpretty --no-document + # Pinned, because both tools decide whether this job passes. .swiftformat + # names the version its four disabled rules were checked against, and a + # formula bump could change what the formatter does to the tree or redden + # every pull request without a commit. Raise these deliberately, and + # re-run `swiftformat .` in the same change. + - name: Install lint tools + env: + SWIFTFORMAT_VERSION: 0.62.1 + SWIFTLINT_VERSION: 0.65.0 + run: | + mkdir -p "$RUNNER_TEMP/tools" + curl -sSfL -o /tmp/swiftformat.zip \ + "https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFTFORMAT_VERSION}/swiftformat.zip" + unzip -q -j -o /tmp/swiftformat.zip -d "$RUNNER_TEMP/tools" + curl -sSfL -o /tmp/swiftlint.zip \ + "https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/portable_swiftlint.zip" + unzip -q -j -o /tmp/swiftlint.zip -d "$RUNNER_TEMP/tools" + chmod +x "$RUNNER_TEMP/tools/swiftformat" "$RUNNER_TEMP/tools/swiftlint" + echo "$RUNNER_TEMP/tools" >> "$GITHUB_PATH" + "$RUNNER_TEMP/tools/swiftformat" --version + "$RUNNER_TEMP/tools/swiftlint" version + # No `--config`: naming a config file makes SwiftLint ignore nested ones, # and Tests/.swiftlint.yml is what relaxes the rules XCTest fixtures break. - name: Lint run: | - brew install swiftlint swiftformat swiftlint swiftformat --lint . From 4beee8bcbe9dc414a09e13f501ebce3aa46dbe55 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Mon, 10 Aug 2026 22:59:17 -0700 Subject: [PATCH 20/57] Report coverage for the code Sonar measures, and nothing else An .xcresult covers whatever the suite touched, so the report handed to Sonar declared coverage for 35 files under Tests/, 5 vendored card-reader files and 6 in the sample app, alongside the 74 under Sources/ that the analysis measures. A coverage report describing files the project holds as tests, or does not hold at all, is a reconciliation the analysis should not be asked to do. The converter takes a repeatable `--include `, and CI passes `Sources/`. With no flag it keeps everything, so the script is still usable on its own. The guard that refuses to emit an empty report now counts what survives filtering, so a prefix that matches nothing fails rather than publishing zero coverage. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 9 +++++--- Scripts/xccov-to-sonarqube-generic.sh | 32 +++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea8b247..4305f7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,10 +112,13 @@ jobs: CODE_SIGNING_ALLOWED=NO \ | xcpretty && exit ${PIPESTATUS[0]} - # The SDK bundle only: sonar-project.properties measures Sources and Tests, - # so demo coverage would name files the analysis does not know about. + # Sources only. sonar-project.properties measures that root, and an + # .xcresult also covers the test files themselves and the vendored + # card-reader source the suite touches. - name: Convert coverage - run: ./Scripts/xccov-to-sonarqube-generic.sh SDKTests.xcresult > coverage.xml + run: | + ./Scripts/xccov-to-sonarqube-generic.sh --include Sources/ \ + SDKTests.xcresult > coverage.xml - name: Upload coverage uses: actions/upload-artifact@v4 diff --git a/Scripts/xccov-to-sonarqube-generic.sh b/Scripts/xccov-to-sonarqube-generic.sh index 508a02b..05ef13b 100755 --- a/Scripts/xccov-to-sonarqube-generic.sh +++ b/Scripts/xccov-to-sonarqube-generic.sh @@ -4,6 +4,13 @@ # format, which is what `sonar.coverageReportPaths` reads. # # ./Scripts/xccov-to-sonarqube-generic.sh TestResults.xcresult > coverage.xml +# ./Scripts/xccov-to-sonarqube-generic.sh --include Sources/ a.xcresult b.xcresult +# +# `--include` is repeatable and keeps only files under the given repo-relative +# prefixes. A coverage report should describe what the analysis measures: an +# .xcresult also covers the test files themselves and any vendored source the +# suite touched, and handing those to Sonar asks it to reconcile files it holds +# as tests, or does not hold at all. # # Adapted from SonarSource's reference script for Xcode projects. Nothing else # reads xccov, so a change to Xcode's output shows up here first: the script @@ -13,6 +20,26 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +INCLUDE=() + +while [ $# -gt 0 ]; do + case "$1" in + --include) + [ $# -ge 2 ] || { echo "error: --include needs a prefix" >&2; exit 2; } + INCLUDE+=("$2"); shift 2 ;; + *) break ;; + esac +done + +# No --include keeps everything, so the script stays usable on its own. +function included { + [ ${#INCLUDE[@]} -eq 0 ] && return 0 + local path="$1" prefix + for prefix in "${INCLUDE[@]}"; do + case "$path" in "$prefix"*) return 0 ;; esac + done + return 1 +} function convert_file { local xccovarchive_file="$1" @@ -42,6 +69,7 @@ function xccov_to_generic { fi while read -r file_name; do [ -z "$file_name" ] && continue + included "${file_name#"$REPO_ROOT"/}" || continue convert_file "$xcresult" "$file_name" "$xccov_options" files=$((files + 1)) done < <(xcrun xccov view $xccov_options --file-list "$xcresult") @@ -49,13 +77,13 @@ function xccov_to_generic { echo '' if [ "$files" -eq 0 ]; then - echo "error: no covered files found in $*" >&2 + echo "error: no covered files found in $* after --include filtering" >&2 return 1 fi } if [ $# -eq 0 ]; then - echo "usage: $0 [...]" >&2 + echo "usage: $0 [--include ]... [...]" >&2 exit 2 fi From bd856d325782cf8b2ce09b1e3cfce8832a42c6f3 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Mon, 10 Aug 2026 23:06:51 -0700 Subject: [PATCH 21/57] Verify the lint tools by content before running them The previous change pinned a version in a URL, which fixes the name and not the bytes: a release asset can be deleted and re-uploaded under the same tag, so the archives were downloaded, unpacked and executed on trust. Both are now checked against a SHA-256 held in this workflow, before either is unpacked. Measured: `shasum -a 256 -c` exits 1 on a modified archive and 0 on the published one, and under `set -euo pipefail` the step stops at that point rather than reaching the unzip. Raising a version means replacing its checksum in the same change. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4305f7a..ac4fb16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,23 +30,36 @@ jobs: - name: Install xcpretty run: gem install xcpretty --no-document - # Pinned, because both tools decide whether this job passes. .swiftformat - # names the version its four disabled rules were checked against, and a - # formula bump could change what the formatter does to the tree or redden - # every pull request without a commit. Raise these deliberately, and - # re-run `swiftformat .` in the same change. + # Pinned by content, not by name. Both tools decide whether this job + # passes, and .swiftformat names the version its four disabled rules were + # checked against, so a change in either would alter the tree or redden + # every pull request without a commit here. + # + # A release tag is not immutable: a publisher can delete an asset and + # upload another under the same URL. The checksums below are what makes + # the bytes fixed, and they are verified before anything is unpacked or + # run. Raising a version means replacing its checksum in the same change, + # with `swiftformat .` re-run alongside it. - name: Install lint tools env: SWIFTFORMAT_VERSION: 0.62.1 + SWIFTFORMAT_SHA256: 7cb1cb1fae04932047c7015441c543848e8e60e1572d808d080e0a1f1661114a SWIFTLINT_VERSION: 0.65.0 + SWIFTLINT_SHA256: d6cb0aa7a2f5f1ef306fc9e37bcb54dc9a26facc8f7784ac0c3dd3eccf5c6ba6 run: | + set -euo pipefail mkdir -p "$RUNNER_TEMP/tools" - curl -sSfL -o /tmp/swiftformat.zip \ + + curl -sSfL -o "$RUNNER_TEMP/swiftformat.zip" \ "https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFTFORMAT_VERSION}/swiftformat.zip" - unzip -q -j -o /tmp/swiftformat.zip -d "$RUNNER_TEMP/tools" - curl -sSfL -o /tmp/swiftlint.zip \ + echo "${SWIFTFORMAT_SHA256} ${RUNNER_TEMP}/swiftformat.zip" | shasum -a 256 -c - + + curl -sSfL -o "$RUNNER_TEMP/swiftlint.zip" \ "https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/portable_swiftlint.zip" - unzip -q -j -o /tmp/swiftlint.zip -d "$RUNNER_TEMP/tools" + echo "${SWIFTLINT_SHA256} ${RUNNER_TEMP}/swiftlint.zip" | shasum -a 256 -c - + + unzip -q -j -o "$RUNNER_TEMP/swiftformat.zip" -d "$RUNNER_TEMP/tools" + unzip -q -j -o "$RUNNER_TEMP/swiftlint.zip" -d "$RUNNER_TEMP/tools" chmod +x "$RUNNER_TEMP/tools/swiftformat" "$RUNNER_TEMP/tools/swiftlint" echo "$RUNNER_TEMP/tools" >> "$GITHUB_PATH" "$RUNNER_TEMP/tools/swiftformat" --version From 5d61160d87f59bd11ef31c91fe40f373a57e8366 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Mon, 10 Aug 2026 23:29:15 -0700 Subject: [PATCH 22/57] Client(iOS) - Probe the card-not-present token endpoint from Configuration The Configuration screen lists three token endpoints and probed one of them. Its Check token button calls `Secrets.fetchAccessToken`, the card-present endpoint, so the two endpoints the PayIn tabs call had no probe anywhere in the app that could be run at will. Inside a payment tab the probe is a step, and a step that is done hides its controls: once a method is stored or a payment captured, `lastResult` keeps the backend step done for the rest of the run and the button is gone. A probe that answers "is the token endpoint up right now" belongs outside the sequence, where it can be run whenever, which is where the counterpart platform puts it. `fetchPaymentCaptureAccessToken` forwards to `fetchPaymentMethodAccessToken`, so one button answers for both tabs. Verified on the simulator against api-qa: the probe reports a token. Co-Authored-By: Claude Opus 5 (1M context) --- .../Configuration/ConfigurationQAView.swift | 58 ++++++++++++++----- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/Example/PayabliDemo/Configuration/ConfigurationQAView.swift b/Example/PayabliDemo/Configuration/ConfigurationQAView.swift index def2545..039b771 100644 --- a/Example/PayabliDemo/Configuration/ConfigurationQAView.swift +++ b/Example/PayabliDemo/Configuration/ConfigurationQAView.swift @@ -11,6 +11,7 @@ import SwiftUI /// source the forms use, so this screen cannot drift from the real behaviour. struct ConfigurationQAView: View { @State private var tokenCheckText = "" + @State private var cardNotPresentCheckText = "" @State private var healthCheckText = "" @State private var isWorking = false @@ -74,21 +75,34 @@ struct ConfigurationQAView: View { ) QADetailRow(label: "Resolved by", value: DemoConfiguration.TokenServer.explanation) - HStack(spacing: 12) { - Button { runTokenCheck() } label: { - Label("Check token", systemImage: "key.horizontal") - } - .buttonStyle(.bordered) - .disabled(isWorking) + // Both probes live here, outside any step sequence, so either can be + // re-run at any time. The sequence on a payment tab hides its own + // probe once the step is done, and a stored method or a captured + // payment leaves it that way for the rest of the run. + // One per row, each sized to its label. Side by side the longer + // label wraps mid-word at this width. + Button { runTokenCheck() } label: { + Label("Check card-present token", systemImage: "key.horizontal") + } + .buttonStyle(.bordered) + .disabled(isWorking) - Button { runHealthCheck() } label: { - Label("Health", systemImage: "heart.text.square") - } - .buttonStyle(.bordered) - .disabled(isWorking) + Button { runCardNotPresentTokenCheck() } label: { + Label("Check card-not-present token", systemImage: "key.horizontal") } + .buttonStyle(.bordered) + .disabled(isWorking) - ForEach([tokenCheckText, healthCheckText].filter { !$0.isEmpty }, id: \.self) { line in + Button { runHealthCheck() } label: { + Label("Local server health", systemImage: "heart.text.square") + } + .buttonStyle(.bordered) + .disabled(isWorking) + + ForEach( + [tokenCheckText, cardNotPresentCheckText, healthCheckText].filter { !$0.isEmpty }, + id: \.self + ) { line in Text(line) .font(.caption) .foregroundColor(.payabliOnSurfaceVariant) @@ -215,9 +229,25 @@ struct ConfigurationQAView: View { defer { isWorking = false } do { _ = try await Secrets.fetchAccessToken() - tokenCheckText = "✓ Token endpoint returned a token" + tokenCheckText = "✓ Card-present token endpoint returned a token" + } catch { + tokenCheckText = "✗ Card-present token endpoint failed: \(error.localizedDescription)" + } + } + } + + /// The endpoint both card-not-present tabs call. `fetchPaymentCaptureAccessToken` + /// forwards to this one, so a single probe answers for both. + private func runCardNotPresentTokenCheck() { + isWorking = true + cardNotPresentCheckText = "Checking token…" + Task { + defer { isWorking = false } + do { + _ = try await Secrets.fetchPaymentMethodAccessToken() + cardNotPresentCheckText = "✓ Card-not-present token endpoint returned a token" } catch { - tokenCheckText = "✗ Token endpoint failed: \(error.localizedDescription)" + cardNotPresentCheckText = "✗ Card-not-present token endpoint failed: \(error.localizedDescription)" } } } From 4553419e7f7b01645c1522ec5438b9a229312b27 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Mon, 10 Aug 2026 23:51:05 -0700 Subject: [PATCH 23/57] Fail the coverage conversion on parsed lines, not on file count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard counted entries in the file list, and every entry was counted whether or not the per-line parser matched anything. A change to Xcode's per-line output therefore left the file list intact, emitted a `` element for each one with no `` inside it, and exited zero — publishing a report that means zero coverage while reporting success. That is the failure the guard exists to prevent. It now counts the elements actually emitted, and a file that parses to nothing contributes no element at all. Measured by breaking both sed rules so they cannot match, and running each version against the same .xcresult: counting files exit 0, 74 empty elements, 0 counting lines exit 1, nothing emitted Co-Authored-By: Claude Opus 5 (1M context) --- Scripts/xccov-to-sonarqube-generic.sh | 28 +++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/Scripts/xccov-to-sonarqube-generic.sh b/Scripts/xccov-to-sonarqube-generic.sh index 05ef13b..5103c15 100755 --- a/Scripts/xccov-to-sonarqube-generic.sh +++ b/Scripts/xccov-to-sonarqube-generic.sh @@ -14,8 +14,10 @@ # # Adapted from SonarSource's reference script for Xcode projects. Nothing else # reads xccov, so a change to Xcode's output shows up here first: the script -# exits non-zero if it produced no element, rather than handing Sonar an -# empty report that reads as zero coverage. +# exits non-zero unless it emitted at least one , rather than +# handing Sonar a report that reads as zero coverage. Counting files rather than +# lines would not catch it — a per-line format change leaves the file list intact +# and every element empty. set -euo pipefail @@ -41,6 +43,9 @@ function included { return 1 } +# Total elements emitted, which is what the guard at the end reads. +LINES_EMITTED=0 + function convert_file { local xccovarchive_file="$1" local file_name="$2" @@ -50,13 +55,21 @@ function convert_file { # job and read in another. local relative_name="${file_name#"$REPO_ROOT"/}" - echo " " - xcrun xccov view $xccov_options --file "$file_name" "$xccovarchive_file" \ + local lines + lines=$(xcrun xccov view $xccov_options --file "$file_name" "$xccovarchive_file" \ | sed -n ' s/^ *\([0-9][0-9]*\): *0.*$/ /p; s/^ *\([0-9][0-9]*\): *[1-9].*$/ /p - ' + ') + + # A file with nothing coverable contributes no element. Emitting an empty + # would still count toward a file-based guard. + [ -n "$lines" ] || return 0 + + echo " " + printf '%s\n' "$lines" echo ' ' + LINES_EMITTED=$((LINES_EMITTED + $(printf '%s\n' "$lines" | wc -l))) } function xccov_to_generic { @@ -71,13 +84,12 @@ function xccov_to_generic { [ -z "$file_name" ] && continue included "${file_name#"$REPO_ROOT"/}" || continue convert_file "$xcresult" "$file_name" "$xccov_options" - files=$((files + 1)) done < <(xcrun xccov view $xccov_options --file-list "$xcresult") done echo '' - if [ "$files" -eq 0 ]; then - echo "error: no covered files found in $* after --include filtering" >&2 + if [ "$LINES_EMITTED" -eq 0 ]; then + echo "error: no coverable lines parsed from $* after --include filtering" >&2 return 1 fi } From 7743961373d9380ed07cdc3846b524a4857cec8a Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 09:24:35 -0700 Subject: [PATCH 24/57] Client(iOS) - Offer another activation code after one is refused A refused code leaves the session `.pendingActivation`, which is the one state `activateDevice` accepts. The activation step reported the failure and `nextAction` returned nil: no code control, and no Recovery either, because the session is not broken. The screen showed a reason with nothing to do about it. `.failed` is actionable for this step as it already is for enable, so another code is the way on. An invariant covers the shape rather than the instance: no combination reports a failure while offering nothing. Both it and the point case fail without the change, checked by reverting the one line and re-running. Co-Authored-By: Claude Opus 5 (1M context) --- Example/PayabliDemo/Flow/TapToPaySteps.swift | 5 ++++- .../FlowTests/TapToPayStepsTests.swift | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/Example/PayabliDemo/Flow/TapToPaySteps.swift b/Example/PayabliDemo/Flow/TapToPaySteps.swift index 3f1b6ab..486cdcf 100644 --- a/Example/PayabliDemo/Flow/TapToPaySteps.swift +++ b/Example/PayabliDemo/Flow/TapToPaySteps.swift @@ -157,7 +157,10 @@ enum TapToPaySteps { return .enableTerminal } guard enable.isFinished else { return nil } - if activation == .current { + // `.failed` as well as `.current`, as with the enable step above: a + // refused code leaves the session `.pendingActivation`, which is the + // one state `activateDevice` accepts, so another code is the way on. + if activation.isActionable { return .enterActivationCode } guard activation.isFinished else { return nil } diff --git a/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift b/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift index 0d1112d..6d2b05e 100644 --- a/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift +++ b/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift @@ -190,6 +190,19 @@ final class TapToPayStepsTests: XCTestCase { } } + func testNoCombinationShowsAFailureWithNothingToDo() { + // A step that reports a failure and offers no control, with no recovery + // either, is a screen a person cannot leave. + for combination in everyCombination { + let sequence = steps(combination) + guard sequence.all.contains(where: { $0.status == .failed }) else { continue } + XCTAssertNotNil( + sequence.nextAction, + "\(combination) reports a failure and offers nothing" + ) + } + } + func testEveryStepSaysWhatItIs() { for combination in everyCombination { let all = steps(combination).all @@ -294,6 +307,9 @@ final class TapToPayStepsTests: XCTestCase { XCTAssertEqual(sequence.activation.status, .failed) XCTAssertTrue(sequence.activation.status.showsContent) XCTAssertEqual(sequence.charge.status, .blocked) + // The session is still the one state `activateDevice` accepts, so the + // reason comes with another go rather than a dead end. + XCTAssertEqual(sequence.nextAction, .enterActivationCode) } func testARecordedActivationFailureStaysQuietUntilTheSequenceReachesActivation() { From eb72bb5368f64e5a2b0090f21d7309b21252bc92 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 09:37:26 -0700 Subject: [PATCH 25/57] Pin the analysis action to a commit `SonarSource/sonarqube-scan-action@v5` is a mutable tag, and this is the step handed SONAR_TOKEN. A replaced tag would run different code with a repository secret in its environment, which is the path the checksummed tool downloads in this same file exist to close. Pinned to 2f77a1ec69fb1d595b06f35ab27e97605bdef703, which is what v5 and v5.3.2 both resolve to today. The tag stays in a trailing comment as the hint for raising it. The remaining `uses:` entries are first-party actions/* on major tags, left as they are. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac4fb16..9c33665 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,7 +158,10 @@ jobs: with: name: coverage + # Pinned to a commit, not a tag. `v5` is mutable, and this step is handed + # SONAR_TOKEN, so a replaced action would hold a repository secret. Same + # reasoning as the checksummed tool downloads above. - name: Analyze - uses: SonarSource/sonarqube-scan-action@v5 + uses: SonarSource/sonarqube-scan-action@2f77a1ec69fb1d595b06f35ab27e97605bdef703 # v5.3.2 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} From a6e2e1286b49b61c52811f8ac3a3be7eb8dd6a8b Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 10:56:45 -0700 Subject: [PATCH 26/57] Client(iOS) - Offer the full setup when the session errored `reinitializeIfNeeded` goes straight to fetching config and never attests, and a config 401 calls `attestation.clearCache()` before marking the session `.error`. Recovery therefore offered Re-initialize for exactly the state whose identity it needs and no longer has: the assertion fails, the session returns to `.error`, and the same control is offered again. `initialize()` re-uses a cached attestation and runs a cold one when there is none, so it is the way back from an error whose cause may have cleared the identity. `.error` now offers it, and Recovery says which of the two it is running. Expiry keeps the cheaper path, and so does a refused activation: reaching the backend at all means the device attested, and only a revoked attestation clears that identity. Two invariants over the 180 combinations, one per recovery kind, plus the refused-activation case. Co-Authored-By: Claude Opus 5 (1M context) --- Example/PayabliDemo/Flow/TapToPaySteps.swift | 19 +++++++- .../FlowTests/TapToPayStepsTests.swift | 45 +++++++++++++++++-- .../TapToPay/PaymentTapToPayQAView.swift | 17 ++++--- 3 files changed, 71 insertions(+), 10 deletions(-) diff --git a/Example/PayabliDemo/Flow/TapToPaySteps.swift b/Example/PayabliDemo/Flow/TapToPaySteps.swift index 486cdcf..601e33a 100644 --- a/Example/PayabliDemo/Flow/TapToPaySteps.swift +++ b/Example/PayabliDemo/Flow/TapToPaySteps.swift @@ -48,7 +48,13 @@ enum TapToPayAction { case enableTerminal case enterActivationCode case charge + /// `reinitializeIfNeeded`, which re-runs config and the reader and skips + /// attestation. Only sound where the attested identity is still held. case reinitialize + /// `initialize`, the full setup. It re-uses a cached attestation and runs a + /// cold one when there is none, so it is the way back from a session whose + /// identity may have been cleared. + case reattest } /// What taking a contactless payment asks for. @@ -147,9 +153,20 @@ enum TapToPaySteps { return .checkToken } guard token.isFinished else { return nil } - if session == .error || session == .sessionExpired { + // A session that expired still holds its attested identity, so + // re-running config and the reader is enough. `.error` is where a + // config 401 lands, and that path clears the attestation cache, so + // skipping attestation would fail on the assertion every time and + // offer the same control again. + if session == .sessionExpired { return .reinitialize } + if session == .error { + // A refused activation reached the backend, so the attested + // identity is intact and config plus reader is enough. Any other + // `.error` may be the config 401, which clears it. + return outcome == .activationFailed ? .reinitialize : .reattest + } // `.failed` as well as `.current`: an enable that failed is retried // from its own row, and this is the state a broken session does not // cover. diff --git a/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift b/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift index 6d2b05e..36457c4 100644 --- a/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift +++ b/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift @@ -124,19 +124,56 @@ final class TapToPayStepsTests: XCTestCase { } func testRecoveryWaitsForTheTokenStepLikeEverythingElse() { - // Re-initialize re-runs config, which a backend known to be down cannot + // Recovery re-runs config, which a backend known to be down cannot // answer, and offering it beside the probe's own retry is two next // actions again. - for combination in everyCombination where steps(combination).nextAction == .reinitialize { + for combination in everyCombination + where steps(combination).nextAction == .reinitialize + || steps(combination).nextAction == .reattest + { let sequence = steps(combination) - XCTAssertTrue(sequence.token.status.isFinished, "\(combination) offered Re-initialize") + XCTAssertTrue(sequence.token.status.isFinished, "\(combination) offered recovery") XCTAssertTrue( combination.session == .error || combination.session == .sessionExpired, - "\(combination) offered Re-initialize" + "\(combination) offered recovery" ) } } + func testAnErroredSessionIsOfferedTheFullSetup() { + // A config 401 clears the attested identity and marks the session + // `.error`. `reinitializeIfNeeded` skips attestation and asserts against + // an identity that is gone, so it would fail and offer itself again. + for combination in everyCombination + where combination.session == .error && combination.outcome != .activationFailed + { + let sequence = steps(combination) + // A failed probe holds the sequence, and one in flight offers + // nothing at all. + guard sequence.token.status.isFinished else { continue } + XCTAssertEqual(sequence.nextAction, .reattest, "\(combination)") + } + } + + func testARefusedActivationKeepsTheCheaperRecovery() { + // Reaching the backend at all means the device attested, and only a + // revoked attestation clears that identity. + let sequence = TapToPaySteps.forCharging( + tokenCheck: .reachable, session: .error, activation: .activationFailed + ) + XCTAssertEqual(sequence.nextAction, .reinitialize) + } + + func testAnExpiredSessionKeepsTheCheaperRecovery() { + // Expiry does not touch the attested identity, so config and the reader + // are all that need re-running. + for combination in everyCombination where combination.session == .sessionExpired { + let sequence = steps(combination) + guard sequence.token.status.isFinished else { continue } + XCTAssertEqual(sequence.nextAction, .reinitialize, "\(combination)") + } + } + func testAFailedProbeKeepsTheOnlyActionOnTheProbe() { for session in everyTapToPaySession { let sequence = TapToPaySteps.forCharging( diff --git a/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift b/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift index 58e7d79..4fca122 100644 --- a/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift +++ b/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift @@ -155,16 +155,23 @@ struct PaymentTapToPayQAView: View { /// is in a state it can actually repair. @ViewBuilder private var recoverySection: some View { - if steps.nextAction == .reinitialize { + if steps.nextAction == .reinitialize || steps.nextAction == .reattest { VStack(alignment: .leading, spacing: 8) { Text("Recovery") .font(.headline) - Text("The session expired or errored. This re-runs config and reader setup without a fresh attestation.") + Text(steps.nextAction == .reinitialize + ? "The session expired. This re-runs config and reader setup without a fresh attestation." + : "The session errored. A config 401 clears the attested identity, so this runs the full setup.") .font(.caption) .foregroundColor(.payabliOnSurfaceVariant) - Button { runReinitialize() } label: { - Label("Re-initialize", systemImage: "arrow.clockwise") - .frame(maxWidth: .infinity) + Button { + steps.nextAction == .reinitialize ? runReinitialize() : runEnableTerminal() + } label: { + Label( + steps.nextAction == .reinitialize ? "Re-initialize" : "Run full setup", + systemImage: "arrow.clockwise" + ) + .frame(maxWidth: .infinity) } .buttonStyle(.bordered) .disabled(isWorking) From cde192f60f44d9e86541b574420a86096082a954 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 10:56:45 -0700 Subject: [PATCH 27/57] Fail the conversion when an archive cannot be read `done < <(xcrun ...)` gives the loop the status of the loop, not of the command feeding it, so an unreadable .xcresult was skipped in silence. Measured: a good archive followed by a missing one exited 0 and emitted a report holding only the first archive's 74 files, while xccov printed "Failed to load result bundle" to stderr and nothing read it. The file list is now captured before the loop, declared on its own line because `local x=$(...)` takes the status of `local`. The same pair of archives now exits 1, and a single good archive produces byte-identical output to before. Co-Authored-By: Claude Opus 5 (1M context) --- Scripts/xccov-to-sonarqube-generic.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Scripts/xccov-to-sonarqube-generic.sh b/Scripts/xccov-to-sonarqube-generic.sh index 5103c15..cecb04a 100755 --- a/Scripts/xccov-to-sonarqube-generic.sh +++ b/Scripts/xccov-to-sonarqube-generic.sh @@ -80,11 +80,19 @@ function xccov_to_generic { if [[ $xcresult == *".xcresult"* ]]; then xccov_options="--archive" fi + # Read the list first. In `done < <(...)` the process substitution's + # status is not the loop's, so an unreadable archive would be skipped in + # silence and the report would come back short but successful. Declared + # before assignment because `local x=$(...)` takes the status of + # `local`, not of the command. + local file_list + file_list=$(xcrun xccov view $xccov_options --file-list "$xcresult") + while read -r file_name; do [ -z "$file_name" ] && continue included "${file_name#"$REPO_ROOT"/}" || continue convert_file "$xcresult" "$file_name" "$xccov_options" - done < <(xcrun xccov view $xccov_options --file-list "$xcresult") + done <<< "$file_list" done echo '' From 344b851551154d25559aae231ed4c52c5b686ddd Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 11:20:31 -0700 Subject: [PATCH 28/57] Read the coverage report in one xccov call The conversion step took 117s of an 8.3 minute CI job. SonarSource's reference script runs `xccov view --file-list` and then `xccov view --file` once per source file, so a 114-file report spent that time in process startup. `xccov view --archive --json` returns every file's line table from one call. Locally the step goes from 30.9s to 7.6s, and from 31s of CPU to 0.6s. The report is unchanged: 74 files and 7221 elements before and after, with the same covered flag on every line. Ordering is now by path, since the file list no longer sets it. Both guards still fire. An --include that matches nothing exits 1, and a bundle xccov cannot read now reports xccov's own message instead of a traceback. Co-Authored-By: Claude Opus 5 (1M context) --- Scripts/xccov-to-sonarqube-generic.sh | 165 ++++++++++++++------------ 1 file changed, 89 insertions(+), 76 deletions(-) diff --git a/Scripts/xccov-to-sonarqube-generic.sh b/Scripts/xccov-to-sonarqube-generic.sh index cecb04a..607b75e 100755 --- a/Scripts/xccov-to-sonarqube-generic.sh +++ b/Scripts/xccov-to-sonarqube-generic.sh @@ -12,16 +12,21 @@ # suite touched, and handing those to Sonar asks it to reconcile files it holds # as tests, or does not hold at all. # -# Adapted from SonarSource's reference script for Xcode projects. Nothing else -# reads xccov, so a change to Xcode's output shows up here first: the script -# exits non-zero unless it emitted at least one , rather than -# handing Sonar a report that reads as zero coverage. Counting files rather than -# lines would not catch it — a per-line format change leaves the file list intact -# and every element empty. +# One `xccov view --archive --json` call per bundle returns every file's line +# table at once. SonarSource's reference script instead runs `--file-list` and +# then `--file` per file, which is one process per source file and took 117s of +# the CI job for a report this size. +# +# Nothing else reads xccov, so a change to Xcode's output shows up here first: +# the script exits non-zero unless it emitted at least one , rather +# than handing Sonar a report that reads as zero coverage. Counting files rather +# than lines would not catch it — a per-line format change leaves the file list +# intact and every element empty. set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export REPO_ROOT INCLUDE=() while [ $# -gt 0 ]; do @@ -33,78 +38,86 @@ while [ $# -gt 0 ]; do esac done -# No --include keeps everything, so the script stays usable on its own. -function included { - [ ${#INCLUDE[@]} -eq 0 ] && return 0 - local path="$1" prefix - for prefix in "${INCLUDE[@]}"; do - case "$path" in "$prefix"*) return 0 ;; esac - done - return 1 -} - -# Total elements emitted, which is what the guard at the end reads. -LINES_EMITTED=0 - -function convert_file { - local xccovarchive_file="$1" - local file_name="$2" - local xccov_options="$3" - - # Relative to the repo root, so the report survives being written in one CI - # job and read in another. - local relative_name="${file_name#"$REPO_ROOT"/}" - - local lines - lines=$(xcrun xccov view $xccov_options --file "$file_name" "$xccovarchive_file" \ - | sed -n ' - s/^ *\([0-9][0-9]*\): *0.*$/ /p; - s/^ *\([0-9][0-9]*\): *[1-9].*$/ /p - ') - - # A file with nothing coverable contributes no element. Emitting an empty - # would still count toward a file-based guard. - [ -n "$lines" ] || return 0 - - echo " " - printf '%s\n' "$lines" - echo ' ' - LINES_EMITTED=$((LINES_EMITTED + $(printf '%s\n' "$lines" | wc -l))) -} - -function xccov_to_generic { - local files=0 - echo '' - for xcresult in "$@"; do - local xccov_options="" - if [[ $xcresult == *".xcresult"* ]]; then - xccov_options="--archive" - fi - # Read the list first. In `done < <(...)` the process substitution's - # status is not the loop's, so an unreadable archive would be skipped in - # silence and the report would come back short but successful. Declared - # before assignment because `local x=$(...)` takes the status of - # `local`, not of the command. - local file_list - file_list=$(xcrun xccov view $xccov_options --file-list "$xcresult") - - while read -r file_name; do - [ -z "$file_name" ] && continue - included "${file_name#"$REPO_ROOT"/}" || continue - convert_file "$xcresult" "$file_name" "$xccov_options" - done <<< "$file_list" - done - echo '' - - if [ "$LINES_EMITTED" -eq 0 ]; then - echo "error: no coverable lines parsed from $* after --include filtering" >&2 - return 1 - fi -} - if [ $# -eq 0 ]; then echo "usage: $0 [--include ]... [...]" >&2 exit 2 fi -xccov_to_generic "$@" +# Newline-separated, because a prefix is a path and the array may be empty. +INCLUDE_PREFIXES="" +if [ ${#INCLUDE[@]} -gt 0 ]; then + INCLUDE_PREFIXES="$(printf '%s\n' "${INCLUDE[@]}")" +fi +export INCLUDE_PREFIXES + +exec python3 - "$@" <<'PY' +import json +import os +import subprocess +import sys +from xml.sax.saxutils import quoteattr + +repo_root = os.environ["REPO_ROOT"] +prefixes = [p for p in os.environ.get("INCLUDE_PREFIXES", "").split("\n") if p] + + +def included(path): + # No --include keeps everything, so the script stays usable on its own. + return not prefixes or any(path.startswith(p) for p in prefixes) + + +# path -> {line number: covered}. Merged across bundles, so a line a second +# suite reached is covered even where the first never entered it. +coverage = {} + +for bundle in sys.argv[1:]: + options = ["--archive"] if ".xcresult" in bundle else [] + # xccov's own message says which bundle and why, and it goes to a captured + # stderr, so it is reported rather than replaced by a traceback. + result = subprocess.run( + ["xcrun", "xccov", "view", *options, "--json", bundle], + capture_output=True, text=True, check=False, + ) + if result.returncode != 0: + sys.stderr.write(result.stderr) + sys.exit(f"error: xccov could not read {bundle}") + report = json.loads(result.stdout) + + # xccov keys the archive report by absolute file path. Anything else is a + # format this script has not been read against, and guessing at it is how a + # report comes back empty but successful. + if not isinstance(report, dict) or not all(isinstance(v, list) for v in report.values()): + sys.exit(f"error: unexpected xccov --json shape from {bundle}") + + for absolute, lines in report.items(): + relative = absolute[len(repo_root) + 1:] if absolute.startswith(repo_root + "/") else absolute + if not included(relative): + continue + file_lines = coverage.setdefault(relative, {}) + for line in lines: + if not line.get("isExecutable"): + continue + covered = line.get("executionCount", 0) > 0 + number = line["line"] + file_lines[number] = file_lines.get(number, False) or covered + +emitted = 0 +out = sys.stdout +out.write('\n') +for path in sorted(coverage): + lines = coverage[path] + # A file with nothing coverable contributes no element. Emitting an empty + # would still count toward a file-based guard. + if not lines: + continue + out.write(f" \n") + for number in sorted(lines): + covered = "true" if lines[number] else "false" + out.write(f' \n') + emitted += 1 + out.write(" \n") +out.write("\n") + +if emitted == 0: + sys.exit(f"error: no coverable lines parsed from {' '.join(sys.argv[1:])} after --include filtering") +PY From 10cd8d80eef4910fc4e58a2a9b1b7958eb381f25 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 11:21:45 -0700 Subject: [PATCH 29/57] Run lint and the two suites as parallel jobs One job ran lint, the SDK suite, the sample app's suite and the coverage conversion in sequence, and the two suites were 350s of it. Neither reads anything the other writes. The suites cannot be made to share a build. Both compile the SDK, but one is the package's own test action and the other consumes the package as a dependency of Example/PayabliDemo.xcodeproj, which Xcode compiles with -suppress-warnings. The intermediates path is the same and the flags are not, so a shared -derivedDataPath makes each run invalidate the other's objects: measured, the second invocation recompiles 87 of them either way. Running them at the same time is what is left. The four setup steps the test jobs share move into a composite action, so the Xcode selection and the simulator lookup stay written once and the jobs cannot end up on different runtimes. Sonar waits on the SDK job alone, since the sample app contributes no coverage to the analysis. Co-Authored-By: Claude Opus 5 (1M context) --- .github/actions/ios-toolchain/action.yml | 50 ++++++++++++ .github/workflows/ci.yml | 98 +++++++++++------------- 2 files changed, 96 insertions(+), 52 deletions(-) create mode 100644 .github/actions/ios-toolchain/action.yml diff --git a/.github/actions/ios-toolchain/action.yml b/.github/actions/ios-toolchain/action.yml new file mode 100644 index 0000000..022d464 --- /dev/null +++ b/.github/actions/ios-toolchain/action.yml @@ -0,0 +1,50 @@ +name: iOS toolchain +description: Selects Xcode and a simulator, and resolves the package graph. + +# The test jobs run in parallel and each needs the same four steps. A composite +# action keeps the Xcode selection and the simulator lookup written once, so the +# jobs cannot drift onto different runtimes and disagree about a failure. + +runs: + using: composite + steps: + - name: Select latest Xcode + shell: bash + run: | + XCODE=$(ls /Applications | grep -E '^Xcode_[0-9]' | sort -V | tail -1) + sudo xcode-select -s "/Applications/$XCODE/Contents/Developer" + echo "Using $XCODE" + xcodebuild -version + swift --version + + - name: Install xcpretty + shell: bash + run: gem install xcpretty --no-document + + - name: Resolve dependencies + shell: bash + run: | + xcodebuild -resolvePackageDependencies \ + -scheme PayabliSDK-Package \ + -clonedSourcePackagesDirPath .build/checkouts + + - name: Select simulator + shell: bash + run: | + SIMULATOR_ID=$(xcrun simctl list devices available -j \ + | python3 -c " + import json, sys + d = json.load(sys.stdin)['devices'] + iphones = [ + v for k, vals in d.items() + if 'iOS' in k + for v in vals + if 'iPhone' in v['name'] and v['isAvailable'] + ] + if not iphones: + print('error: no available iPhone simulator found', file=sys.stderr) + sys.exit(1) + print(sorted(iphones, key=lambda x: x['name'])[-1]['udid']) + ") + echo "Using simulator: $SIMULATOR_ID" + echo "SIMULATOR_ID=$SIMULATOR_ID" >> "$GITHUB_ENV" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c33665..d88c471 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,26 +10,24 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true +# Three jobs, run at once. The two suites are the whole cost of this workflow +# and neither reads anything the other writes, so run in sequence they only add +# up. They cannot share a build: both compile the SDK, but one is the package's +# own test action and the other consumes the package as a dependency of +# Example/PayabliDemo.xcodeproj, and Xcode compiles a dependency with +# -suppress-warnings. Same intermediates path, different flags, so pointing both +# at one -derivedDataPath makes them invalidate each other's objects rather than +# reuse them. + jobs: - test: - name: Build & Test + lint: + name: Lint runs-on: macos-15 steps: - name: Checkout uses: actions/checkout@v4 - - name: Select latest Xcode - run: | - XCODE=$(ls /Applications | grep -E '^Xcode_[0-9]' | sort -V | tail -1) - sudo xcode-select -s "/Applications/$XCODE/Contents/Developer" - echo "Using $XCODE" - xcodebuild -version - swift --version - - - name: Install xcpretty - run: gem install xcpretty --no-document - # Pinned by content, not by name. Both tools decide whether this job # passes, and .swiftformat names the version its four disabled rules were # checked against, so a change in either would alter the tree or redden @@ -72,31 +70,15 @@ jobs: swiftlint swiftformat --lint . - - name: Resolve dependencies - run: | - xcodebuild -resolvePackageDependencies \ - -scheme PayabliSDK-Package \ - -clonedSourcePackagesDirPath .build/checkouts + test-sdk: + name: SDK tests + runs-on: macos-15 - - name: Select simulator - run: | - SIMULATOR_ID=$(xcrun simctl list devices available -j \ - | python3 -c " - import json, sys - d = json.load(sys.stdin)['devices'] - iphones = [ - v for k, vals in d.items() - if 'iOS' in k - for v in vals - if 'iPhone' in v['name'] and v['isAvailable'] - ] - if not iphones: - print('error: no available iPhone simulator found', file=sys.stderr) - sys.exit(1) - print(sorted(iphones, key=lambda x: x['name'])[-1]['udid']) - ") - echo "Using simulator: $SIMULATOR_ID" - echo "SIMULATOR_ID=$SIMULATOR_ID" >> "$GITHUB_ENV" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - uses: ./.github/actions/ios-toolchain - name: Test the SDK run: | @@ -109,6 +91,30 @@ jobs: CODE_SIGNING_ALLOWED=NO \ | xcpretty && exit ${PIPESTATUS[0]} + # Sources only. sonar-project.properties measures that root, and an + # .xcresult also covers the test files themselves and the vendored + # card-reader source the suite touches. + - name: Convert coverage + run: | + ./Scripts/xccov-to-sonarqube-generic.sh --include Sources/ \ + SDKTests.xcresult > coverage.xml + + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: coverage + path: coverage.xml + + test-demo: + name: Sample app step sequences + runs-on: macos-15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - uses: ./.github/actions/ios-toolchain + # The sample app's step sequences, which decide what each screen offers # next. A separate scheme, because this bundle has no host application: # Secrets.swift is gitignored and is a member of the app target, so the app @@ -125,24 +131,12 @@ jobs: CODE_SIGNING_ALLOWED=NO \ | xcpretty && exit ${PIPESTATUS[0]} - # Sources only. sonar-project.properties measures that root, and an - # .xcresult also covers the test files themselves and the vendored - # card-reader source the suite touches. - - name: Convert coverage - run: | - ./Scripts/xccov-to-sonarqube-generic.sh --include Sources/ \ - SDKTests.xcresult > coverage.xml - - - name: Upload coverage - uses: actions/upload-artifact@v4 - with: - name: coverage - path: coverage.xml - sonar: name: SonarCloud runs-on: macos-15 - needs: [test] + # Only the SDK suite's coverage is analysed, so this does not wait on the + # sample app. + needs: [test-sdk] # A fork's pull request has no access to the token, and the scan would fail # rather than be skipped. if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository From 1ba2edbc8838d40228a4eb8e05b34379ebada34f Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 12:27:59 -0700 Subject: [PATCH 30/57] Drop xcpretty from CI and read failures from the result bundle Both test jobs installed whichever xcpretty release was newest at run time and piped the build through it. Every other tool this workflow runs is fixed: the lint binaries by checksum and the scanner action by commit. A gem resolved at run time is the one that was not, and it ran on a checked-out repository. xcodebuild's own -quiet replaces it, which also retires the `| xcpretty && exit ${PIPESTATUS[0]}` handling and leaves the exit status as xcodebuild's. -quiet names the failing tests and prints no assertion, so a new step reads the result bundle the run already writes and prints each failure with its file and line. It runs only on failure and uses xcresulttool, which ships with Xcode. Measured on a deliberately broken test: exit 65, and the step prints `StepStatusTests.swift:38: XCTAssertEqual failed: ("9") is not equal to ("1234")`. A skip reason is carried in the same field as a failure, so the script reads the test's result and a skipped test reports nothing. Co-Authored-By: Claude Opus 5 (1M context) --- .github/actions/ios-toolchain/action.yml | 4 -- .github/workflows/ci.yml | 18 ++++++-- Scripts/print-test-failures.sh | 55 ++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 8 deletions(-) create mode 100755 Scripts/print-test-failures.sh diff --git a/.github/actions/ios-toolchain/action.yml b/.github/actions/ios-toolchain/action.yml index 022d464..0b892bc 100644 --- a/.github/actions/ios-toolchain/action.yml +++ b/.github/actions/ios-toolchain/action.yml @@ -17,10 +17,6 @@ runs: xcodebuild -version swift --version - - name: Install xcpretty - shell: bash - run: gem install xcpretty --no-document - - name: Resolve dependencies shell: bash run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d88c471..fd2d98a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,8 +88,14 @@ jobs: -clonedSourcePackagesDirPath .build/checkouts \ -enableCodeCoverage YES \ -resultBundlePath SDKTests.xcresult \ - CODE_SIGNING_ALLOWED=NO \ - | xcpretty && exit ${PIPESTATUS[0]} + -quiet \ + CODE_SIGNING_ALLOWED=NO + + # -quiet names the failing tests and stops there, so the assertion comes + # back out of the result bundle. + - name: Report test failures + if: failure() + run: ./Scripts/print-test-failures.sh SDKTests.xcresult # Sources only. sonar-project.properties measures that root, and an # .xcresult also covers the test files themselves and the vendored @@ -128,8 +134,12 @@ jobs: -clonedSourcePackagesDirPath .build/checkouts \ -enableCodeCoverage YES \ -resultBundlePath DemoFlowTests.xcresult \ - CODE_SIGNING_ALLOWED=NO \ - | xcpretty && exit ${PIPESTATUS[0]} + -quiet \ + CODE_SIGNING_ALLOWED=NO + + - name: Report test failures + if: failure() + run: ./Scripts/print-test-failures.sh DemoFlowTests.xcresult sonar: name: SonarCloud diff --git a/Scripts/print-test-failures.sh b/Scripts/print-test-failures.sh new file mode 100755 index 0000000..d1ae89e --- /dev/null +++ b/Scripts/print-test-failures.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# +# Prints the assertion and its file and line for every failed test in an +# .xcresult bundle. +# +# ./Scripts/print-test-failures.sh SDKTests.xcresult +# +# `xcodebuild -quiet` names the failing tests and stops there. A name on its own +# sends the reader to a local reproduction to find out what the assertion said, +# which is the part CI already knows. Nothing here is third party: the bundle is +# already written by -resultBundlePath and xcresulttool ships with Xcode. + +set -euo pipefail + +if [ $# -eq 0 ]; then + echo "usage: $0 [...]" >&2 + exit 2 +fi + +for bundle in "$@"; do + if [ ! -e "$bundle" ]; then + echo "no result bundle at $bundle" >&2 + continue + fi + echo "=== $bundle ===" + xcrun xcresulttool get test-results tests --path "$bundle" | python3 -c ' +import json +import sys + +report = json.load(sys.stdin) +failures = [] + + +def walk(nodes, test=None, failed=False): + for node in nodes: + kind = node.get("nodeType") + name = node.get("name", "") + if kind == "Test Case": + test = name + # A skip reason is also carried as a Failure Message, and a skipped + # test is not a failing one. + failed = node.get("result") == "Failed" + elif kind == "Failure Message" and failed: + failures.append((test, name)) + walk(node.get("children") or [], test, failed) + + +walk(report.get("testNodes") or []) +for test, message in failures: + label = test or "unknown test" + print(f" {label}\n {message}") +if not failures: + print(" no failed tests recorded in this bundle") +' +done From a75140803d3bd5f989b25734c94bf5133e08a4b4 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 12:31:37 -0700 Subject: [PATCH 31/57] Say which failure recovery is recovering from The Recovery section chose its sentence from `nextAction`, and `.reinitialize` has two sources: a session that expired, and an activation the backend refused, which leaves the session `.error` with the attested identity intact. A refused code therefore read "The session expired." on screen, which it had not. The reason is now derived alongside the steps as `TapToPayRecovery`, with `nextAction` reading it, so the control and the sentence beside it cannot disagree. The view holds the three strings and no derivation, which is what moving the sequences out of the views was for. Two whole-space invariants over the 180 combinations: a reason exists exactly when a recovery control is offered, and every reason is paired with the control that fits it. Three point tests, one per reason, and one for a failing token probe, which offers no recovery because the token step is the first thing to fix. Making every reason read `.sessionExpired` fails 7 of them, naming the refused activation and the full-setup case directly. 62 tests in the demo bundle. Co-Authored-By: Claude Opus 5 (1M context) --- Example/PayabliDemo/Flow/TapToPaySteps.swift | 54 ++++++++++---- .../FlowTests/TapToPayStepsTests.swift | 71 +++++++++++++++++++ .../TapToPay/PaymentTapToPayQAView.swift | 17 +++-- 3 files changed, 125 insertions(+), 17 deletions(-) diff --git a/Example/PayabliDemo/Flow/TapToPaySteps.swift b/Example/PayabliDemo/Flow/TapToPaySteps.swift index 601e33a..0eb8c27 100644 --- a/Example/PayabliDemo/Flow/TapToPaySteps.swift +++ b/Example/PayabliDemo/Flow/TapToPaySteps.swift @@ -37,11 +37,30 @@ struct TapToPayFlowSteps { /// something. let nextAction: TapToPayAction? + /// Why recovery is on offer, which is what the section says out loud. + /// + /// `nextAction` cannot say it. `.reinitialize` is the way out of an expired + /// session and of a refused activation both, and those are different + /// sentences: one session expired and the other never did. + let recovery: TapToPayRecovery? + var all: [FlowStep] { [token, enable, activation, charge] } } +/// What the screen is recovering from. +enum TapToPayRecovery { + /// The session expired holding its attested identity. + case sessionExpired + /// Activation was refused. The request reached the backend, so the attested + /// identity is intact. + case activationRefused + /// Any other `.error`, which may be the config 401 that clears the attested + /// identity. + case sessionErrored +} + /// A control on the Tap to Pay screen. enum TapToPayAction { case checkToken @@ -148,24 +167,32 @@ enum TapToPaySteps { // wants attention is the only thing offered. Re-initialize sits behind // the token step because it re-runs config, which a backend known to be // down cannot answer. + // Derived before `nextAction`, which reads it, so the control and the + // sentence beside it cannot disagree about what went wrong. + let recovery: TapToPayRecovery? = { + guard !token.isActionable, token.isFinished else { return nil } + if session == .sessionExpired { + return .sessionExpired + } + if session == .error { + return outcome == .activationFailed ? .activationRefused : .sessionErrored + } + return nil + }() + let nextAction: TapToPayAction? = { if token.isActionable { return .checkToken } guard token.isFinished else { return nil } // A session that expired still holds its attested identity, so - // re-running config and the reader is enough. `.error` is where a - // config 401 lands, and that path clears the attestation cache, so - // skipping attestation would fail on the assertion every time and - // offer the same control again. - if session == .sessionExpired { - return .reinitialize - } - if session == .error { - // A refused activation reached the backend, so the attested - // identity is intact and config plus reader is enough. Any other - // `.error` may be the config 401, which clears it. - return outcome == .activationFailed ? .reinitialize : .reattest + // re-running config and the reader is enough, and so does a refused + // activation, whose request reached the backend. `.error` otherwise + // is where a config 401 lands, and that path clears the attestation + // cache, so skipping attestation would fail on the assertion every + // time and offer the same control again. + if let recovery { + return recovery == .sessionErrored ? .reattest : .reinitialize } // `.failed` as well as `.current`: an enable that failed is retried // from its own row, and this is the state a broken session does not @@ -207,7 +234,8 @@ enum TapToPaySteps { detail: "Presents Apple's Tap to Pay sheet. Hold a card to the top of the phone.", status: charge ), - nextAction: nextAction + nextAction: nextAction, + recovery: recovery ) } } diff --git a/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift b/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift index 36457c4..6db3cb2 100644 --- a/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift +++ b/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift @@ -69,6 +69,77 @@ final class TapToPayStepsTests: XCTestCase { } } + func testRecoveryIsGivenAReasonExactlyWhenItIsOffered() { + for combination in everyCombination { + let sequence = steps(combination) + let offered = sequence.nextAction == .reinitialize || sequence.nextAction == .reattest + XCTAssertEqual( + sequence.recovery != nil, offered, + "\(combination) offers \(String(describing: sequence.nextAction)) " + + "with reason \(String(describing: sequence.recovery))" + ) + } + } + + func testEveryRecoveryReasonMatchesTheControlBesideIt() { + // The screen writes one sentence per reason, so a reason paired with the + // wrong control is a sentence describing something that did not happen. + for combination in everyCombination { + let sequence = steps(combination) + guard let recovery = sequence.recovery else { continue } + let expected: TapToPayAction = recovery == .sessionErrored ? .reattest : .reinitialize + XCTAssertEqual( + sequence.nextAction, expected, + "\(combination) pairs \(recovery) with \(String(describing: sequence.nextAction))" + ) + } + } + + // MARK: - The three reasons, one at a time + + func testARefusedActivationDoesNotClaimTheSessionExpired() { + // Both this and an expired session offer Re-initialize, so the control + // cannot tell them apart and the reason is what the screen reads from. + let sequence = TapToPaySteps.forCharging( + tokenCheck: .reachable, + session: .error, + activation: .activationFailed + ) + XCTAssertEqual(sequence.recovery, .activationRefused) + XCTAssertEqual(sequence.nextAction, .reinitialize) + } + + func testAnExpiredSessionSaysItExpired() { + let sequence = TapToPaySteps.forCharging( + tokenCheck: .reachable, + session: .sessionExpired, + activation: .none + ) + XCTAssertEqual(sequence.recovery, .sessionExpired) + XCTAssertEqual(sequence.nextAction, .reinitialize) + } + + func testAnyOtherErrorRunsTheFullSetup() { + let sequence = TapToPaySteps.forCharging( + tokenCheck: .reachable, + session: .error, + activation: .none + ) + XCTAssertEqual(sequence.recovery, .sessionErrored) + XCTAssertEqual(sequence.nextAction, .reattest) + } + + func testAFailingTokenProbeOffersNoRecovery() { + // Recovery sits behind the token step, which is the first thing to fix. + let sequence = TapToPaySteps.forCharging( + tokenCheck: .unreachable, + session: .sessionExpired, + activation: .none + ) + XCTAssertNil(sequence.recovery) + XCTAssertEqual(sequence.nextAction, .checkToken) + } + func testEveryStepAfterAnUnfinishedOneIsBlocked() { for combination in everyCombination { let all = steps(combination).all diff --git a/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift b/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift index 4fca122..e1707ac 100644 --- a/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift +++ b/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift @@ -151,17 +151,26 @@ struct PaymentTapToPayQAView: View { } } + private func recoveryDetail(_ recovery: TapToPayRecovery) -> String { + switch recovery { + case .sessionExpired: + "The session expired. This re-runs config and reader setup without a fresh attestation." + case .activationRefused: + "Activation was refused. The attested identity is intact, so this re-runs config and reader setup." + case .sessionErrored: + "The session errored. A config 401 clears the attested identity, so this runs the full setup." + } + } + /// Recovery is not part of the sequence, so it only appears when the session /// is in a state it can actually repair. @ViewBuilder private var recoverySection: some View { - if steps.nextAction == .reinitialize || steps.nextAction == .reattest { + if let recovery = steps.recovery { VStack(alignment: .leading, spacing: 8) { Text("Recovery") .font(.headline) - Text(steps.nextAction == .reinitialize - ? "The session expired. This re-runs config and reader setup without a fresh attestation." - : "The session errored. A config 401 clears the attested identity, so this runs the full setup.") + Text(recoveryDetail(recovery)) .font(.caption) .foregroundColor(.payabliOnSurfaceVariant) Button { From c356886fd0df8944cc3bd382569103a242a38d8c Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 12:56:23 -0700 Subject: [PATCH 32/57] Report what a change touched, and what is only formatting A branch that runs `swiftformat .` puts hundreds of files in the diff, and the file count does not say which of them changed what the code does. Answering it by hand took a build of both commits and a comparison of every object's __TEXT,__text. `Scripts/classify-changes.sh` answers the cheap half automatically. It takes each modified file under Sources/ as it was at the fork point, runs the formatter over it, and compares against the head version. An exact match means the whole change is what the formatter would have produced. It also lists the paths added, deleted and renamed, which a file count hides. On this branch it reports 36 files of formatter output and 6 carrying an edit, which is what the object comparison found. It runs as its own job on pull requests and writes to the run summary. The job is continue-on-error and the script exits 0 whatever it finds, because a report that can redden a pull request is a gate and this is not one. Sonar cannot stand in for it: it measures new code and has no notion of a line that only moved. The checksummed tool install moves into a composite action, since the lint job and this one both need the formatter. Co-Authored-By: Claude Opus 5 (1M context) --- .github/actions/lint-tools/action.yml | 42 +++++++++ .github/workflows/ci.yml | 63 +++++++------- Scripts/classify-changes.sh | 120 ++++++++++++++++++++++++++ 3 files changed, 191 insertions(+), 34 deletions(-) create mode 100644 .github/actions/lint-tools/action.yml create mode 100755 Scripts/classify-changes.sh diff --git a/.github/actions/lint-tools/action.yml b/.github/actions/lint-tools/action.yml new file mode 100644 index 0000000..ca54679 --- /dev/null +++ b/.github/actions/lint-tools/action.yml @@ -0,0 +1,42 @@ +name: Lint tools +description: Installs swiftformat and swiftlint, verified by checksum. + +# Pinned by content, not by name. Both tools decide whether the lint job passes, +# and .swiftformat names the version its four disabled rules were checked +# against, so a change in either would alter the tree or redden every pull +# request without a commit here. +# +# A release tag is not immutable: a publisher can delete an asset and upload +# another under the same URL. The checksums below are what makes the bytes +# fixed, and they are verified before anything is unpacked or run. Raising a +# version means replacing its checksum in the same change, with `swiftformat .` +# re-run alongside it. + +runs: + using: composite + steps: + - name: Install lint tools + shell: bash + env: + SWIFTFORMAT_VERSION: 0.62.1 + SWIFTFORMAT_SHA256: 7cb1cb1fae04932047c7015441c543848e8e60e1572d808d080e0a1f1661114a + SWIFTLINT_VERSION: 0.65.0 + SWIFTLINT_SHA256: d6cb0aa7a2f5f1ef306fc9e37bcb54dc9a26facc8f7784ac0c3dd3eccf5c6ba6 + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/tools" + + curl -sSfL -o "$RUNNER_TEMP/swiftformat.zip" \ + "https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFTFORMAT_VERSION}/swiftformat.zip" + echo "${SWIFTFORMAT_SHA256} ${RUNNER_TEMP}/swiftformat.zip" | shasum -a 256 -c - + + curl -sSfL -o "$RUNNER_TEMP/swiftlint.zip" \ + "https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/portable_swiftlint.zip" + echo "${SWIFTLINT_SHA256} ${RUNNER_TEMP}/swiftlint.zip" | shasum -a 256 -c - + + unzip -q -j -o "$RUNNER_TEMP/swiftformat.zip" -d "$RUNNER_TEMP/tools" + unzip -q -j -o "$RUNNER_TEMP/swiftlint.zip" -d "$RUNNER_TEMP/tools" + chmod +x "$RUNNER_TEMP/tools/swiftformat" "$RUNNER_TEMP/tools/swiftlint" + echo "$RUNNER_TEMP/tools" >> "$GITHUB_PATH" + "$RUNNER_TEMP/tools/swiftformat" --version + "$RUNNER_TEMP/tools/swiftlint" version diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd2d98a..1bef368 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,40 +28,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 - # Pinned by content, not by name. Both tools decide whether this job - # passes, and .swiftformat names the version its four disabled rules were - # checked against, so a change in either would alter the tree or redden - # every pull request without a commit here. - # - # A release tag is not immutable: a publisher can delete an asset and - # upload another under the same URL. The checksums below are what makes - # the bytes fixed, and they are verified before anything is unpacked or - # run. Raising a version means replacing its checksum in the same change, - # with `swiftformat .` re-run alongside it. - - name: Install lint tools - env: - SWIFTFORMAT_VERSION: 0.62.1 - SWIFTFORMAT_SHA256: 7cb1cb1fae04932047c7015441c543848e8e60e1572d808d080e0a1f1661114a - SWIFTLINT_VERSION: 0.65.0 - SWIFTLINT_SHA256: d6cb0aa7a2f5f1ef306fc9e37bcb54dc9a26facc8f7784ac0c3dd3eccf5c6ba6 - run: | - set -euo pipefail - mkdir -p "$RUNNER_TEMP/tools" - - curl -sSfL -o "$RUNNER_TEMP/swiftformat.zip" \ - "https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFTFORMAT_VERSION}/swiftformat.zip" - echo "${SWIFTFORMAT_SHA256} ${RUNNER_TEMP}/swiftformat.zip" | shasum -a 256 -c - - - curl -sSfL -o "$RUNNER_TEMP/swiftlint.zip" \ - "https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/portable_swiftlint.zip" - echo "${SWIFTLINT_SHA256} ${RUNNER_TEMP}/swiftlint.zip" | shasum -a 256 -c - - - unzip -q -j -o "$RUNNER_TEMP/swiftformat.zip" -d "$RUNNER_TEMP/tools" - unzip -q -j -o "$RUNNER_TEMP/swiftlint.zip" -d "$RUNNER_TEMP/tools" - chmod +x "$RUNNER_TEMP/tools/swiftformat" "$RUNNER_TEMP/tools/swiftlint" - echo "$RUNNER_TEMP/tools" >> "$GITHUB_PATH" - "$RUNNER_TEMP/tools/swiftformat" --version - "$RUNNER_TEMP/tools/swiftlint" version + - uses: ./.github/actions/lint-tools # No `--config`: naming a config file makes SwiftLint ignore nested ones, # and Tests/.swiftlint.yml is what relaxes the rules XCTest fixtures break. @@ -70,6 +37,34 @@ jobs: swiftlint swiftformat --lint . + # Reports, never judges. A branch that runs `swiftformat .` puts hundreds of + # files in the diff, and the file count alone does not say which of them + # changed what the code does. This says so on the run's summary page. + # + # continue-on-error, and the script exits 0 whatever it finds: a report that + # can redden a pull request is a gate, and this is not one. + changes: + name: Change report + runs-on: macos-15 + if: github.event_name == 'pull_request' + continue-on-error: true + + steps: + # The classification reads both sides of the change, so it needs history. + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: ./.github/actions/lint-tools + + - name: Classify the diff + run: | + ./Scripts/classify-changes.sh \ + "${{ github.event.pull_request.base.sha }}" \ + "${{ github.event.pull_request.head.sha }}" \ + >> "$GITHUB_STEP_SUMMARY" + test-sdk: name: SDK tests runs-on: macos-15 diff --git a/Scripts/classify-changes.sh b/Scripts/classify-changes.sh new file mode 100755 index 0000000..f7b9035 --- /dev/null +++ b/Scripts/classify-changes.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# +# Reports which paths a change touches, and which of them carry an edit of their +# own rather than formatter output. +# +# ./Scripts/classify-changes.sh main HEAD +# ./Scripts/classify-changes.sh main HEAD >> "$GITHUB_STEP_SUMMARY" +# +# A branch that runs `swiftformat .` puts hundreds of files in the diff, and a +# reviewer cannot see from the file count which of them changed what the code +# does. Each file under Sources/ is taken as it was at the base, run through the +# formatter, and compared against its head version. An exact match means the +# whole change is what the formatter would have produced. +# +# This reports and never judges. It writes Markdown, exits 0 whatever it finds, +# and nothing here is a gate. + +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BASE_REF="${1:?usage: $0 }" +HEAD_REF="${2:?usage: $0 }" + +cd "$REPO_ROOT" + +# The fork point, so a base branch that moved on does not show up as this +# change's work. +BASE=$(git merge-base "$BASE_REF" "$HEAD_REF" 2>/dev/null) || BASE="$BASE_REF" +HEAD_SHA=$(git rev-parse "$HEAD_REF") + +echo "## Change report" +echo +echo "\`$(git rev-parse --short "$BASE")…$(git rev-parse --short "$HEAD_SHA")\`" +echo + +TOTAL=$(git diff --name-only "$BASE..$HEAD_SHA" | wc -l | tr -d ' ') +echo "$TOTAL files changed." +echo + +echo "### Where" +echo +echo "| Area | Files |" +echo "| --- | ---: |" +git diff --name-only "$BASE..$HEAD_SHA" \ + | awk -F/ '{ print (NF > 1 ? $1 "/" $2 : $1) }' \ + | sort | uniq -c | sort -rn \ + | awk '{ printf "| `%s` | %d |\n", $2, $1 }' +echo + +# Added, deleted and renamed paths, which a file count hides. +echo "### Paths added, deleted or renamed" +echo +NOTABLE=$(git diff --name-status --find-renames "$BASE..$HEAD_SHA" | grep -vE '^M' || true) +if [ -z "$NOTABLE" ]; then + echo "None. Every changed file already existed at the base." +else + echo '```' + printf '%s\n' "$NOTABLE" + echo '```' +fi +echo + +# Only Sources/ ships. Tests/ and Example/ change on purpose and are not in the +# built product. +CHANGED_SOURCES=$(git diff --name-only --diff-filter=M "$BASE..$HEAD_SHA" -- Sources/) +if [ -z "$CHANGED_SOURCES" ]; then + echo "### Shipped code" + echo + echo "No file under \`Sources/\` was modified." + exit 0 +fi + +if ! command -v swiftformat >/dev/null 2>&1; then + echo "### Shipped code" + echo + echo "swiftformat is not on PATH, so the formatting classification was skipped." + echo "Modified under \`Sources/\`:" + echo '```' + printf '%s\n' "$CHANGED_SOURCES" + echo '```' + exit 0 +fi + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +pure=0 +edited="" +while IFS= read -r path; do + [ -n "$path" ] || continue + mkdir -p "$WORK/$(dirname "$path")" + git show "$BASE:$path" > "$WORK/$path" 2>/dev/null || continue + swiftformat "$WORK/$path" --config "$REPO_ROOT/.swiftformat" --quiet >/dev/null 2>&1 + git show "$HEAD_SHA:$path" > "$WORK/head-version" 2>/dev/null || continue + if cmp -s "$WORK/$path" "$WORK/head-version"; then + pure=$((pure + 1)) + else + lines=$(diff "$WORK/$path" "$WORK/head-version" | grep -cE '^[<>]') + edited="${edited}${lines} ${path}"$'\n' + fi +done <<< "$CHANGED_SOURCES" + +edited_count=$(printf '%s' "$edited" | grep -c . || true) + +echo "### Shipped code (\`Sources/\`)" +echo +echo "| | Files |" +echo "| --- | ---: |" +echo "| Formatter output only, no edit of its own | $pure |" +echo "| Carry an edit of their own | $edited_count |" +echo + +if [ "$edited_count" -gt 0 ]; then + echo "These are the files to read. The count is lines that differ once formatting" + echo "is accounted for, and it counts a comment the same as a statement." + echo + echo "| Changed lines | File |" + echo "| ---: | --- |" + printf '%s' "$edited" | sort -rn | awk -F'\t' 'NF == 2 { printf "| %s | `%s` |\n", $1, $2 }' +fi From fce6148df90568a85529115cd101a1f7ff6804d8 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 13:39:20 -0700 Subject: [PATCH 33/57] Run the full setup after any errored session, including a failed activation Recovery offered the cheaper Re-initialize for a failed activation, on the premise that reaching the backend at all means the device attested. The premise does not hold. `generateAssertion` clears the cached key and device on a DeviceCheck error and throws before the request is sent, and that error is not a `PayabliTTPError`, so `activateDevice` maps it to `.activationFailed` and marks the session `.error`. The identity is already gone, and `reinitializeIfNeeded` skips attestation, so it would fail on the assertion and offer itself again. Only a 401 from `/activate` is reported as `.attestationRevoked`, and that case already resets the session to `.idle` and is answered by the enable step. What is left under `.error` cannot be told apart from the outside, so every one of them now runs `initialize`, which re-uses a cached attestation and attests again when there is none. `TapToPayRecovery` loses `.activationRefused`. The activation step still reports the failure and its reason; only the recovery control changes. Reverting the one line fails 4 tests. 61 in the demo bundle. Co-Authored-By: Claude Opus 5 (1M context) --- Example/PayabliDemo/Flow/StepStatus.swift | 2 +- Example/PayabliDemo/Flow/TapToPaySteps.swift | 33 ++++++++------ .../FlowTests/TapToPayStepsTests.swift | 44 ++++++++----------- .../TapToPay/PaymentTapToPayQAView.swift | 4 +- 4 files changed, 41 insertions(+), 42 deletions(-) diff --git a/Example/PayabliDemo/Flow/StepStatus.swift b/Example/PayabliDemo/Flow/StepStatus.swift index 0fd02c7..1bbbedb 100644 --- a/Example/PayabliDemo/Flow/StepStatus.swift +++ b/Example/PayabliDemo/Flow/StepStatus.swift @@ -27,7 +27,7 @@ enum StepStatus { self == .current || self == .failed } - /// Whether the step after this one may proceed. A skipped step counts as + /// Whether the step after this one is free to proceed. A skipped step counts as /// finished. /// /// A step that reads the state underneath instead can offer itself alongside diff --git a/Example/PayabliDemo/Flow/TapToPaySteps.swift b/Example/PayabliDemo/Flow/TapToPaySteps.swift index 0eb8c27..ae6c831 100644 --- a/Example/PayabliDemo/Flow/TapToPaySteps.swift +++ b/Example/PayabliDemo/Flow/TapToPaySteps.swift @@ -30,18 +30,20 @@ struct TapToPayFlowSteps { /// retry: a refused activation leaves the session `.error`, and /// `activateDevice` throws `.invalidState` for anything but /// `.pendingActivation`, so the activation step shows the reason while - /// Re-initialize is the way forward. + /// recovery is the way forward. /// /// Recovery lives here for the same reason. It is not a step, and while it - /// was a flag of its own it could appear beside a step still asking for + /// was a flag of its own it appeared beside a step still asking for /// something. let nextAction: TapToPayAction? /// Why recovery is on offer, which is what the section says out loud. /// - /// `nextAction` cannot say it. `.reinitialize` is the way out of an expired - /// session and of a refused activation both, and those are different - /// sentences: one session expired and the other never did. + /// Two reasons and two controls, one each, as it stands. The sentence is + /// keyed on the reason because a control serves whichever reasons share it: + /// `.reinitialize` answered both an expired session and a refused activation + /// until the DeviceCheck path was traced, and the sentence beside it named + /// expiry for both. let recovery: TapToPayRecovery? var all: [FlowStep] { @@ -53,11 +55,16 @@ struct TapToPayFlowSteps { enum TapToPayRecovery { /// The session expired holding its attested identity. case sessionExpired - /// Activation was refused. The request reached the backend, so the attested - /// identity is intact. - case activationRefused - /// Any other `.error`, which may be the config 401 that clears the attested - /// identity. + /// The session errored. Some of the paths here clear the attested identity + /// and the rest keep it, and which one ran is not knowable from the outside, + /// so recovery runs the setup that works for both. + /// + /// A failed activation is one of them. `.activationFailed` does not + /// establish that `/activate` reached the backend: `generateAssertion` + /// clears the cached key and device on a DeviceCheck error and throws before + /// the request is sent, and that error is not a `PayabliTTPError`, so it + /// arrives as a plain `.activationFailed` with the identity already gone. + /// Only a 401 from `/activate` itself is reported as `.attestationRevoked`. case sessionErrored } @@ -71,8 +78,8 @@ enum TapToPayAction { /// attestation. Only sound where the attested identity is still held. case reinitialize /// `initialize`, the full setup. It re-uses a cached attestation and runs a - /// cold one when there is none, so it is the way back from a session whose - /// identity may have been cleared. + /// cold one when there is none, so it is the way back from a session that + /// still holds its attested identity and from one that has lost it. case reattest } @@ -175,7 +182,7 @@ enum TapToPaySteps { return .sessionExpired } if session == .error { - return outcome == .activationFailed ? .activationRefused : .sessionErrored + return .sessionErrored } return nil }() diff --git a/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift b/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift index 6db3cb2..ed02ca2 100644 --- a/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift +++ b/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift @@ -88,6 +88,7 @@ final class TapToPayStepsTests: XCTestCase { let sequence = steps(combination) guard let recovery = sequence.recovery else { continue } let expected: TapToPayAction = recovery == .sessionErrored ? .reattest : .reinitialize + // Two reasons, two controls, and the mapping is total. XCTAssertEqual( sequence.nextAction, expected, "\(combination) pairs \(recovery) with \(String(describing: sequence.nextAction))" @@ -95,19 +96,7 @@ final class TapToPayStepsTests: XCTestCase { } } - // MARK: - The three reasons, one at a time - - func testARefusedActivationDoesNotClaimTheSessionExpired() { - // Both this and an expired session offer Re-initialize, so the control - // cannot tell them apart and the reason is what the screen reads from. - let sequence = TapToPaySteps.forCharging( - tokenCheck: .reachable, - session: .error, - activation: .activationFailed - ) - XCTAssertEqual(sequence.recovery, .activationRefused) - XCTAssertEqual(sequence.nextAction, .reinitialize) - } + // MARK: - Each reason, one at a time func testAnExpiredSessionSaysItExpired() { let sequence = TapToPaySteps.forCharging( @@ -212,12 +201,12 @@ final class TapToPayStepsTests: XCTestCase { } func testAnErroredSessionIsOfferedTheFullSetup() { - // A config 401 clears the attested identity and marks the session - // `.error`. `reinitializeIfNeeded` skips attestation and asserts against - // an identity that is gone, so it would fail and offer itself again. - for combination in everyCombination - where combination.session == .error && combination.outcome != .activationFailed - { + // Two paths clear the attested identity and mark the session `.error`: + // a config 401, and a `generateAssertion` that fails on a DeviceCheck + // error, which surfaces as `.activationFailed`. `reinitializeIfNeeded` + // skips attestation and asserts against an identity that is gone, so it + // would fail and offer itself again. + for combination in everyCombination where combination.session == .error { let sequence = steps(combination) // A failed probe holds the sequence, and one in flight offers // nothing at all. @@ -226,13 +215,18 @@ final class TapToPayStepsTests: XCTestCase { } } - func testARefusedActivationKeepsTheCheaperRecovery() { - // Reaching the backend at all means the device attested, and only a - // revoked attestation clears that identity. + func testAFailedActivationIsOfferedTheFullSetup() { + // `.activationFailed` does not establish that `/activate` reached the + // backend. `generateAssertion` clears the cached key and device on a + // DeviceCheck error and throws before the request is sent, and that error + // is not a `PayabliTTPError`, so it arrives here indistinguishable from a + // decline. Only a 401 from `/activate` is reported as + // `.attestationRevoked`. let sequence = TapToPaySteps.forCharging( tokenCheck: .reachable, session: .error, activation: .activationFailed ) - XCTAssertEqual(sequence.nextAction, .reinitialize) + XCTAssertEqual(sequence.recovery, .sessionErrored) + XCTAssertEqual(sequence.nextAction, .reattest) } func testAnExpiredSessionKeepsTheCheaperRecovery() { @@ -379,8 +373,8 @@ final class TapToPayStepsTests: XCTestCase { XCTAssertEqual(sequence.activation.status, .failed) XCTAssertTrue(sequence.activation.status.showsContent) XCTAssertEqual(sequence.charge.status, .blocked) - // The reason shows; Re-initialize is the way forward. - XCTAssertEqual(sequence.nextAction, .reinitialize) + // The reason shows; the full setup is the way forward. + XCTAssertEqual(sequence.nextAction, .reattest) } func testNoRecordedActivationFailureIsAnsweredByAnEarlierStep() { diff --git a/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift b/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift index e1707ac..2351758 100644 --- a/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift +++ b/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift @@ -155,10 +155,8 @@ struct PaymentTapToPayQAView: View { switch recovery { case .sessionExpired: "The session expired. This re-runs config and reader setup without a fresh attestation." - case .activationRefused: - "Activation was refused. The attested identity is intact, so this re-runs config and reader setup." case .sessionErrored: - "The session errored. A config 401 clears the attested identity, so this runs the full setup." + "The session errored. This runs the full setup, re-using the attested identity when it is still held and attesting again when it is not." } } From 6b8cb0e5af49b14c166c0b646638b0eae4332f08 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 13:40:58 -0700 Subject: [PATCH 34/57] Post the change report to the pull request The report was written to the run summary, which is a page nobody opens while reading a diff. It now also goes to the pull request as a comment. One comment, edited in place, found by a marker the script writes into the Markdown. A new comment per push would bury the review under reports of itself. The job names `contents: read` alongside `pull-requests: write`, because naming any scope sets every other one to none and the checkout needs its own read. The comment step is continue-on-error on top of the job's: a fork's pull request carries a read-only token, and a report that cannot be posted is not a reason to fail anything. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 32 +++++++++++++++++++++++++++++++- Scripts/classify-changes.sh | 3 +++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1bef368..a52513d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,11 @@ jobs: runs-on: macos-15 if: github.event_name == 'pull_request' continue-on-error: true + # Naming any scope sets every other one to none, so the checkout's own read + # has to be named alongside the write this job is here for. + permissions: + contents: read + pull-requests: write steps: # The classification reads both sides of the change, so it needs history. @@ -63,7 +68,32 @@ jobs: ./Scripts/classify-changes.sh \ "${{ github.event.pull_request.base.sha }}" \ "${{ github.event.pull_request.head.sha }}" \ - >> "$GITHUB_STEP_SUMMARY" + > report.md + cat report.md >> "$GITHUB_STEP_SUMMARY" + + # One comment that is edited in place, found by the marker the script + # writes. A new comment on every push buries the review under reports of + # itself. A fork's pull request has a read-only token, so this is allowed + # to fail without taking the job with it. + - name: Comment on the pull request + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + # jq slurps the pages into one array. --jq would run once per page, so + # `last` would be the last match on the last page and not overall. + existing=$(gh api "repos/$REPO/issues/$PR/comments" --paginate \ + | jq -s 'add | map(select(.body | contains(""))) | last | .id // empty') + if [ -n "$existing" ]; then + gh api -X PATCH "repos/$REPO/issues/comments/$existing" -F body=@report.md >/dev/null + echo "updated comment $existing" + else + gh api -X POST "repos/$REPO/issues/$PR/comments" -F body=@report.md >/dev/null + echo "posted a new comment" + fi test-sdk: name: SDK tests diff --git a/Scripts/classify-changes.sh b/Scripts/classify-changes.sh index f7b9035..9ad9902 100755 --- a/Scripts/classify-changes.sh +++ b/Scripts/classify-changes.sh @@ -28,6 +28,9 @@ cd "$REPO_ROOT" BASE=$(git merge-base "$BASE_REF" "$HEAD_REF" 2>/dev/null) || BASE="$BASE_REF" HEAD_SHA=$(git rev-parse "$HEAD_REF") +# The marker CI finds this comment by, so each run edits the one comment +# instead of posting another. Invisible when the Markdown renders. +echo "" echo "## Change report" echo echo "\`$(git rev-parse --short "$BASE")…$(git rev-parse --short "$HEAD_SHA")\`" From 3fa8cf2488d76a115c5854e920dd439527da392b Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 13:50:05 -0700 Subject: [PATCH 35/57] Report what the change means, not what it measures The report listed directories and file counts and left the reader to work out what any of it implied. It now answers the questions a reviewer opens a diff with. Files are grouped by review surface: production code, which is the only category linked into a consumer's app, then test code, the sample app, bridge wrappers, and build and CI. A directory name does not say which of those it is. Modified production files are classified by whether the change can alter behaviour at all, and each classification carries the action it implies: formatting is inert, comments compile to the same code, and declarations and statements are what to review. The per-file counts separate the two, so a file whose whole diff is documentation is not sitting in the review list. Counts are net of relocations. A line moved without being altered compiles to what it compiled to before, and counting both ends of a moved block reports a rewrite where nothing changed. On this branch that is the difference between naming six files as behavioural and the five that actually are: the sixth moves a compilation condition and its documentation, and its object code is identical. A new section reports whether any line carrying `public` or `open` moved, since that is the contract consumers compile against and the one this repository holds in common with the SDK for Android. A rename below R100 is called out, because a file that moved and was edited in one commit reads as a move to anyone following the new path. Co-Authored-By: Claude Opus 5 (1M context) --- Scripts/classify-changes.sh | 220 ++++++++++++++++++++++++++++-------- 1 file changed, 170 insertions(+), 50 deletions(-) diff --git a/Scripts/classify-changes.sh b/Scripts/classify-changes.sh index 9ad9902..00f495e 100755 --- a/Scripts/classify-changes.sh +++ b/Scripts/classify-changes.sh @@ -1,19 +1,19 @@ #!/usr/bin/env bash # -# Reports which paths a change touches, and which of them carry an edit of their -# own rather than formatter output. +# Sizes the review surface of a change: how much of a diff is a semantic change +# to shipped code, and how much is inert. # # ./Scripts/classify-changes.sh main HEAD # ./Scripts/classify-changes.sh main HEAD >> "$GITHUB_STEP_SUMMARY" # -# A branch that runs `swiftformat .` puts hundreds of files in the diff, and a -# reviewer cannot see from the file count which of them changed what the code -# does. Each file under Sources/ is taken as it was at the base, run through the -# formatter, and compared against its head version. An exact match means the -# whole change is what the formatter would have produced. +# A branch that runs `swiftformat .` puts hundreds of files in the diff and a +# file count says nothing about what to read. Every modified file under Sources/ +# is normalised by running the formatter over its base revision, so what remains +# in the diff is the author's edit. Those lines are then split into declarations, +# executable statements and comments, because only the first two can change +# behaviour and only the first can break a consumer. # -# This reports and never judges. It writes Markdown, exits 0 whatever it finds, -# and nothing here is a gate. +# Reports and never judges. Writes Markdown, exits 0 whatever it finds. set -uo pipefail @@ -23,8 +23,6 @@ HEAD_REF="${2:?usage: $0 }" cd "$REPO_ROOT" -# The fork point, so a base branch that moved on does not show up as this -# change's work. BASE=$(git merge-base "$BASE_REF" "$HEAD_REF" 2>/dev/null) || BASE="$BASE_REF" HEAD_SHA=$(git rev-parse "$HEAD_REF") @@ -33,51 +31,77 @@ HEAD_SHA=$(git rev-parse "$HEAD_REF") echo "" echo "## Change report" echo -echo "\`$(git rev-parse --short "$BASE")…$(git rev-parse --short "$HEAD_SHA")\`" +echo "\`$(git rev-parse --short "$BASE")…$(git rev-parse --short "$HEAD_SHA")\` · $(git diff --name-only "$BASE..$HEAD_SHA" | wc -l | tr -d ' ') files" echo -TOTAL=$(git diff --name-only "$BASE..$HEAD_SHA" | wc -l | tr -d ' ') -echo "$TOTAL files changed." -echo +# ---------------------------------------------------------------- review surface + +# Sources/ is the library that ships to consumers. Nothing else in the tree is +# linked into their app, so a change outside it cannot alter released behaviour. +classify_path() { + case "$1" in + Sources/*) echo "Production code (ships in the SDK)" ;; + Tests/*) echo "Test code" ;; + Example/*) echo "Sample app" ;; + Bridges/*) echo "Bridge wrappers" ;; + .github/*|Scripts/*|*.yml|*.xcconfig|.swiftformat|.swiftlint.yml|sonar-project.properties|Package.swift|.gitignore) + echo "Build, CI and tooling" ;; + *) echo "Other" ;; + esac +} -echo "### Where" +echo "### Review surface" echo -echo "| Area | Files |" +echo "| Category | Files |" echo "| --- | ---: |" -git diff --name-only "$BASE..$HEAD_SHA" \ - | awk -F/ '{ print (NF > 1 ? $1 "/" $2 : $1) }' \ - | sort | uniq -c | sort -rn \ - | awk '{ printf "| `%s` | %d |\n", $2, $1 }' +while IFS= read -r path; do + classify_path "$path" +done < <(git diff --name-only "$BASE..$HEAD_SHA") | sort | uniq -c | sort -rn \ + | sed -E 's/^ *([0-9]+) (.*)$/| \2 | \1 |/' echo -# Added, deleted and renamed paths, which a file count hides. -echo "### Paths added, deleted or renamed" +# ------------------------------------------------------------- lifecycle of files + +# A rename similarity below 100 means the file moved and was edited in the same +# commit, which a reviewer reading only the new path would miss. +echo "### Files added, deleted and renamed" echo -NOTABLE=$(git diff --name-status --find-renames "$BASE..$HEAD_SHA" | grep -vE '^M' || true) -if [ -z "$NOTABLE" ]; then - echo "None. Every changed file already existed at the base." +LIFECYCLE=$(git diff --name-status --find-renames "$BASE..$HEAD_SHA" | grep -vE '^M' || true) +if [ -z "$LIFECYCLE" ]; then + echo "None. Every changed file already existed at the base revision, so no" + echo "compilation unit entered or left the build." else + added=$(printf '%s\n' "$LIFECYCLE" | grep -c '^A' || true) + deleted=$(printf '%s\n' "$LIFECYCLE" | grep -c '^D' || true) + renamed=$(printf '%s\n' "$LIFECYCLE" | grep -c '^R' || true) + echo "$added added, $deleted deleted, $renamed renamed." + echo echo '```' - printf '%s\n' "$NOTABLE" + printf '%s\n' "$LIFECYCLE" echo '```' + if printf '%s\n' "$LIFECYCLE" | grep -qE '^R(0[0-9][0-9]|1[0-9][0-9])' ; then + echo + echo "A rename shown below \`R100\` was edited as well as moved." + fi fi echo -# Only Sources/ ships. Tests/ and Example/ change on purpose and are not in the -# built product. +# ------------------------------------------------------------------ shipped code + CHANGED_SOURCES=$(git diff --name-only --diff-filter=M "$BASE..$HEAD_SHA" -- Sources/) if [ -z "$CHANGED_SOURCES" ]; then - echo "### Shipped code" + echo "### Production code" echo - echo "No file under \`Sources/\` was modified." + echo "No existing file under \`Sources/\` was modified, so released behaviour" + echo "is unchanged except by any file added or deleted above." exit 0 fi if ! command -v swiftformat >/dev/null 2>&1; then - echo "### Shipped code" + echo "### Production code" echo - echo "swiftformat is not on PATH, so the formatting classification was skipped." - echo "Modified under \`Sources/\`:" + echo "swiftformat is not on PATH, so the diff could not be normalised and the" + echo "semantic classification was skipped. Modified under \`Sources/\`:" echo '```' printf '%s\n' "$CHANGED_SOURCES" echo '```' @@ -87,37 +111,133 @@ fi WORK=$(mktemp -d) trap 'rm -rf "$WORK"' EXIT -pure=0 -edited="" +inert=0 # files whose entire diff the formatter would have produced +doc_only=0 # files whose remaining diff is comments and blank lines +semantic=0 # files with a changed declaration or statement +rows="" # per-file detail for the files that carry a semantic change +doc_rows="" +api_added="" +api_removed="" + while IFS= read -r path; do [ -n "$path" ] || continue mkdir -p "$WORK/$(dirname "$path")" git show "$BASE:$path" > "$WORK/$path" 2>/dev/null || continue + # Normalising the base revision is what separates the author's edit from the + # formatter's. Without it every reformatted file looks like a rewrite. swiftformat "$WORK/$path" --config "$REPO_ROOT/.swiftformat" --quiet >/dev/null 2>&1 git show "$HEAD_SHA:$path" > "$WORK/head-version" 2>/dev/null || continue + if cmp -s "$WORK/$path" "$WORK/head-version"; then - pure=$((pure + 1)) + inert=$((inert + 1)) + continue + fi + + changed=$(diff "$WORK/$path" "$WORK/head-version" | grep -E '^[<>]' || true) + + # Net of relocations. A line removed from one place and added unchanged in + # another is a move, and a move compiles to what it compiled to before. + # Counting both ends of it reports a rewrite where a block shifted. + side() { # $1 = < or >, $2 = keep|drop comments + local filter='^(///|//|/\*|\*/|\*)' + printf '%s\n' "$changed" | grep -E "^$1" | sed -E 's/^.[[:space:]]*//' \ + | if [ "$2" = drop ]; then grep -vE "$filter"; else grep -E "$filter"; fi \ + | grep -v '^[[:space:]]*$' | sed -E 's/[[:space:]]+/ /g' | sort + } + code=$(comm -3 <(side '<' drop) <(side '>' drop) | grep -c . || true) + comments=$(comm -3 <(side '<' keep) <(side '>' keep) | grep -c . || true) + + # A changed declaration carrying `public` or `open` is a change to the + # contract consumers compile against, which the repository holds in common + # with the SDK for Android. + added_api=$(printf '%s\n' "$changed" | grep -E '^>' | grep -E '(^|[[:space:]])(public|open)([[:space:]]|$)' || true) + removed_api=$(printf '%s\n' "$changed" | grep -E '^<' | grep -E '(^|[[:space:]])(public|open)([[:space:]]|$)' || true) + [ -n "$added_api" ] && api_added="${api_added}${path}"$'\n'"${added_api}"$'\n' + [ -n "$removed_api" ] && api_removed="${api_removed}${path}"$'\n'"${removed_api}"$'\n' + + if [ "$code" -gt 0 ]; then + semantic=$((semantic + 1)) + rows="${rows}${code} ${comments} ${path}"$'\n' else - lines=$(diff "$WORK/$path" "$WORK/head-version" | grep -cE '^[<>]') - edited="${edited}${lines} ${path}"$'\n' + doc_only=$((doc_only + 1)) + doc_rows="${doc_rows}${comments} ${path}"$'\n' fi done <<< "$CHANGED_SOURCES" -edited_count=$(printf '%s' "$edited" | grep -c . || true) +modified_total=$(printf '%s\n' "$CHANGED_SOURCES" | grep -c . || true) -echo "### Shipped code (\`Sources/\`)" +echo "### Production code (\`Sources/\`)" echo -echo "| | Files |" -echo "| --- | ---: |" -echo "| Formatter output only, no edit of its own | $pure |" -echo "| Carry an edit of their own | $edited_count |" +echo "$modified_total modified files, classified by whether the change can alter behaviour." +echo +echo "| Classification | Files | Reviewer action |" +echo "| --- | ---: | --- |" +echo "| Formatting only, semantically inert | $inert | None. Reproduced by running the formatter over the base revision. |" +echo "| Comments and documentation only | $doc_only | Read for accuracy. Compiles to the same code. |" +echo "| Declarations or statements changed | $semantic | Review. This is where behaviour can change. |" echo -if [ "$edited_count" -gt 0 ]; then - echo "These are the files to read. The count is lines that differ once formatting" - echo "is accounted for, and it counts a comment the same as a statement." +if [ "$semantic" -gt 0 ]; then + echo "#### Files that can change behaviour" + echo + echo "\`Code\` counts declarations and executable statements that differ once" + echo "formatting is normalised and relocations are netted out: a line moved" + echo "without being altered compiles to what it compiled to before. \`Docs\` is" + echo "the same count for comments." + echo + echo "| Code | Docs | File |" + echo "| ---: | ---: | --- |" + printf '%s' "$rows" | sort -rn | awk -F'\t' 'NF == 3 { printf "| %s | %s | `%s` |\n", $1, $2, $3 }' echo - echo "| Changed lines | File |" +fi + +if [ "$doc_only" -gt 0 ]; then + echo "#### Documentation-only files" + echo + echo "| Docs | File |" echo "| ---: | --- |" - printf '%s' "$edited" | sort -rn | awk -F'\t' 'NF == 2 { printf "| %s | `%s` |\n", $1, $2 }' + printf '%s' "$doc_rows" | sort -rn | awk -F'\t' 'NF == 2 { printf "| %s | `%s` |\n", $1, $2 }' + echo +fi + +# ------------------------------------------------------------ public API surface + +echo "### Public API surface" +echo +if [ -z "$api_added" ] && [ -z "$api_removed" ]; then + echo "No line carrying \`public\` or \`open\` was added or removed in a modified file." + echo "Nothing a consumer compiles against moved, so this change is source compatible" + echo "by that measure and needs no matching change in the SDK for Android." +else + echo "Lines carrying \`public\` or \`open\` moved. The public surface is a contract" + echo "shared with the SDK for Android, so a change here is a change to both." + if [ -n "$api_removed" ]; then + echo + echo "Removed or altered:" + echo '```' + printf '%s' "$api_removed" + echo '```' + fi + if [ -n "$api_added" ]; then + echo + echo "Added or altered:" + echo '```' + printf '%s' "$api_added" + echo '```' + fi +fi +echo + +# ------------------------------------------------------------------ test balance + +TEST_FILES=$(git diff --name-only "$BASE..$HEAD_SHA" -- Tests/ Example/PayabliDemo/FlowTests/ | grep -c . || true) +echo "### Tests" +echo +if [ "$semantic" -eq 0 ]; then + echo "No production file changed a declaration or a statement, so no new test is implied." +elif [ "$TEST_FILES" -eq 0 ]; then + echo "$semantic production files changed behaviour and no test file changed. Worth" + echo "confirming the existing suite covers the new paths." +else + echo "$semantic production files changed behaviour, alongside $TEST_FILES changed test files." fi From 12e2f3b5107617a13a29fd4f3af54a7c8941429a Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 15:47:37 -0700 Subject: [PATCH 36/57] Show a failed submit's reason on the step that failed `handleError` writes the reason into `resultText`, which renders inside the result row alone. A failed submit marks the form `.failed`, and the result step reads `form.isFinished ? .current : .blocked`, so the result row is blocked and hidden. Both card-not-present screens offered a retryable form with the reason for the failure nowhere on screen. The form row now renders the text while `submitFailed`, so the step that failed carries its own reason, as the activation step does on the Tap to Pay screen. The result row keeps the success text and drops its error colouring, which it can no longer reach. The derivation is unchanged and correct, so the step suites do not move: this is which row the string is drawn in. Nothing in the logic suite can reach it, and no snapshot tier exists, so it is checked by driving the app. Co-Authored-By: Claude Opus 5 (1M context) --- .../PayabliDemo/PayIn/PaymentCaptureQAView.swift | 14 +++++++++++++- .../PayabliDemo/PayIn/PaymentMethodQAView.swift | 14 +++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift b/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift index 78ab900..71bdceb 100644 --- a/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift +++ b/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift @@ -61,6 +61,18 @@ struct PaymentCaptureQAView: View { onError: handleError ) .payabliPayInPaymentFlowStyle(style) + + // The step that failed shows why. A failed form + // blocks the result row, which is the only other + // place this text renders, so leaving it there + // offers a retry with no reason beside it. + if submitFailed { + Text(resultText) + .font(.footnote) + .foregroundColor(.payabliError) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } } } @@ -68,7 +80,7 @@ struct PaymentCaptureQAView: View { VStack(alignment: .leading, spacing: 10) { Text(resultText.isEmpty ? "Nothing captured yet." : resultText) .font(.footnote) - .foregroundColor(submitFailed ? .payabliError : .payabliOnSurfaceVariant) + .foregroundColor(.payabliOnSurfaceVariant) .frame(maxWidth: .infinity, alignment: .leading) .textSelection(.enabled) diff --git a/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift b/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift index 8e2f0bd..109ea6a 100644 --- a/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift +++ b/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift @@ -60,6 +60,18 @@ struct PaymentMethodQAView: View { onError: handleError ) .payabliPayInPaymentFlowStyle(style) + + // The step that failed shows why. A failed form + // blocks the result row, which is the only other + // place this text renders, so leaving it there + // offers a retry with no reason beside it. + if submitFailed { + Text(resultText) + .font(.footnote) + .foregroundColor(.payabliError) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } } } @@ -67,7 +79,7 @@ struct PaymentMethodQAView: View { VStack(alignment: .leading, spacing: 10) { Text(resultText.isEmpty ? "Nothing stored yet." : resultText) .font(.footnote) - .foregroundColor(submitFailed ? .payabliError : .payabliOnSurfaceVariant) + .foregroundColor(.payabliOnSurfaceVariant) .frame(maxWidth: .infinity, alignment: .leading) .textSelection(.enabled) From 01ed4f81c5fc0997a3eec7468cca700b37df8f63 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 16:07:19 -0700 Subject: [PATCH 37/57] Keep the write token out of the job that runs the branch's code The change report generated and posted from one job. That job checked out the pull request, ran a local action and a script from the branch, and then exposed a `pull-requests: write` token to a later step. A step can prepend to $GITHUB_PATH for the steps after it, so code from the branch could leave a `gh` wrapper on the path and read the token out of the environment when the comment step called it. A `pull_request` run uses the branch's own workflow file, and a same-repository pull request is granted the permissions it asks for. Split in two. The report job is read-only and uploads its output as an artifact. The comment job holds the write token and runs nothing from the branch: no checkout, no local action, no script. `gh` and `jq` come from the runner image and the report arrives as data. The comment job runs on ubuntu, since nothing in it needs Xcode and a macOS runner bills at ten times the rate to post one comment. Checked across the workflow: no job now both holds a token and runs code from the branch. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a52513d..379e033 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,20 +39,24 @@ jobs: # Reports, never judges. A branch that runs `swiftformat .` puts hundreds of # files in the diff, and the file count alone does not say which of them - # changed what the code does. This says so on the run's summary page. + # changed what the code does. # # continue-on-error, and the script exits 0 whatever it finds: a report that # can redden a pull request is a gate, and this is not one. + # + # This job runs the pull request's own code — the local action and the script + # under Scripts/ — so it is read-only and holds no token worth taking. The + # comment is posted by the job below, which runs none of it. A step can + # prepend to $GITHUB_PATH for the steps after it, so code from the branch and + # a write token in one job means the code can wrap `gh` and read the token out + # of the environment when the later step calls it. changes: name: Change report runs-on: macos-15 if: github.event_name == 'pull_request' continue-on-error: true - # Naming any scope sets every other one to none, so the checkout's own read - # has to be named alongside the write this job is here for. permissions: contents: read - pull-requests: write steps: # The classification reads both sides of the change, so it needs history. @@ -71,6 +75,33 @@ jobs: > report.md cat report.md >> "$GITHUB_STEP_SUMMARY" + - name: Upload the report + uses: actions/upload-artifact@v4 + with: + name: change-report + path: report.md + + # Holds the write token and runs nothing from the pull request: no checkout, + # no local action, no script from the branch. `gh` and `jq` come from the + # runner image, and the report arrives as an artifact, which is data. + # + # ubuntu, because nothing here needs Xcode, and a macOS runner bills at ten + # times the rate for a step that posts one comment. + changes-comment: + name: Change report comment + runs-on: ubuntu-latest + needs: [changes] + if: github.event_name == 'pull_request' + continue-on-error: true + permissions: + pull-requests: write + + steps: + - name: Download the report + uses: actions/download-artifact@v4 + with: + name: change-report + # One comment that is edited in place, found by the marker the script # writes. A new comment on every push buries the review under reports of # itself. A fork's pull request has a read-only token, so this is allowed From 55d3b03dfc47d6c9525b6a6ee4d63b6ae2a4673c Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 16:41:04 -0700 Subject: [PATCH 38/57] Report the API of files that entered or left the build Two defects in the change report, both of which made it state something it had not checked. The public surface scan read modified files only. A `Sources/` file that was added or deleted was never opened, so a change introducing a public type could still be reported as source compatible and needing no counterpart in the SDK for Android. Added and deleted files are now scanned in full and named as such, and the sentence says which set it covers. The early exit and the file counts account for them too. The rename check matched `R100` as well as the scores below it, so a byte-identical move was announced as having been edited, which is the opposite of what the line beneath it said. The scan is restricted to `.swift`. `Sources/` also carries Markdown, and prose about a public type reads as a declaration to a grep, which is how the first fix surfaced an `LLM.md` in a list of API changes. Checked against a range that adds and deletes production files, since this branch modifies only: the added file reports its declarations, seven deleted files report theirs, and no Markdown appears. This branch's own report is unchanged at 36 inert, 1 documentation, 5 behavioural. Co-Authored-By: Claude Opus 5 (1M context) --- Scripts/classify-changes.sh | 44 +++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/Scripts/classify-changes.sh b/Scripts/classify-changes.sh index 00f495e..234fa9f 100755 --- a/Scripts/classify-changes.sh +++ b/Scripts/classify-changes.sh @@ -79,7 +79,7 @@ else echo '```' printf '%s\n' "$LIFECYCLE" echo '```' - if printf '%s\n' "$LIFECYCLE" | grep -qE '^R(0[0-9][0-9]|1[0-9][0-9])' ; then + if printf '%s\n' "$LIFECYCLE" | grep -qE '^R0[0-9][0-9]' ; then echo echo "A rename shown below \`R100\` was edited as well as moved." fi @@ -88,12 +88,16 @@ echo # ------------------------------------------------------------------ shipped code -CHANGED_SOURCES=$(git diff --name-only --diff-filter=M "$BASE..$HEAD_SHA" -- Sources/) -if [ -z "$CHANGED_SOURCES" ]; then +# Swift only. Sources/ also carries Markdown, and prose about a public type +# reads as a declaration to a grep. +CHANGED_SOURCES=$(git diff --name-only --diff-filter=M "$BASE..$HEAD_SHA" -- Sources/ | grep '\.swift$' || true) +ADDED_SOURCES=$(git diff --name-only --diff-filter=A "$BASE..$HEAD_SHA" -- Sources/ | grep '\.swift$' || true) +DELETED_SOURCES=$(git diff --name-only --diff-filter=D "$BASE..$HEAD_SHA" -- Sources/ | grep '\.swift$' || true) +if [ -z "$CHANGED_SOURCES" ] && [ -z "$ADDED_SOURCES" ] && [ -z "$DELETED_SOURCES" ]; then echo "### Production code" echo - echo "No existing file under \`Sources/\` was modified, so released behaviour" - echo "is unchanged except by any file added or deleted above." + echo "Nothing under \`Sources/\` changed, so released behaviour is unchanged and" + echo "the public surface is untouched." exit 0 fi @@ -164,17 +168,42 @@ while IFS= read -r path; do fi done <<< "$CHANGED_SOURCES" +# A file that entered or left the build carries its whole contents as added or +# removed API. The classification above speaks only for modified files, so +# without this the report would call a new public type source compatible. +while IFS= read -r path; do + [ -n "$path" ] || continue + decls=$(git show "$HEAD_SHA:$path" 2>/dev/null \ + | grep -E '(^|[[:space:]])(public|open)([[:space:]]|$)' || true) + [ -n "$decls" ] && api_added="${api_added}${path} (file added)"$'\n'"${decls}"$'\n' +done <<< "$ADDED_SOURCES" + +while IFS= read -r path; do + [ -n "$path" ] || continue + decls=$(git show "$BASE:$path" 2>/dev/null \ + | grep -E '(^|[[:space:]])(public|open)([[:space:]]|$)' || true) + [ -n "$decls" ] && api_removed="${api_removed}${path} (file deleted)"$'\n'"${decls}"$'\n' +done <<< "$DELETED_SOURCES" + modified_total=$(printf '%s\n' "$CHANGED_SOURCES" | grep -c . || true) +added_total=$(printf '%s\n' "$ADDED_SOURCES" | grep -c . || true) +deleted_total=$(printf '%s\n' "$DELETED_SOURCES" | grep -c . || true) echo "### Production code (\`Sources/\`)" echo -echo "$modified_total modified files, classified by whether the change can alter behaviour." +echo "$modified_total modified, $added_total added, $deleted_total deleted, classified by whether the change can alter behaviour." echo echo "| Classification | Files | Reviewer action |" echo "| --- | ---: | --- |" echo "| Formatting only, semantically inert | $inert | None. Reproduced by running the formatter over the base revision. |" echo "| Comments and documentation only | $doc_only | Read for accuracy. Compiles to the same code. |" echo "| Declarations or statements changed | $semantic | Review. This is where behaviour can change. |" +if [ "$added_total" -gt 0 ]; then + echo "| Files added | $added_total | Review in full. Every line is new. |" +fi +if [ "$deleted_total" -gt 0 ]; then + echo "| Files deleted | $deleted_total | Confirm nothing depended on them. |" +fi echo if [ "$semantic" -gt 0 ]; then @@ -205,7 +234,8 @@ fi echo "### Public API surface" echo if [ -z "$api_added" ] && [ -z "$api_removed" ]; then - echo "No line carrying \`public\` or \`open\` was added or removed in a modified file." + echo "No line carrying \`public\` or \`open\` was added or removed, across modified" + echo "files and the full contents of any file added or deleted under \`Sources/\`." echo "Nothing a consumer compiles against moved, so this change is source compatible" echo "by that measure and needs no matching change in the SDK for Android." else From 4793a7217cfad0e47a7f27b36fadec5c66634856 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 17:02:54 -0700 Subject: [PATCH 39/57] Report which customer fields a charge carried, never their values `logChargeStart` wrote the cardholder's first and last name, `customerNumber`, `customerId`, `company`, email, phone and the billing and shipping addresses through the `private:` overload. `.private` redacts a value in a shared log and still delivers it to a local stream and to a sysdiagnose, so it is not a level a cardholder name may be logged at. Every value now goes through `PayabliLogger.redactFully`, which the module already exposes, so each field renders `[REDACTED]` when set and `[nil]` when not. The line reports which fields the caller populated and nothing they hold, which is what the Android core emits for the same record: `LoggableFieldNames` is deny-by-default and lists no name, address or contact field. The rendering is `PayabliTTPCustomerData.redactedFieldSummary` so it can be asserted directly. No test can observe an os_log privacy level, and the charge suite passes no customer, so the block was unreachable from any test while it held the values. `CustomerFieldRedactionTests` populates all 21 fields with sentinels and asserts none reaches the summary, that a set field is distinguished from an unset one, and that every field is still named. Restoring the old rendering fails three of the four; the fourth covers the field names, which that change does not touch. This is the only site under `Sources/` that logged a name. Co-Authored-By: Claude Opus 5 (1M context) --- .../PayabliTTP+Charge.swift | 70 +++++++++------ .../CustomerFieldRedactionTests.swift | 90 +++++++++++++++++++ 2 files changed, 132 insertions(+), 28 deletions(-) create mode 100644 Tests/PayabliSDKTapToPayTests/CustomerFieldRedactionTests.swift diff --git a/Sources/PayabliSDKTapToPay/PayabliTTP+Charge.swift b/Sources/PayabliSDKTapToPay/PayabliTTP+Charge.swift index 3b74ed5..2bde805 100644 --- a/Sources/PayabliSDKTapToPay/PayabliTTP+Charge.swift +++ b/Sources/PayabliSDKTapToPay/PayabliTTP+Charge.swift @@ -238,10 +238,8 @@ extension PayabliTTP { } } - /// Two-line log: a `.public` summary that lands in shared OS logs, plus - /// a `.private` detail line carrying PII (billing/shipping/email/phone) - /// that's redacted in shared logs but visible in the developer's local - /// stream. + /// Two lines: the charge summary, then which customer fields the caller + /// populated. No customer value is emitted at any privacy level. private func logChargeStart( paymentDetails: PayabliTTPPaymentDetails, customer: PayabliTTPCustomerData, @@ -249,8 +247,7 @@ extension PayabliTTP { orderDescription: String? ) { // Charge metadata. The single-argument overload renders the whole string - // `.public`; anything naming the customer goes to the `private:` call - // below. + // `.public`, so only fields that carry no subject belong in it. logger.info( "[charge] → amount=\(paymentDetails.amount) serviceFee=\(paymentDetails.serviceFee) " + "currency=\(paymentDetails.currency ?? "") " + @@ -259,27 +256,44 @@ extension PayabliTTP { ) guard !customer.isEmpty else { return } - let pii = "firstName=\(customer.firstName ?? "") " + - "lastName=\(customer.lastName ?? "") " + - "customerNumber=\(customer.customerNumber ?? "") " + - "customerId=\(customer.customerId.map(String.init) ?? "") " + - "company=\(customer.company ?? "") " + - "email=\(customer.email ?? "") " + - "phone=\(customer.phone ?? "") " + - "billing.address1=\(customer.billingAddress1 ?? "") " + - "billing.address2=\(customer.billingAddress2 ?? "") " + - "billing.city=\(customer.billingCity ?? "") " + - "billing.state=\(customer.billingState ?? "") " + - "billing.zip=\(customer.billingZip ?? "") " + - "billing.country=\(customer.billingCountry ?? "") " + - "billing.email=\(customer.billingEmail ?? "") " + - "billing.phone=\(customer.billingPhone ?? "") " + - "shipping.address1=\(customer.shippingAddress1 ?? "") " + - "shipping.address2=\(customer.shippingAddress2 ?? "") " + - "shipping.city=\(customer.shippingCity ?? "") " + - "shipping.state=\(customer.shippingState ?? "") " + - "shipping.zip=\(customer.shippingZip ?? "") " + - "shipping.country=\(customer.shippingCountry ?? "")" - logger.info("[charge] customerPII", private: pii) + logger.info("[charge] customer \(customer.redactedFieldSummary)") + } +} + +extension PayabliTTPCustomerData { + /// Which fields the caller set, never what they hold: each renders + /// `[REDACTED]` when set and `[nil]` when not. + /// + /// `.private` redacts a value in a shared log and still delivers it to a + /// local stream and to a sysdiagnose, so a cardholder name has no privacy + /// level it may be logged at. The same holds for the contact and address + /// fields beside it, which the Android core also emits redacted. + var redactedFieldSummary: String { + let fields: [(String, String?)] = [ + ("firstName", firstName), + ("lastName", lastName), + ("customerNumber", customerNumber), + ("customerId", customerId.map(String.init)), + ("company", company), + ("email", email), + ("phone", phone), + ("billing.address1", billingAddress1), + ("billing.address2", billingAddress2), + ("billing.city", billingCity), + ("billing.state", billingState), + ("billing.zip", billingZip), + ("billing.country", billingCountry), + ("billing.email", billingEmail), + ("billing.phone", billingPhone), + ("shipping.address1", shippingAddress1), + ("shipping.address2", shippingAddress2), + ("shipping.city", shippingCity), + ("shipping.state", shippingState), + ("shipping.zip", shippingZip), + ("shipping.country", shippingCountry) + ] + return fields + .map { "\($0.0)=\(PayabliLogger.redactFully($0.1))" } + .joined(separator: " ") } } diff --git a/Tests/PayabliSDKTapToPayTests/CustomerFieldRedactionTests.swift b/Tests/PayabliSDKTapToPayTests/CustomerFieldRedactionTests.swift new file mode 100644 index 0000000..0b6bff0 --- /dev/null +++ b/Tests/PayabliSDKTapToPayTests/CustomerFieldRedactionTests.swift @@ -0,0 +1,90 @@ +@testable import PayabliSDKTapToPay +import XCTest + +/// `redactedFieldSummary` is what the charge path logs in place of the customer +/// record. The guarantee is that it names which fields were set and carries no +/// value from any of them. +final class CustomerFieldRedactionTests: XCTestCase { + /// Distinct from every field name, so a value found in the summary cannot be + /// the field's own label. + private static let values = (1 ... 20).map { "zzsentinel\($0)" } + private static let customerId = 987_654_321 + + private static func populated() -> PayabliTTPCustomerData { + let v = values + return PayabliTTPCustomerData( + firstName: v[0], + lastName: v[1], + customerNumber: v[2], + email: v[3], + phone: v[4], + customerId: customerId, + company: v[5], + billingAddress1: v[6], + billingAddress2: v[7], + billingCity: v[8], + billingState: v[9], + billingZip: v[10], + billingCountry: v[11], + billingPhone: v[12], + billingEmail: v[13], + shippingAddress1: v[14], + shippingAddress2: v[15], + shippingCity: v[16], + shippingState: v[17], + shippingZip: v[18], + shippingCountry: v[19] + ) + } + + func testNoCustomerValueReachesTheSummary() { + let summary = Self.populated().redactedFieldSummary + + for value in Self.values { + XCTAssertFalse( + summary.contains(value), + "\(value) reached the log line: \(summary)" + ) + } + XCTAssertFalse( + summary.contains(String(Self.customerId)), + "customerId reached the log line: \(summary)" + ) + } + + func testEveryFieldOfAPopulatedCustomerRendersRedacted() { + let summary = Self.populated().redactedFieldSummary + + XCTAssertEqual( + summary.components(separatedBy: "[REDACTED]").count - 1, + 21, + "every field is set, so each renders redacted: \(summary)" + ) + XCTAssertFalse(summary.contains("[nil]"), summary) + } + + func testAnUnsetFieldIsDistinguishedFromASetOne() { + let summary = PayabliTTPCustomerData(firstName: "Ada").redactedFieldSummary + + XCTAssertTrue(summary.contains("firstName=[REDACTED]"), summary) + XCTAssertTrue(summary.contains("lastName=[nil]"), summary) + XCTAssertFalse(summary.contains("Ada"), summary) + } + + func testEveryFieldIsNamed() { + let summary = Self.populated().redactedFieldSummary + + let names = [ + "firstName", "lastName", "customerNumber", "customerId", "company", + "email", "phone", + "billing.address1", "billing.address2", "billing.city", + "billing.state", "billing.zip", "billing.country", + "billing.email", "billing.phone", + "shipping.address1", "shipping.address2", "shipping.city", + "shipping.state", "shipping.zip", "shipping.country" + ] + for name in names { + XCTAssertTrue(summary.contains("\(name)="), "\(name) is missing: \(summary)") + } + } +} From b24dbfe1244a9f1356f93023cd54c35e37422cde Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 17:29:56 -0700 Subject: [PATCH 40/57] Report the initiate request by size, not by its contents `[initiate] body` serialised the whole `InitiateRequest` and logged it through the `private:` overload. `customerData` is an `InitiateCustomerData`, which carries the same 21 fields the charge log had just stopped emitting: first and last name, `customerNumber`, `customerId`, `company`, email, phone and the billing and shipping addresses. The call now reports endpoint and request size, which is the shape the other call sites in this branch already use. This was the last `info(_:private:)` caller under `Sources/`, so no customer value now reaches a log at any privacy level. Co-Authored-By: Claude Opus 5 (1M context) --- Sources/PayabliSDKTapToPay/TTPTransactionClient.swift | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/Sources/PayabliSDKTapToPay/TTPTransactionClient.swift b/Sources/PayabliSDKTapToPay/TTPTransactionClient.swift index f72be25..b8f1b2f 100644 --- a/Sources/PayabliSDKTapToPay/TTPTransactionClient.swift +++ b/Sources/PayabliSDKTapToPay/TTPTransactionClient.swift @@ -47,12 +47,10 @@ public final class TTPTransactionClient: Sendable { jsonBody: body ) - let bodyDump = request.body.flatMap { String(data: $0, encoding: .utf8) } ?? "" - // The headers carry the App Attest assertion, key id and device id. - logger.info("[initiate] → POST \(request.path)") - // Body carries PII (billing/shipping/email/phone) — keep redacted in - // shared OS logs. - logger.info("[initiate] body", private: bodyDump) + // `customerData` is the caller's whole customer record, so the body is + // reported by size. The headers carry the App Attest assertion, key id + // and device id, and are not logged either. + logger.info("[initiate] → POST \(request.path) bytes=\(request.body?.count ?? 0)") let envelope: PayabliV2Envelope do { From 6225029b41b9045208c9ae9cfa5e45240fb22c77 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 17:38:41 -0700 Subject: [PATCH 41/57] Count an added or deleted production file as a behaviour change The test-balance line read `semantic`, which counts files that existed on both sides and were edited. A change that adds a whole implementation under `Sources/` and no test therefore reported "no new test is implied", which is the case the line exists to catch. Measured against a commit adding a public struct and nothing else: the previous version printed "No production file changed a declaration or a statement", and this one prints "1 production files changed behaviour and no test file changed". Co-Authored-By: Claude Opus 5 (1M context) --- Scripts/classify-changes.sh | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Scripts/classify-changes.sh b/Scripts/classify-changes.sh index 234fa9f..9b0d1a8 100755 --- a/Scripts/classify-changes.sh +++ b/Scripts/classify-changes.sh @@ -263,11 +263,16 @@ echo TEST_FILES=$(git diff --name-only "$BASE..$HEAD_SHA" -- Tests/ Example/PayabliDemo/FlowTests/ | grep -c . || true) echo "### Tests" echo -if [ "$semantic" -eq 0 ]; then +# An added or deleted production file is a behaviour change with no counterpart +# in `semantic`, which counts edits to files that existed on both sides. Reading +# `semantic` alone lets a change that adds a whole implementation with no test +# report that no test is implied. +production_changed=$((semantic + added_total + deleted_total)) +if [ "$production_changed" -eq 0 ]; then echo "No production file changed a declaration or a statement, so no new test is implied." elif [ "$TEST_FILES" -eq 0 ]; then - echo "$semantic production files changed behaviour and no test file changed. Worth" + echo "$production_changed production files changed behaviour and no test file changed. Worth" echo "confirming the existing suite covers the new paths." else - echo "$semantic production files changed behaviour, alongside $TEST_FILES changed test files." + echo "$production_changed production files changed behaviour, alongside $TEST_FILES changed test files." fi From 9bc7af2601176f78455549726fed891d338dfd42 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 17:39:47 -0700 Subject: [PATCH 42/57] Classify a production file that was renamed as well as edited Git reports a rename as `R`, and the shipped-code classification selected `M`, so a file moved and edited in the same commit was dropped from the formatter normalisation, the semantic count and the public-API comparison. With no other `Sources/` change in the diff it also reached the early return, and the report then said released behaviour was unchanged and the public surface untouched. Each file is now carried as an old path at the base and a new path at the head, which for a modified file are the same path. Measured on a commit that renames a `Sources/` file and appends a public enum to it: the previous version printed "Nothing under `Sources/` changed, so released behaviour is unchanged and the public surface is untouched", and this one classifies the file and reports the enum under the public surface. The report for this branch is unchanged at 36 inert, 1 documentation, 5 semantic. Co-Authored-By: Claude Opus 5 (1M context) --- Scripts/classify-changes.sh | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/Scripts/classify-changes.sh b/Scripts/classify-changes.sh index 9b0d1a8..7447528 100755 --- a/Scripts/classify-changes.sh +++ b/Scripts/classify-changes.sh @@ -90,7 +90,18 @@ echo # Swift only. Sources/ also carries Markdown, and prose about a public type # reads as a declaration to a grep. -CHANGED_SOURCES=$(git diff --name-only --diff-filter=M "$BASE..$HEAD_SHA" -- Sources/ | grep '\.swift$' || true) +# +# A modified file and a renamed one both carry an edit, and git reports the +# rename as `R` rather than `M`. Reading only `M` skips a file that was renamed +# and edited in the same commit, which is the case most in need of review. Each +# is therefore held as an old path at the base and a new path at the head; for a +# modified file the two are the same. +SOURCE_PAIRS=$(git diff --name-status --find-renames "$BASE..$HEAD_SHA" -- Sources/ \ + | awk -F'\t' ' + $1 ~ /^M/ && $2 ~ /\.swift$/ { print $2 "\t" $2 } + $1 ~ /^R/ && $3 ~ /\.swift$/ { print $2 "\t" $3 } + ' || true) +CHANGED_SOURCES=$(printf '%s\n' "$SOURCE_PAIRS" | awk -F'\t' 'NF == 2 { print $2 }' | grep . || true) ADDED_SOURCES=$(git diff --name-only --diff-filter=A "$BASE..$HEAD_SHA" -- Sources/ | grep '\.swift$' || true) DELETED_SOURCES=$(git diff --name-only --diff-filter=D "$BASE..$HEAD_SHA" -- Sources/ | grep '\.swift$' || true) if [ -z "$CHANGED_SOURCES" ] && [ -z "$ADDED_SOURCES" ] && [ -z "$DELETED_SOURCES" ]; then @@ -123,21 +134,21 @@ doc_rows="" api_added="" api_removed="" -while IFS= read -r path; do +while IFS=$'\t' read -r old_path path; do [ -n "$path" ] || continue - mkdir -p "$WORK/$(dirname "$path")" - git show "$BASE:$path" > "$WORK/$path" 2>/dev/null || continue + mkdir -p "$WORK/$(dirname "$old_path")" + git show "$BASE:$old_path" > "$WORK/$old_path" 2>/dev/null || continue # Normalising the base revision is what separates the author's edit from the # formatter's. Without it every reformatted file looks like a rewrite. - swiftformat "$WORK/$path" --config "$REPO_ROOT/.swiftformat" --quiet >/dev/null 2>&1 + swiftformat "$WORK/$old_path" --config "$REPO_ROOT/.swiftformat" --quiet >/dev/null 2>&1 git show "$HEAD_SHA:$path" > "$WORK/head-version" 2>/dev/null || continue - if cmp -s "$WORK/$path" "$WORK/head-version"; then + if cmp -s "$WORK/$old_path" "$WORK/head-version"; then inert=$((inert + 1)) continue fi - changed=$(diff "$WORK/$path" "$WORK/head-version" | grep -E '^[<>]' || true) + changed=$(diff "$WORK/$old_path" "$WORK/head-version" | grep -E '^[<>]' || true) # Net of relocations. A line removed from one place and added unchanged in # another is a move, and a move compiles to what it compiled to before. @@ -166,7 +177,7 @@ while IFS= read -r path; do doc_only=$((doc_only + 1)) doc_rows="${doc_rows}${comments} ${path}"$'\n' fi -done <<< "$CHANGED_SOURCES" +done <<< "$SOURCE_PAIRS" # A file that entered or left the build carries its whole contents as added or # removed API. The classification above speaks only for modified files, so From 9243bf6690bdc1ce976a0d6cf78ebe99b881f54d Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 17:40:50 -0700 Subject: [PATCH 43/57] Read the public surface by its container, not by the word public Searching changed lines for `public` or `open` finds a minority of what a consumer can see. An enum's cases carry the enum's visibility and name it nowhere, so every case of `PayabliTTPEvent` was invisible to that search, and so was `activateDevice`, a member of a `public extension` that needs no keyword of its own. Either could be added, removed or reshaped and the report would still conclude the change was source compatible and needed no counterpart on Android. `public_surface` walks a file tracking the container each declaration sits in, because Swift's default depends on it: a member of a `public extension` or `public protocol` is public, a `case` of a public enum is public, and a member of a public struct, class or actor is internal. An explicit `private`, `fileprivate` or `internal` wins over all of it. Braces inside string literals are skipped, since counting one shifts the depth for the rest of the file. The base and head surfaces are compared as sets, so a declaration that only moved within its file is not reported, and one that changed shape is reported from both ends. Files that entered or left the build are read the same way. The section no longer claims source compatibility. It is a text scan, it does not resolve conditional compilation, and it reads a declaration split across lines by its first line, so it now says it found nothing to look at and names its own limit. Checked against the two declarations the keyword search misses: the walk reports `activateDevice` and all eighteen `PayabliTTPEvent` cases, reports `redact` and `redactFully` from `PayabliLogger`'s public extension, and reports nothing for the internal wire-format structs or for a private method. Co-Authored-By: Claude Opus 5 (1M context) --- Scripts/classify-changes.sh | 112 +++++++++++++++++++++++++++++++----- 1 file changed, 97 insertions(+), 15 deletions(-) diff --git a/Scripts/classify-changes.sh b/Scripts/classify-changes.sh index 7447528..c78871d 100755 --- a/Scripts/classify-changes.sh +++ b/Scripts/classify-changes.sh @@ -126,6 +126,84 @@ fi WORK=$(mktemp -d) trap 'rm -rf "$WORK"' EXIT +# Every declaration in a file that a consumer can see, one per line. +# +# Searching changed lines for the word `public` finds a minority of the surface. +# An enum's cases carry the enum's visibility and name it nowhere, so all of +# `PayabliTTPEvent` is invisible to that search; so is `activateDevice`, a member +# of a `public extension` that needs no keyword of its own. Swift's default +# differs by container, so the container is what gets tracked: +# +# public extension, public protocol a member is public +# public enum a `case` is public, a `func` is internal +# public struct, class, actor a member is internal +# +# An explicit `private`, `fileprivate` or `internal` always wins. +# +# A text scan, not a compiler: it does not resolve conditional compilation, and +# a declaration split across lines is read by its first line. It is enough to +# say which declarations to look at, which is what the section it feeds claims. +public_surface() { + awk ' + function trim(s) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", s); return s } + # Braces inside a string literal are text, not scope. Counting them shifts + # the depth for the rest of the file and every later visibility with it. + function scan(s, i, c, inq, esc) { + opens = 0; closes = 0; inq = 0; esc = 0 + for (i = 1; i <= length(s); i++) { + c = substr(s, i, 1) + if (esc) { esc = 0; continue } + if (c == "\\") { esc = 1; continue } + if (c == "\"") { inq = !inq; continue } + if (inq) continue + if (c == "{") opens++ + if (c == "}") closes++ + } + } + BEGIN { depth = 0; memberpub[0] = 0; casepub[0] = 0 } + { + line = $0 + if (inblock) { + if (line ~ /\*\//) { sub(/^.*\*\//, "", line); inblock = 0 } else { next } + } + gsub(/\/\*[^*]*\*\//, "", line) + if (line ~ /\/\*/) { sub(/\/\*.*$/, "", line); inblock = 1 } + sub(/\/\/.*$/, "", line) + t = trim(line) + if (t == "") next + + lowered = (t ~ /(^|[[:space:]])(private|fileprivate|internal)([[:space:](]|$)/) + raised = (t ~ /(^|[[:space:]])(public|open)([[:space:]]|$)/) + is_case = (t ~ /^case[[:space:]]/) + is_decl = (t ~ /(^|[[:space:]])(func|var|let|init|subscript|typealias|associatedtype|class|struct|enum|protocol|extension|actor)([[:space:]<(:]|$)/) + + visible = 0 + if (raised && !lowered) visible = 1 + else if (!lowered) { + if (is_case && casepub[depth]) visible = 1 + else if (is_decl && memberpub[depth]) visible = 1 + } + + if (visible && (is_decl || is_case)) print t + + scan(line) + if (opens > 0) { + newmember = 0; newcase = 0 + if (visible) { + if (t ~ /(^|[[:space:]])(extension|protocol)([[:space:]]|$)/) newmember = 1 + else if (t ~ /(^|[[:space:]])enum([[:space:]]|$)/) newcase = 1 + } + for (i = 0; i < opens; i++) { + depth++ + memberpub[depth] = newmember + casepub[depth] = newcase + } + } + for (i = 0; i < closes; i++) if (depth > 0) depth-- + } + ' "$1" +} + inert=0 # files whose entire diff the formatter would have produced doc_only=0 # files whose remaining diff is comments and blank lines semantic=0 # files with a changed declaration or statement @@ -162,11 +240,14 @@ while IFS=$'\t' read -r old_path path; do code=$(comm -3 <(side '<' drop) <(side '>' drop) | grep -c . || true) comments=$(comm -3 <(side '<' keep) <(side '>' keep) | grep -c . || true) - # A changed declaration carrying `public` or `open` is a change to the - # contract consumers compile against, which the repository holds in common - # with the SDK for Android. - added_api=$(printf '%s\n' "$changed" | grep -E '^>' | grep -E '(^|[[:space:]])(public|open)([[:space:]]|$)' || true) - removed_api=$(printf '%s\n' "$changed" | grep -E '^<' | grep -E '(^|[[:space:]])(public|open)([[:space:]]|$)' || true) + # The contract consumers compile against, which the repository holds in + # common with the SDK for Android. Both sides are the whole visible surface + # of the file, so a declaration that moved within it is not reported as a + # change, and one that changed shape is reported from both ends. + public_surface "$WORK/$old_path" | sort > "$WORK/api-base" + public_surface "$WORK/head-version" | sort > "$WORK/api-head" + added_api=$(comm -13 "$WORK/api-base" "$WORK/api-head") + removed_api=$(comm -23 "$WORK/api-base" "$WORK/api-head") [ -n "$added_api" ] && api_added="${api_added}${path}"$'\n'"${added_api}"$'\n' [ -n "$removed_api" ] && api_removed="${api_removed}${path}"$'\n'"${removed_api}"$'\n' @@ -184,15 +265,15 @@ done <<< "$SOURCE_PAIRS" # without this the report would call a new public type source compatible. while IFS= read -r path; do [ -n "$path" ] || continue - decls=$(git show "$HEAD_SHA:$path" 2>/dev/null \ - | grep -E '(^|[[:space:]])(public|open)([[:space:]]|$)' || true) + git show "$HEAD_SHA:$path" > "$WORK/lifecycle-version" 2>/dev/null || continue + decls=$(public_surface "$WORK/lifecycle-version") [ -n "$decls" ] && api_added="${api_added}${path} (file added)"$'\n'"${decls}"$'\n' done <<< "$ADDED_SOURCES" while IFS= read -r path; do [ -n "$path" ] || continue - decls=$(git show "$BASE:$path" 2>/dev/null \ - | grep -E '(^|[[:space:]])(public|open)([[:space:]]|$)' || true) + git show "$BASE:$path" > "$WORK/lifecycle-version" 2>/dev/null || continue + decls=$(public_surface "$WORK/lifecycle-version") [ -n "$decls" ] && api_removed="${api_removed}${path} (file deleted)"$'\n'"${decls}"$'\n' done <<< "$DELETED_SOURCES" @@ -245,13 +326,14 @@ fi echo "### Public API surface" echo if [ -z "$api_added" ] && [ -z "$api_removed" ]; then - echo "No line carrying \`public\` or \`open\` was added or removed, across modified" - echo "files and the full contents of any file added or deleted under \`Sources/\`." - echo "Nothing a consumer compiles against moved, so this change is source compatible" - echo "by that measure and needs no matching change in the SDK for Android." + echo "No declaration a consumer can see was added or removed, across modified," + echo "renamed, added and deleted files under \`Sources/\`. This counts a member of a" + echo "\`public extension\` and an enum's cases, neither of which carries the keyword." + echo "It is a text scan rather than a compiled comparison, so read it as nothing" + echo "found to look at, not as a source-compatibility guarantee." else - echo "Lines carrying \`public\` or \`open\` moved. The public surface is a contract" - echo "shared with the SDK for Android, so a change here is a change to both." + echo "Declarations a consumer can see were added or removed. The public surface is a" + echo "contract shared with the SDK for Android, so a change here is a change to both." if [ -n "$api_removed" ]; then echo echo "Removed or altered:" From 51ec4315ef1551805ca80abc490e51767c947819 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 19:18:45 -0700 Subject: [PATCH 44/57] Give the token probes one owner across the tabs Each screen kept the probe answer in its own `@State`, which made it unshareable in both directions. A probe run on the Configuration tab never reached the tab whose sequence reads it, and a tab whose backend step had already finished renders no content, so its own probe button was gone. Between them, the rule that the latest probe outranks an earlier success was reachable from the unit tests and from nowhere in the app. `TokenProbeResults` holds the two answers and runs the two probes: the card-present partner token that Tap to Pay attests with, and the card-not-present token both PayIn tabs submit with, which is one endpoint because `fetchPaymentCaptureAccessToken` forwards to `fetchPaymentMethodAccessToken`. The app owns it and every tab reads it, so a failure recorded anywhere is the answer everywhere. That also collapses four copies of the probe into one each, which is why the wording of the two card-not-present messages no longer depends on which screen ran it. The step derivations do not move, so the 61 sequence tests are unchanged: this is where the input comes from, not what it means. Checked by building the app and launching it on the simulator, since a view missing an environment object compiles and crashes at runtime; the three previews that construct these views outside the app supply one too. Co-Authored-By: Claude Opus 5 (1M context) --- .../PayabliDemo/App/PayabliDemoQAApp.swift | 6 +++ .../Configuration/ConfigurationQAView.swift | 25 +++-------- .../PayIn/PaymentCaptureQAView.swift | 20 +++------ .../PayIn/PaymentMethodQAView.swift | 20 +++------ .../PayabliDemo.xcodeproj/project.pbxproj | 4 ++ .../Shared/TokenProbeResults.swift | 43 +++++++++++++++++++ .../TapToPay/PaymentTapToPayQAView.swift | 17 +++----- 7 files changed, 77 insertions(+), 58 deletions(-) create mode 100644 Example/PayabliDemo/Shared/TokenProbeResults.swift diff --git a/Example/PayabliDemo/App/PayabliDemoQAApp.swift b/Example/PayabliDemo/App/PayabliDemoQAApp.swift index 89f4dee..bfe8406 100644 --- a/Example/PayabliDemo/App/PayabliDemoQAApp.swift +++ b/Example/PayabliDemo/App/PayabliDemoQAApp.swift @@ -43,6 +43,10 @@ struct PayabliDemoQAApp: App { environment: DemoConfiguration.environment ) + /// One owner for the token probes, so a tab that has finished its backend + /// step still reflects an answer another tab has since had. + @StateObject private var tokenProbes = TokenProbeResults() + var body: some Scene { WindowGroup { TabView { @@ -69,6 +73,7 @@ struct PayabliDemoQAApp: App { // The app-wide tint. The palette lives in one Swift file rather than an // asset catalogue, so it is set here instead of by an AccentColor asset. .tint(.payabliPrimary) + .environmentObject(tokenProbes) } } } @@ -129,4 +134,5 @@ struct PayabliDemoQAApp: App { Label("Config", systemImage: "gearshape") } } + .environmentObject(TokenProbeResults()) } diff --git a/Example/PayabliDemo/Configuration/ConfigurationQAView.swift b/Example/PayabliDemo/Configuration/ConfigurationQAView.swift index 039b771..d22ab7e 100644 --- a/Example/PayabliDemo/Configuration/ConfigurationQAView.swift +++ b/Example/PayabliDemo/Configuration/ConfigurationQAView.swift @@ -10,8 +10,7 @@ import SwiftUI /// The card-not-present rows read from `PayInSharedConfiguration`, the same /// source the forms use, so this screen cannot drift from the real behaviour. struct ConfigurationQAView: View { - @State private var tokenCheckText = "" - @State private var cardNotPresentCheckText = "" + @EnvironmentObject private var tokenProbes: TokenProbeResults @State private var healthCheckText = "" @State private var isWorking = false @@ -100,7 +99,7 @@ struct ConfigurationQAView: View { .disabled(isWorking) ForEach( - [tokenCheckText, cardNotPresentCheckText, healthCheckText].filter { !$0.isEmpty }, + [tokenProbes.cardPresent, tokenProbes.cardNotPresent, healthCheckText].filter { !$0.isEmpty }, id: \.self ) { line in Text(line) @@ -221,34 +220,19 @@ struct ConfigurationQAView: View { // MARK: - Actions - /// Reports only *that* a token arrived. Never the value. private func runTokenCheck() { isWorking = true - tokenCheckText = "Checking token…" Task { defer { isWorking = false } - do { - _ = try await Secrets.fetchAccessToken() - tokenCheckText = "✓ Card-present token endpoint returned a token" - } catch { - tokenCheckText = "✗ Card-present token endpoint failed: \(error.localizedDescription)" - } + await tokenProbes.probeCardPresent() } } - /// The endpoint both card-not-present tabs call. `fetchPaymentCaptureAccessToken` - /// forwards to this one, so a single probe answers for both. private func runCardNotPresentTokenCheck() { isWorking = true - cardNotPresentCheckText = "Checking token…" Task { defer { isWorking = false } - do { - _ = try await Secrets.fetchPaymentMethodAccessToken() - cardNotPresentCheckText = "✓ Card-not-present token endpoint returned a token" - } catch { - cardNotPresentCheckText = "✗ Card-not-present token endpoint failed: \(error.localizedDescription)" - } + await tokenProbes.probeCardNotPresent() } } @@ -274,4 +258,5 @@ struct ConfigurationQAView: View { #Preview { ConfigurationQAView() + .environmentObject(TokenProbeResults()) } diff --git a/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift b/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift index 71bdceb..6ad1f18 100644 --- a/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift +++ b/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift @@ -7,8 +7,8 @@ struct PaymentCaptureQAView: View { @ObservedObject var paymentFlow: PayabliPayInPaymentFlow @StateObject private var diagnosticsStore = DiagnosticsStore.paymentCapture + @EnvironmentObject private var tokenProbes: TokenProbeResults @State private var resultText = "" - @State private var tokenCheckText = "" @State private var resultAcknowledged = false @State private var submitFailed = false @State private var isCheckingToken = false @@ -32,10 +32,10 @@ struct PaymentCaptureQAView: View { } .buttonStyle(.bordered) .disabled(isCheckingToken) - if !tokenCheckText.isEmpty { - Text(tokenCheckText) + if !tokenProbes.cardNotPresent.isEmpty { + Text(tokenProbes.cardNotPresent) .font(.caption) - .foregroundColor(tokenCheckText.hasPrefix("✗") ? .payabliError : .payabliOnSurfaceVariant) + .foregroundColor(tokenProbes.cardNotPresent.hasPrefix("✗") ? .payabliError : .payabliOnSurfaceVariant) } } } @@ -133,7 +133,7 @@ struct PaymentCaptureQAView: View { private var steps: PayInFlowSteps { PayInSteps.forCapture( PayInProgress( - tokenCheck: TokenCheck.classify(tokenCheckText), + tokenCheck: TokenCheck.classify(tokenProbes.cardNotPresent), hasResult: paymentFlow.lastResult != nil, resultAcknowledged: resultAcknowledged, isSubmitting: paymentFlow.isSubmitting, @@ -173,18 +173,11 @@ struct PaymentCaptureQAView: View { ) } - /// Reports only that a token arrived. Never the token itself. private func runTokenCheck() { isCheckingToken = true - tokenCheckText = "Checking…" Task { defer { isCheckingToken = false } - do { - _ = try await Secrets.fetchPaymentCaptureAccessToken() - tokenCheckText = "✓ Token endpoint returned a token" - } catch { - tokenCheckText = "✗ \(error.localizedDescription)" - } + await tokenProbes.probeCardNotPresent() } } @@ -384,4 +377,5 @@ struct PaymentCaptureQAView: View { ) ) ) + .environmentObject(TokenProbeResults()) } diff --git a/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift b/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift index 109ea6a..6951240 100644 --- a/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift +++ b/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift @@ -7,8 +7,8 @@ struct PaymentMethodQAView: View { @ObservedObject var paymentFlow: PayabliPayInPaymentFlow @StateObject private var diagnosticsStore = DiagnosticsStore.paymentMethod + @EnvironmentObject private var tokenProbes: TokenProbeResults @State private var resultText = "" - @State private var tokenCheckText = "" @State private var resultAcknowledged = false @State private var submitFailed = false @State private var isCheckingToken = false @@ -31,10 +31,10 @@ struct PaymentMethodQAView: View { } .buttonStyle(.bordered) .disabled(isCheckingToken) - if !tokenCheckText.isEmpty { - Text(tokenCheckText) + if !tokenProbes.cardNotPresent.isEmpty { + Text(tokenProbes.cardNotPresent) .font(.caption) - .foregroundColor(tokenCheckText.hasPrefix("✗") ? .payabliError : .payabliOnSurfaceVariant) + .foregroundColor(tokenProbes.cardNotPresent.hasPrefix("✗") ? .payabliError : .payabliOnSurfaceVariant) } } } @@ -130,7 +130,7 @@ struct PaymentMethodQAView: View { private var steps: PayInFlowSteps { PayInSteps.forStoringMethod( PayInProgress( - tokenCheck: TokenCheck.classify(tokenCheckText), + tokenCheck: TokenCheck.classify(tokenProbes.cardNotPresent), hasResult: paymentFlow.lastResult != nil, resultAcknowledged: resultAcknowledged, isSubmitting: paymentFlow.isSubmitting, @@ -148,18 +148,11 @@ struct PaymentMethodQAView: View { resultText = "" } - /// Reports only that a token arrived. Never the token itself. private func runTokenCheck() { isCheckingToken = true - tokenCheckText = "Checking…" Task { defer { isCheckingToken = false } - do { - _ = try await Secrets.fetchPaymentMethodAccessToken() - tokenCheckText = "✓ Token endpoint returned a token" - } catch { - tokenCheckText = "✗ \(error.localizedDescription)" - } + await tokenProbes.probeCardNotPresent() } } @@ -310,4 +303,5 @@ struct PaymentMethodQAView: View { environment: DemoConfiguration.environment ) ) + .environmentObject(TokenProbeResults()) } diff --git a/Example/PayabliDemo/PayabliDemo.xcodeproj/project.pbxproj b/Example/PayabliDemo/PayabliDemo.xcodeproj/project.pbxproj index 23b48cf..1768d0c 100644 --- a/Example/PayabliDemo/PayabliDemo.xcodeproj/project.pbxproj +++ b/Example/PayabliDemo/PayabliDemo.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ A1B2C3D4E5F60000000000E2 /* QAContextLine.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60000000000E1 /* QAContextLine.swift */; }; A1B2C3D4E5F60000000000D2 /* StepRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60000000000D1 /* StepRow.swift */; }; + A1B2C3D4E5F60000000000F2 /* TokenProbeResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60000000000F1 /* TokenProbeResults.swift */; }; 0F5E8CC7E7A24D19A71B0A1C /* PaymentMethodAddedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F5E8CC6E7A24D19A71B0A1C /* PaymentMethodAddedView.swift */; }; 1C7A9E9B6D2F4C0F8A1B3D5E /* PaymentCaptureQAView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B1E0F9A8C7D6E5F4A3B2C1D /* PaymentCaptureQAView.swift */; }; 3F9C8D7E6A5B4C3D2E1F0A9B /* PayabliSDKExampleAggregate in Frameworks */ = {isa = PBXBuildFile; productRef = 4A0D9E8F7B6C5D4E3F2A1B0C /* PayabliSDKExampleAggregate */; }; @@ -71,6 +72,7 @@ /* Begin PBXFileReference section */ A1B2C3D4E5F60000000000E1 /* QAContextLine.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = QAContextLine.swift; sourceTree = ""; }; A1B2C3D4E5F60000000000D1 /* StepRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StepRow.swift; sourceTree = ""; }; + A1B2C3D4E5F60000000000F1 /* TokenProbeResults.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TokenProbeResults.swift; sourceTree = ""; }; 0F5E8CC6E7A24D19A71B0A1C /* PaymentMethodAddedView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PaymentMethodAddedView.swift; sourceTree = ""; }; 1DADA588F49DF0C4A6611302 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 4C921D1F3EE7130C23911757 /* Secrets.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = ""; }; @@ -285,6 +287,7 @@ D10000000000000000000005 /* QADetailRow.swift */, A1B2C3D4E5F60000000000D1 /* StepRow.swift */, A1B2C3D4E5F60000000000E1 /* QAContextLine.swift */, + A1B2C3D4E5F60000000000F1 /* TokenProbeResults.swift */, ); path = Shared; sourceTree = ""; @@ -452,6 +455,7 @@ F20000000000000000000006 /* PayInSteps.swift in Sources */, A1B2C3D4E5F60000000000D2 /* StepRow.swift in Sources */, A1B2C3D4E5F60000000000E2 /* QAContextLine.swift in Sources */, + A1B2C3D4E5F60000000000F2 /* TokenProbeResults.swift in Sources */, 0F5E8CC7E7A24D19A71B0A1C /* PaymentMethodAddedView.swift in Sources */, C9B9D43650B2230A8582B93C /* Secrets.swift in Sources */, E1D2C3B4A5960718293A4B01 /* DebugPrefill.swift in Sources */, diff --git a/Example/PayabliDemo/Shared/TokenProbeResults.swift b/Example/PayabliDemo/Shared/TokenProbeResults.swift new file mode 100644 index 0000000..56d0e90 --- /dev/null +++ b/Example/PayabliDemo/Shared/TokenProbeResults.swift @@ -0,0 +1,43 @@ +import SwiftUI + +/// The latest answer from each token probe, shared by every screen that runs one +/// or derives a step from one. +/// +/// Each screen used to keep its own copy, which made the answer unshareable in +/// both directions. A probe run on the Configuration tab could not reach the tab +/// whose sequence reads it, and a tab whose backend step had already finished +/// renders no content, so its own probe button was gone. Between them a failing +/// probe could not be made to outrank an earlier success anywhere except a unit +/// test. +/// +/// Two probes, because they are two endpoints. The card-present partner token is +/// what Tap to Pay attests with; the card-not-present token is what both PayIn +/// tabs submit with, and `Secrets.fetchPaymentCaptureAccessToken` forwards to +/// `fetchPaymentMethodAccessToken`, so one answer covers both of those tabs. +/// +/// Each probe reports only *that* a token arrived. Never the token. +@MainActor +final class TokenProbeResults: ObservableObject { + @Published private(set) var cardPresent = "" + @Published private(set) var cardNotPresent = "" + + func probeCardPresent() async { + cardPresent = "Checking…" + do { + _ = try await Secrets.fetchAccessToken() + cardPresent = "✓ Card-present token endpoint returned a token" + } catch { + cardPresent = "✗ Card-present token endpoint failed: \(error.localizedDescription)" + } + } + + func probeCardNotPresent() async { + cardNotPresent = "Checking…" + do { + _ = try await Secrets.fetchPaymentMethodAccessToken() + cardNotPresent = "✓ Card-not-present token endpoint returned a token" + } catch { + cardNotPresent = "✗ Card-not-present token endpoint failed: \(error.localizedDescription)" + } + } +} diff --git a/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift b/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift index 2351758..f1f1db3 100644 --- a/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift +++ b/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift @@ -16,12 +16,12 @@ import SwiftUI struct PaymentTapToPayQAView: View { @ObservedObject var terminal: PayabliTTP + @EnvironmentObject private var tokenProbes: TokenProbeResults @State private var amountText = "1.00" @State private var activationCode = "" @State private var enableMessage = "" @State private var activationMessage = "" @State private var chargeMessage = "" - @State private var tokenCheckText = "" @State private var eventLog: [TapToPayQAEventEntry] = [] @State private var eventToken: PayabliTTPEventToken? @State private var isActivationPresented = false @@ -74,7 +74,7 @@ struct PaymentTapToPayQAView: View { /// steps can disagree about which is next. private var steps: TapToPayFlowSteps { TapToPaySteps.forCharging( - tokenCheck: TokenCheck.classify(tokenCheckText), + tokenCheck: TokenCheck.classify(tokenProbes.cardPresent), session: terminal.sessionState, activation: activationOutcome ) @@ -92,8 +92,8 @@ struct PaymentTapToPayQAView: View { } .buttonStyle(.bordered) .disabled(isWorking) - if !tokenCheckText.isEmpty { - Text(tokenCheckText) + if !tokenProbes.cardPresent.isEmpty { + Text(tokenProbes.cardPresent) .font(.caption) .foregroundColor(.payabliOnSurfaceVariant) } @@ -463,18 +463,11 @@ struct PaymentTapToPayQAView: View { } /// Confirms the partner backend answers before `initialize()` depends on it. - /// Reports only that a token arrived — never the token itself. private func runTokenCheck() { isWorking = true - tokenCheckText = "Checking…" Task { defer { isWorking = false } - do { - _ = try await Secrets.fetchAccessToken() - tokenCheckText = "✓ Token endpoint returned a token" - } catch { - tokenCheckText = "✗ Token endpoint failed: \(error.localizedDescription)" - } + await tokenProbes.probeCardPresent() } } From ab7e283f23648ac1c40f751fb8cda0b1f15363d0 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 20:26:11 -0700 Subject: [PATCH 45/57] Give each token function its own probe, and let the latest run win Sharing the probe answer introduced two defects that per-screen state could not have, and exposed a third that the sharing made reachable. The two card-not-present tabs submit with different token functions. `Secrets.swift.sample` forwards the capture one to the stored-method one, and says so only for the sample, since a backend may separate the scopes. `Secrets.swift` is per developer, so one shared answer let the capture tab report on an endpoint it never calls. There are now three probes, one per token function, and the Configuration screen runs both card-not-present ones and reports both. Two runs of one probe can also be in flight at once, since each screen disables only its own button. `@MainActor` serialises the writes but suspends at the fetch, so the runs interleave and finish in either order, and a slower earlier one published over the answer a later one had already given. Each run takes a generation and publishes only while it is still the current one. The backend step reads `.done` while a submission is in flight. A shared probe lets another tab answer for this endpoint mid-submit, which unfinished the step, blocked the form, hid the row and deallocated the view model holding what was typed. A submission in flight obtained a token to submit with, so the step is finished for as long as it lasts, and the probe's answer applies once it is over, which is when it says anything about the next one. The fetches are supplied to `TokenProbeResults` rather than called inside it, so the ordering rule is testable without a backend. `TokenProbeResultsTests` holds each fetch open until the test releases it and asserts on which answer survives; the first run refuses and the second succeeds, so which one published is visible in the text. Removing the generation guard fails that test and no other, and removing the submission precedence fails `testAProbeLandingMidSubmissionDoesNotHideTheForm` and no other. 67 tests in the demo bundle, up from 61, and every whole-space invariant is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .../PayabliDemo/App/PayabliDemoQAApp.swift | 11 +- .../Configuration/ConfigurationQAView.swift | 16 ++- Example/PayabliDemo/Flow/PayInSteps.swift | 10 ++ .../FlowTests/PayInStepsTests.swift | 19 +++ .../FlowTests/TokenProbeResultsTests.swift | 136 ++++++++++++++++++ .../PayIn/PaymentCaptureQAView.swift | 12 +- .../PayIn/PaymentMethodQAView.swift | 12 +- .../PayabliDemo.xcodeproj/project.pbxproj | 6 + .../Shared/TokenProbeResults.swift | 104 +++++++++++--- 9 files changed, 291 insertions(+), 35 deletions(-) create mode 100644 Example/PayabliDemo/FlowTests/TokenProbeResultsTests.swift diff --git a/Example/PayabliDemo/App/PayabliDemoQAApp.swift b/Example/PayabliDemo/App/PayabliDemoQAApp.swift index bfe8406..b973c91 100644 --- a/Example/PayabliDemo/App/PayabliDemoQAApp.swift +++ b/Example/PayabliDemo/App/PayabliDemoQAApp.swift @@ -44,8 +44,13 @@ struct PayabliDemoQAApp: App { ) /// One owner for the token probes, so a tab that has finished its backend - /// step still reflects an answer another tab has since had. - @StateObject private var tokenProbes = TokenProbeResults() + /// step still reflects an answer another tab has since had. One entry per + /// token function, because a backend may scope them separately. + @StateObject private var tokenProbes = TokenProbeResults( + fetchCardPresent: { try await Secrets.fetchAccessToken() }, + fetchStoredMethod: { try await Secrets.fetchPaymentMethodAccessToken() }, + fetchCapture: { try await Secrets.fetchPaymentCaptureAccessToken() } + ) var body: some Scene { WindowGroup { @@ -134,5 +139,5 @@ struct PayabliDemoQAApp: App { Label("Config", systemImage: "gearshape") } } - .environmentObject(TokenProbeResults()) + .environmentObject(TokenProbeResults.inert()) } diff --git a/Example/PayabliDemo/Configuration/ConfigurationQAView.swift b/Example/PayabliDemo/Configuration/ConfigurationQAView.swift index d22ab7e..1cf13a0 100644 --- a/Example/PayabliDemo/Configuration/ConfigurationQAView.swift +++ b/Example/PayabliDemo/Configuration/ConfigurationQAView.swift @@ -87,7 +87,7 @@ struct ConfigurationQAView: View { .disabled(isWorking) Button { runCardNotPresentTokenCheck() } label: { - Label("Check card-not-present token", systemImage: "key.horizontal") + Label("Check card-not-present tokens", systemImage: "key.horizontal") } .buttonStyle(.bordered) .disabled(isWorking) @@ -99,7 +99,12 @@ struct ConfigurationQAView: View { .disabled(isWorking) ForEach( - [tokenProbes.cardPresent, tokenProbes.cardNotPresent, healthCheckText].filter { !$0.isEmpty }, + [ + tokenProbes.cardPresent, + tokenProbes.storedMethod, + tokenProbes.capture, + healthCheckText + ].filter { !$0.isEmpty }, id: \.self ) { line in Text(line) @@ -228,11 +233,14 @@ struct ConfigurationQAView: View { } } + /// Both card-not-present endpoints, because the two tabs submit with + /// different token functions and this screen answers for both. private func runCardNotPresentTokenCheck() { isWorking = true Task { defer { isWorking = false } - await tokenProbes.probeCardNotPresent() + await tokenProbes.probeStoredMethod() + await tokenProbes.probeCapture() } } @@ -258,5 +266,5 @@ struct ConfigurationQAView: View { #Preview { ConfigurationQAView() - .environmentObject(TokenProbeResults()) + .environmentObject(TokenProbeResults.inert()) } diff --git a/Example/PayabliDemo/Flow/PayInSteps.swift b/Example/PayabliDemo/Flow/PayInSteps.swift index c20be5c..26715c6 100644 --- a/Example/PayabliDemo/Flow/PayInSteps.swift +++ b/Example/PayabliDemo/Flow/PayInSteps.swift @@ -57,6 +57,16 @@ enum PayInSteps { let showingFinishedResult = progress.hasResult && !progress.resultAcknowledged let backend: StepStatus = { + // A submission in flight got a token to submit with, so this step is + // finished for as long as it lasts. The probe is shared, so another + // tab can answer for this endpoint mid-submit; letting that unfinish + // this step would block the form, hide the row, and deallocate the + // view model holding what was typed. The answer applies once the + // submission is over, which is when it says anything about the next + // one. + if progress.isSubmitting { + return .done + } switch progress.tokenCheck { // Before the outcome, or the step offers its button over a request // already in flight. diff --git a/Example/PayabliDemo/FlowTests/PayInStepsTests.swift b/Example/PayabliDemo/FlowTests/PayInStepsTests.swift index 23aeef9..96a7237 100644 --- a/Example/PayabliDemo/FlowTests/PayInStepsTests.swift +++ b/Example/PayabliDemo/FlowTests/PayInStepsTests.swift @@ -160,6 +160,25 @@ final class PayInStepsTests: XCTestCase { XCTAssertFalse(steps.form.status.isActionable) } + func testAProbeLandingMidSubmissionDoesNotHideTheForm() { + // The Configuration tab shares this probe, so it can answer while a + // submission is in flight here. A blocked form is a deallocated view + // model and the typed values go with it. + for check in [TokenCheck.unreachable, .checking, .notRun, .reachable] { + let steps = PayInSteps.forCapture(PayInProgress(tokenCheck: check, isSubmitting: true)) + XCTAssertEqual(steps.backend.status, .done, "token \(check)") + XCTAssertEqual(steps.form.status, .inProgress, "token \(check)") + XCTAssertTrue(steps.form.status.showsContent, "token \(check)") + XCTAssertFalse(steps.form.status.isActionable, "token \(check)") + } + } + + func testAProbeFailureAppliesOnceTheSubmissionFinishes() { + let steps = PayInSteps.forCapture(PayInProgress(tokenCheck: .unreachable, isSubmitting: false)) + XCTAssertEqual(steps.backend.status, .failed) + XCTAssertEqual(steps.form.status, .blocked) + } + func testAFailedSubmissionIsReportedByTheStepThatTookIt() { let steps = PayInSteps.forCapture(PayInProgress(tokenCheck: .reachable, submitFailed: true)) XCTAssertEqual(steps.form.status, .failed) diff --git a/Example/PayabliDemo/FlowTests/TokenProbeResultsTests.swift b/Example/PayabliDemo/FlowTests/TokenProbeResultsTests.swift new file mode 100644 index 0000000..7dedd4f --- /dev/null +++ b/Example/PayabliDemo/FlowTests/TokenProbeResultsTests.swift @@ -0,0 +1,136 @@ +import XCTest + +/// The probes are shared across tabs, so two runs of one can be in flight at +/// once and finish in either order. These pin the rule that the run started last +/// is the one that publishes, and that the three probes are separate answers. +@MainActor +final class TokenProbeResultsTests: XCTestCase { + /// Holds each fetch until the test releases it, so the finishing order is the + /// test's choice. Ordering asserted against real timing would be a coin toss. + private actor Latch { + private var entered = 0 + private var held: [Int: CheckedContinuation] = [:] + + func enter() -> Int { + entered += 1 + return entered + } + + func count() -> Int { + entered + } + + func hold(_ run: Int) async { + await withCheckedContinuation { held[run] = $0 } + } + + func release(_ run: Int) { + held.removeValue(forKey: run)?.resume() + } + } + + private struct Refused: Error, LocalizedError { + var errorDescription: String? { + "refused" + } + } + + private let succeeded = "✓ Card-present token endpoint returned a token" + private let failed = "✗ Card-present token endpoint failed: refused" + + /// Bounded, so a condition that never holds fails with a name instead of + /// running the suite into its timeout. + private func waitUntil( + _ description: String, + _ condition: () async -> Bool, + file: StaticString = #filePath, + line: UInt = #line + ) async { + for _ in 0 ..< 1000 { + if await condition() { + return + } + await Task.yield() + } + XCTFail("never became true: \(description)", file: file, line: line) + } + + func testAnEarlierRunFinishingLastDoesNotOverwriteTheLatestAnswer() async { + let latch = Latch() + // The first run refuses and the second succeeds, so which one published + // is visible in the text rather than inferred. + let store = TokenProbeResults( + fetchCardPresent: { + let run = await latch.enter() + await latch.hold(run) + if run == 1 { + throw Refused() + } + return "token" + }, + fetchStoredMethod: { "" }, + fetchCapture: { "" } + ) + + let first = Task { await store.probeCardPresent() } + await waitUntil("the first run reaches its fetch") { await latch.count() >= 1 } + let second = Task { await store.probeCardPresent() } + await waitUntil("the second run reaches its fetch") { await latch.count() >= 2 } + + await latch.release(2) + await second.value + XCTAssertEqual(store.cardPresent, succeeded) + + await latch.release(1) + await first.value + XCTAssertEqual( + store.cardPresent, succeeded, + "the first run published over an answer given after it started" + ) + } + + func testASingleRunStillPublishes() async { + let store = TokenProbeResults( + fetchCardPresent: { "token" }, + fetchStoredMethod: { "" }, + fetchCapture: { "" } + ) + + await store.probeCardPresent() + + XCTAssertEqual(store.cardPresent, succeeded) + } + + func testAFailureIsPublishedLikeAnyOtherAnswer() async { + let store = TokenProbeResults( + fetchCardPresent: { throw Refused() }, + fetchStoredMethod: { "" }, + fetchCapture: { "" } + ) + + await store.probeCardPresent() + + XCTAssertEqual(store.cardPresent, failed) + } + + /// The two card-not-present tabs submit with different token functions, so + /// one answering must not answer for the other. + func testTheThreeProbesAreSeparateAnswers() async { + let store = TokenProbeResults( + fetchCardPresent: { "token" }, + fetchStoredMethod: { "token" }, + fetchCapture: { throw Refused() } + ) + + await store.probeStoredMethod() + + XCTAssertEqual(store.storedMethod, "✓ Stored-method token endpoint returned a token") + XCTAssertEqual(store.cardPresent, "") + XCTAssertEqual(store.capture, "") + + await store.probeCapture() + + XCTAssertEqual(store.capture, "✗ Capture token endpoint failed: refused") + XCTAssertEqual(store.storedMethod, "✓ Stored-method token endpoint returned a token") + } +} diff --git a/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift b/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift index 6ad1f18..3aead10 100644 --- a/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift +++ b/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift @@ -32,10 +32,10 @@ struct PaymentCaptureQAView: View { } .buttonStyle(.bordered) .disabled(isCheckingToken) - if !tokenProbes.cardNotPresent.isEmpty { - Text(tokenProbes.cardNotPresent) + if !tokenProbes.capture.isEmpty { + Text(tokenProbes.capture) .font(.caption) - .foregroundColor(tokenProbes.cardNotPresent.hasPrefix("✗") ? .payabliError : .payabliOnSurfaceVariant) + .foregroundColor(tokenProbes.capture.hasPrefix("✗") ? .payabliError : .payabliOnSurfaceVariant) } } } @@ -133,7 +133,7 @@ struct PaymentCaptureQAView: View { private var steps: PayInFlowSteps { PayInSteps.forCapture( PayInProgress( - tokenCheck: TokenCheck.classify(tokenProbes.cardNotPresent), + tokenCheck: TokenCheck.classify(tokenProbes.capture), hasResult: paymentFlow.lastResult != nil, resultAcknowledged: resultAcknowledged, isSubmitting: paymentFlow.isSubmitting, @@ -177,7 +177,7 @@ struct PaymentCaptureQAView: View { isCheckingToken = true Task { defer { isCheckingToken = false } - await tokenProbes.probeCardNotPresent() + await tokenProbes.probeCapture() } } @@ -377,5 +377,5 @@ struct PaymentCaptureQAView: View { ) ) ) - .environmentObject(TokenProbeResults()) + .environmentObject(TokenProbeResults.inert()) } diff --git a/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift b/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift index 6951240..2c33fd0 100644 --- a/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift +++ b/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift @@ -31,10 +31,10 @@ struct PaymentMethodQAView: View { } .buttonStyle(.bordered) .disabled(isCheckingToken) - if !tokenProbes.cardNotPresent.isEmpty { - Text(tokenProbes.cardNotPresent) + if !tokenProbes.storedMethod.isEmpty { + Text(tokenProbes.storedMethod) .font(.caption) - .foregroundColor(tokenProbes.cardNotPresent.hasPrefix("✗") ? .payabliError : .payabliOnSurfaceVariant) + .foregroundColor(tokenProbes.storedMethod.hasPrefix("✗") ? .payabliError : .payabliOnSurfaceVariant) } } } @@ -130,7 +130,7 @@ struct PaymentMethodQAView: View { private var steps: PayInFlowSteps { PayInSteps.forStoringMethod( PayInProgress( - tokenCheck: TokenCheck.classify(tokenProbes.cardNotPresent), + tokenCheck: TokenCheck.classify(tokenProbes.storedMethod), hasResult: paymentFlow.lastResult != nil, resultAcknowledged: resultAcknowledged, isSubmitting: paymentFlow.isSubmitting, @@ -152,7 +152,7 @@ struct PaymentMethodQAView: View { isCheckingToken = true Task { defer { isCheckingToken = false } - await tokenProbes.probeCardNotPresent() + await tokenProbes.probeStoredMethod() } } @@ -303,5 +303,5 @@ struct PaymentMethodQAView: View { environment: DemoConfiguration.environment ) ) - .environmentObject(TokenProbeResults()) + .environmentObject(TokenProbeResults.inert()) } diff --git a/Example/PayabliDemo/PayabliDemo.xcodeproj/project.pbxproj b/Example/PayabliDemo/PayabliDemo.xcodeproj/project.pbxproj index 1768d0c..f709f9d 100644 --- a/Example/PayabliDemo/PayabliDemo.xcodeproj/project.pbxproj +++ b/Example/PayabliDemo/PayabliDemo.xcodeproj/project.pbxproj @@ -10,6 +10,8 @@ A1B2C3D4E5F60000000000E2 /* QAContextLine.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60000000000E1 /* QAContextLine.swift */; }; A1B2C3D4E5F60000000000D2 /* StepRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60000000000D1 /* StepRow.swift */; }; A1B2C3D4E5F60000000000F2 /* TokenProbeResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60000000000F1 /* TokenProbeResults.swift */; }; + A1B2C3D4E5F60000000000F3 /* TokenProbeResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60000000000F1 /* TokenProbeResults.swift */; }; + A1B2C3D4E5F60000000000F5 /* TokenProbeResultsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60000000000F4 /* TokenProbeResultsTests.swift */; }; 0F5E8CC7E7A24D19A71B0A1C /* PaymentMethodAddedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F5E8CC6E7A24D19A71B0A1C /* PaymentMethodAddedView.swift */; }; 1C7A9E9B6D2F4C0F8A1B3D5E /* PaymentCaptureQAView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B1E0F9A8C7D6E5F4A3B2C1D /* PaymentCaptureQAView.swift */; }; 3F9C8D7E6A5B4C3D2E1F0A9B /* PayabliSDKExampleAggregate in Frameworks */ = {isa = PBXBuildFile; productRef = 4A0D9E8F7B6C5D4E3F2A1B0C /* PayabliSDKExampleAggregate */; }; @@ -73,6 +75,7 @@ A1B2C3D4E5F60000000000E1 /* QAContextLine.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = QAContextLine.swift; sourceTree = ""; }; A1B2C3D4E5F60000000000D1 /* StepRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StepRow.swift; sourceTree = ""; }; A1B2C3D4E5F60000000000F1 /* TokenProbeResults.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TokenProbeResults.swift; sourceTree = ""; }; + A1B2C3D4E5F60000000000F4 /* TokenProbeResultsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TokenProbeResultsTests.swift; sourceTree = ""; }; 0F5E8CC6E7A24D19A71B0A1C /* PaymentMethodAddedView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PaymentMethodAddedView.swift; sourceTree = ""; }; 1DADA588F49DF0C4A6611302 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 4C921D1F3EE7130C23911757 /* Secrets.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = ""; }; @@ -308,6 +311,7 @@ F20000000000000000000021 /* StepStatusTests.swift */, F20000000000000000000023 /* TapToPayStepsTests.swift */, F20000000000000000000025 /* PayInStepsTests.swift */, + A1B2C3D4E5F60000000000F4 /* TokenProbeResultsTests.swift */, ); path = FlowTests; sourceTree = ""; @@ -431,6 +435,8 @@ F20000000000000000000022 /* StepStatusTests.swift in Sources */, F20000000000000000000024 /* TapToPayStepsTests.swift in Sources */, F20000000000000000000026 /* PayInStepsTests.swift in Sources */, + A1B2C3D4E5F60000000000F3 /* TokenProbeResults.swift in Sources */, + A1B2C3D4E5F60000000000F5 /* TokenProbeResultsTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Example/PayabliDemo/Shared/TokenProbeResults.swift b/Example/PayabliDemo/Shared/TokenProbeResults.swift index 56d0e90..703939d 100644 --- a/Example/PayabliDemo/Shared/TokenProbeResults.swift +++ b/Example/PayabliDemo/Shared/TokenProbeResults.swift @@ -10,34 +10,106 @@ import SwiftUI /// probe could not be made to outrank an earlier success anywhere except a unit /// test. /// -/// Two probes, because they are two endpoints. The card-present partner token is -/// what Tap to Pay attests with; the card-not-present token is what both PayIn -/// tabs submit with, and `Secrets.fetchPaymentCaptureAccessToken` forwards to -/// `fetchPaymentMethodAccessToken`, so one answer covers both of those tabs. +/// Three probes, because they are three token functions. `Secrets.swift.sample` +/// forwards the capture one to the stored-method one and says that holds for the +/// sample "unless your backend separates scopes"; `Secrets.swift` is per +/// developer, so a tab reporting on an endpoint it never calls would be +/// answering a different question. +/// +/// The fetches are supplied rather than called directly, so the ordering rule +/// below can be tested without a backend. /// /// Each probe reports only *that* a token arrived. Never the token. @MainActor final class TokenProbeResults: ObservableObject { + typealias Fetch = @Sendable () async throws -> String + + enum Probe: Hashable { + /// The partner token Tap to Pay attests with. + case cardPresent + /// The token the stored-method tab submits with. + case storedMethod + /// The token the capture tab submits with. + case capture + } + @Published private(set) var cardPresent = "" - @Published private(set) var cardNotPresent = "" + @Published private(set) var storedMethod = "" + @Published private(set) var capture = "" + + private let fetches: [Probe: Fetch] + + /// Which run of each probe is current. `@MainActor` serialises the writes but + /// suspends at the fetch, so two probes started from different tabs + /// interleave and can finish in either order. Without this, a slower earlier + /// request publishes over the answer a later one already gave, and the shared + /// state reports something other than the latest. + private var generations: [Probe: Int] = [:] + + init( + fetchCardPresent: @escaping Fetch, + fetchStoredMethod: @escaping Fetch, + fetchCapture: @escaping Fetch + ) { + fetches = [ + .cardPresent: fetchCardPresent, + .storedMethod: fetchStoredMethod, + .capture: fetchCapture + ] + } + + /// A store whose probes answer immediately and reach no backend, for previews. + static func inert() -> TokenProbeResults { + TokenProbeResults( + fetchCardPresent: { "" }, + fetchStoredMethod: { "" }, + fetchCapture: { "" } + ) + } + + func answer(for probe: Probe) -> String { + switch probe { + case .cardPresent: cardPresent + case .storedMethod: storedMethod + case .capture: capture + } + } func probeCardPresent() async { - cardPresent = "Checking…" + await run(.cardPresent, named: "Card-present token endpoint") + } + + func probeStoredMethod() async { + await run(.storedMethod, named: "Stored-method token endpoint") + } + + func probeCapture() async { + await run(.capture, named: "Capture token endpoint") + } + + private func run(_ probe: Probe, named name: String) async { + let generation = (generations[probe] ?? 0) + 1 + generations[probe] = generation + publish("Checking…", to: probe) + + let answer: String do { - _ = try await Secrets.fetchAccessToken() - cardPresent = "✓ Card-present token endpoint returned a token" + _ = try await fetches[probe]?() + answer = "✓ \(name) returned a token" } catch { - cardPresent = "✗ Card-present token endpoint failed: \(error.localizedDescription)" + answer = "✗ \(name) failed: \(error.localizedDescription)" } + + // A later run of this probe has already answered, so this one is stale. + guard generations[probe] == generation else { return } + publish(answer, to: probe) } - func probeCardNotPresent() async { - cardNotPresent = "Checking…" - do { - _ = try await Secrets.fetchPaymentMethodAccessToken() - cardNotPresent = "✓ Card-not-present token endpoint returned a token" - } catch { - cardNotPresent = "✗ Card-not-present token endpoint failed: \(error.localizedDescription)" + private func publish(_ text: String, to probe: Probe) { + switch probe { + case .cardPresent: cardPresent = text + case .storedMethod: storedMethod = text + case .capture: capture = text } } } From 9b9956836956902d3d047e81431cb39884eb83f0 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 20:51:53 -0700 Subject: [PATCH 46/57] Count a moved statement as a change, because order is behaviour The semantic count netted out a line removed from one place and added unaltered in another, on the stated grounds that a move compiles to what it compiled to before. That holds for a declaration and not for a statement: validation moved to after the network call it guards is a change made entirely of unaltered lines, and both ends cancelled to zero. The report is what tells a reviewer which files need no reading, so the netting turned a behaviour change into "Comments and documentation only. Compiles to the same code." Measured on a commit that moves one `logger.info` past the `transport.perform` below it and alters nothing else. The previous version reported 1 file, documentation only, 0 declarations or statements changed. This one reports it under "Declarations or statements changed" with a code count of 2. On this branch the inert count is unchanged at 36, and `PayabliTTP.swift` moves from documentation-only to needing review: `e63324c` moved the `#if` above the documentation of a `public convenience init`, and a declaration relocated across a conditional-compilation boundary is not inert. Co-Authored-By: Claude Opus 5 (1M context) --- Scripts/classify-changes.sh | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/Scripts/classify-changes.sh b/Scripts/classify-changes.sh index c78871d..37c4004 100755 --- a/Scripts/classify-changes.sh +++ b/Scripts/classify-changes.sh @@ -228,17 +228,20 @@ while IFS=$'\t' read -r old_path path; do changed=$(diff "$WORK/$old_path" "$WORK/head-version" | grep -E '^[<>]' || true) - # Net of relocations. A line removed from one place and added unchanged in - # another is a move, and a move compiles to what it compiled to before. - # Counting both ends of it reports a rewrite where a block shifted. + # Both sides of the diff, counted. An earlier version netted out a line that + # was removed from one place and added unchanged in another, on the grounds + # that a move compiles to what it compiled to before. That is not true of a + # statement: order is behaviour, so validation moved to after the network + # call it guards is a change made entirely of unaltered lines, and netting + # reported it as nothing to read. side() { # $1 = < or >, $2 = keep|drop comments local filter='^(///|//|/\*|\*/|\*)' printf '%s\n' "$changed" | grep -E "^$1" | sed -E 's/^.[[:space:]]*//' \ | if [ "$2" = drop ]; then grep -vE "$filter"; else grep -E "$filter"; fi \ | grep -v '^[[:space:]]*$' | sed -E 's/[[:space:]]+/ /g' | sort } - code=$(comm -3 <(side '<' drop) <(side '>' drop) | grep -c . || true) - comments=$(comm -3 <(side '<' keep) <(side '>' keep) | grep -c . || true) + code=$(( $(side '<' drop | grep -c . || true) + $(side '>' drop | grep -c . || true) )) + comments=$(( $(side '<' keep | grep -c . || true) + $(side '>' keep | grep -c . || true) )) # The contract consumers compile against, which the repository holds in # common with the SDK for Android. Both sides are the whole visible surface @@ -302,9 +305,10 @@ if [ "$semantic" -gt 0 ]; then echo "#### Files that can change behaviour" echo echo "\`Code\` counts declarations and executable statements that differ once" - echo "formatting is normalised and relocations are netted out: a line moved" - echo "without being altered compiles to what it compiled to before. \`Docs\` is" - echo "the same count for comments." + echo "formatting is normalised, on both sides of the diff, so an altered line" + echo "counts twice and a line that only moved still counts. Order is behaviour:" + echo "validation moved to after the network call it guards is a change made" + echo "entirely of unaltered lines. \`Docs\` is the same count for comments." echo echo "| Code | Docs | File |" echo "| ---: | ---: | --- |" From 68d422b38c06c4938c194be0c73ea0c2ef000339 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 20:52:17 -0700 Subject: [PATCH 47/57] Take the probe button's enabled state from the step, not a local flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each screen disabled its probe button on a flag it set itself, so a probe started on another tab left the button enabled everywhere else. The row stays mounted while the step is `.inProgress`, so the button was offered against a row already reading "working…", and pressing it started a second run of a probe that was already in flight. The derived step is what knows a shared probe is running, which is the same reason every other control on these screens reads it. On the two card-not-present tabs the local flag is now redundant and gone; the button is disabled unless the backend step is actionable. Tap to Pay keeps `isWorking`, which also covers its enable, activate and charge operations, and adds the step condition beside it. The Configuration screen has the same shape and was not in the review: its buttons were gated on its own `isWorking` alone, so a probe running on a tab left them enabled. Each is now also disabled while the probe it starts is in flight. The generation guard already made a second run harmless, so this is what the screen offers rather than what it would corrupt. No derivation changes and the 67 sequence tests are unchanged; checked by building the sample app. Co-Authored-By: Claude Opus 5 (1M context) --- .../Configuration/ConfigurationQAView.swift | 14 ++++++++++++-- .../PayabliDemo/PayIn/PaymentCaptureQAView.swift | 12 +++++------- .../PayabliDemo/PayIn/PaymentMethodQAView.swift | 12 +++++------- .../TapToPay/PaymentTapToPayQAView.swift | 5 ++++- 4 files changed, 26 insertions(+), 17 deletions(-) diff --git a/Example/PayabliDemo/Configuration/ConfigurationQAView.swift b/Example/PayabliDemo/Configuration/ConfigurationQAView.swift index 1cf13a0..cf891aa 100644 --- a/Example/PayabliDemo/Configuration/ConfigurationQAView.swift +++ b/Example/PayabliDemo/Configuration/ConfigurationQAView.swift @@ -84,13 +84,19 @@ struct ConfigurationQAView: View { Label("Check card-present token", systemImage: "key.horizontal") } .buttonStyle(.bordered) - .disabled(isWorking) + // `isWorking` is this screen's own. A probe started on a tab is in + // flight here too, and only the shared answer says so. + .disabled(isWorking || isChecking(tokenProbes.cardPresent)) Button { runCardNotPresentTokenCheck() } label: { Label("Check card-not-present tokens", systemImage: "key.horizontal") } .buttonStyle(.bordered) - .disabled(isWorking) + .disabled( + isWorking + || isChecking(tokenProbes.storedMethod) + || isChecking(tokenProbes.capture) + ) Button { runHealthCheck() } label: { Label("Local server health", systemImage: "heart.text.square") @@ -223,6 +229,10 @@ struct ConfigurationQAView: View { } } + private func isChecking(_ answer: String) -> Bool { + TokenCheck.classify(answer) == .checking + } + // MARK: - Actions private func runTokenCheck() { diff --git a/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift b/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift index 3aead10..80323ee 100644 --- a/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift +++ b/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift @@ -11,7 +11,6 @@ struct PaymentCaptureQAView: View { @State private var resultText = "" @State private var resultAcknowledged = false @State private var submitFailed = false - @State private var isCheckingToken = false @State private var capturedResult: PayabliPayInPaymentFlowResult? @State private var isPaymentCaptureSheetPresented = false @State private var isPaymentCaptureResultViewPresented = false @@ -31,7 +30,10 @@ struct PaymentCaptureQAView: View { Label("Check token endpoint", systemImage: "key.horizontal") } .buttonStyle(.bordered) - .disabled(isCheckingToken) + // The probe is shared, so a run started on another + // tab is this step's `.inProgress` too. The derived + // step is what knows that; a local flag does not. + .disabled(!steps.backend.status.isActionable) if !tokenProbes.capture.isEmpty { Text(tokenProbes.capture) .font(.caption) @@ -174,11 +176,7 @@ struct PaymentCaptureQAView: View { } private func runTokenCheck() { - isCheckingToken = true - Task { - defer { isCheckingToken = false } - await tokenProbes.probeCapture() - } + Task { await tokenProbes.probeCapture() } } private var configuration: PayabliPayInPaymentFlowFormConfiguration { diff --git a/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift b/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift index 2c33fd0..7e34ff5 100644 --- a/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift +++ b/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift @@ -11,7 +11,6 @@ struct PaymentMethodQAView: View { @State private var resultText = "" @State private var resultAcknowledged = false @State private var submitFailed = false - @State private var isCheckingToken = false @State private var isPaymentMethodAddedViewPresented = false @State private var isPaymentMethodSheetPresented = false @@ -30,7 +29,10 @@ struct PaymentMethodQAView: View { Label("Check token endpoint", systemImage: "key.horizontal") } .buttonStyle(.bordered) - .disabled(isCheckingToken) + // The probe is shared, so a run started on another + // tab is this step's `.inProgress` too. The derived + // step is what knows that; a local flag does not. + .disabled(!steps.backend.status.isActionable) if !tokenProbes.storedMethod.isEmpty { Text(tokenProbes.storedMethod) .font(.caption) @@ -149,11 +151,7 @@ struct PaymentMethodQAView: View { } private func runTokenCheck() { - isCheckingToken = true - Task { - defer { isCheckingToken = false } - await tokenProbes.probeStoredMethod() - } + Task { await tokenProbes.probeStoredMethod() } } private var configuration: PayabliPayInPaymentFlowFormConfiguration { diff --git a/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift b/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift index f1f1db3..e12f29d 100644 --- a/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift +++ b/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift @@ -91,7 +91,10 @@ struct PaymentTapToPayQAView: View { Label("Check token endpoint", systemImage: "key.horizontal") } .buttonStyle(.bordered) - .disabled(isWorking) + // `isWorking` covers this screen's own operations. The probe + // is shared, so a run started on another tab is this step's + // `.inProgress` and only the derived step knows it. + .disabled(isWorking || !steps.token.status.isActionable) if !tokenProbes.cardPresent.isEmpty { Text(tokenProbes.cardPresent) .font(.caption) From 4ea808c34d98c15e2193e32ad5ecc1fc0a4b87ca Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 21:11:13 -0700 Subject: [PATCH 48/57] Register a held run in the same call that numbers it `Latch.enter` incremented the count and `Latch.hold` stored the continuation, as two calls on the actor. The test waited on the count, so it could reach `release(2)` after the second run was numbered and before it was registered. That release found nothing to resume and was dropped, and the run then waited for a release that had already happened: `await second.value` never returns and the case hangs until XCTest gives up, which reports nothing about the defect. `hold()` now numbers and registers in one call, with no suspension between the two lines, so a run the test can count is a run the test can release. Ten consecutive runs of the class pass. The previous structure was not observed hanging, because the interleaving it allows is the rarer one; it is removed rather than measured, since the failure it produces is a hang. Co-Authored-By: Claude Opus 5 (1M context) --- .../FlowTests/TokenProbeResultsTests.swift | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/Example/PayabliDemo/FlowTests/TokenProbeResultsTests.swift b/Example/PayabliDemo/FlowTests/TokenProbeResultsTests.swift index 7dedd4f..2735479 100644 --- a/Example/PayabliDemo/FlowTests/TokenProbeResultsTests.swift +++ b/Example/PayabliDemo/FlowTests/TokenProbeResultsTests.swift @@ -8,20 +8,24 @@ final class TokenProbeResultsTests: XCTestCase { /// Holds each fetch until the test releases it, so the finishing order is the /// test's choice. Ordering asserted against real timing would be a coin toss. private actor Latch { - private var entered = 0 + private var registered = 0 private var held: [Int: CheckedContinuation] = [:] - func enter() -> Int { - entered += 1 - return entered + /// Takes the next run number and registers it in one call. Numbering in + /// one call and registering in another lets a `release` land in between, + /// where it finds nothing to resume and is dropped, and the run it was + /// meant for then waits for a release that has already happened. Nothing + /// suspends between the two lines below, so a run the test can count is + /// a run the test can release. + func hold() async -> Int { + registered += 1 + let run = registered + await withCheckedContinuation { held[run] = $0 } + return run } func count() -> Int { - entered - } - - func hold(_ run: Int) async { - await withCheckedContinuation { held[run] = $0 } + registered } func release(_ run: Int) { @@ -61,8 +65,7 @@ final class TokenProbeResultsTests: XCTestCase { // is visible in the text rather than inferred. let store = TokenProbeResults( fetchCardPresent: { - let run = await latch.enter() - await latch.hold(run) + let run = await latch.hold() if run == 1 { throw Refused() } @@ -73,9 +76,9 @@ final class TokenProbeResultsTests: XCTestCase { ) let first = Task { await store.probeCardPresent() } - await waitUntil("the first run reaches its fetch") { await latch.count() >= 1 } + await waitUntil("the first run registers") { await latch.count() >= 1 } let second = Task { await store.probeCardPresent() } - await waitUntil("the second run reaches its fetch") { await latch.count() >= 2 } + await waitUntil("the second run registers") { await latch.count() >= 2 } await latch.release(2) await second.value From d7a8f3301e9900354453f498adc818a7a94f5711 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 21:12:00 -0700 Subject: [PATCH 49/57] Let the change-report comment fail out loud The job and the step both carried continue-on-error, for a fork's pull request, whose token is read-only and cannot post. That made every outcome green: a report that had stopped posting looked exactly like one that posted, and the only evidence was a comment nobody noticed was stale. The fork case is skipped by the same condition the analysis job already uses, so the case that cannot work does not run and every other failure is a red job. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 379e033..c8dd0a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,8 +91,14 @@ jobs: name: Change report comment runs-on: ubuntu-latest needs: [changes] - if: github.event_name == 'pull_request' - continue-on-error: true + # A fork's pull request gets a read-only token, so skip it there rather than + # let it fail. `continue-on-error` used to cover that case, on the job and on + # the step, which made every outcome green: a report that had stopped posting + # was indistinguishable from one that posted. Skipping the case that cannot + # work leaves a same-repository failure loud. + if: >- + github.event_name == 'pull_request' + && github.event.pull_request.head.repo.full_name == github.repository permissions: pull-requests: write @@ -107,7 +113,6 @@ jobs: # itself. A fork's pull request has a read-only token, so this is allowed # to fail without taking the job with it. - name: Comment on the pull request - continue-on-error: true env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR: ${{ github.event.pull_request.number }} From fc436ecca21291ec1a9570a6acc569b33cb5a633 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 21:12:19 -0700 Subject: [PATCH 50/57] Post the change report where it will be read The report was edited in place, which kept it to one comment and left it wherever it was first posted. On a review of this length that is pages above the discussion, marked only "edited", so six pushes updated a comment that never moved and the report read as broken. Every earlier copy is deleted and a fresh one posted, so there is still exactly one report and it is the newest thing on the page. The marker the script writes is what finds them; on this pull request it matches 1 of 12 comments, checked before the deletion was written. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8dd0a0..4e60c9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,8 +94,9 @@ jobs: # A fork's pull request gets a read-only token, so skip it there rather than # let it fail. `continue-on-error` used to cover that case, on the job and on # the step, which made every outcome green: a report that had stopped posting - # was indistinguishable from one that posted. Skipping the case that cannot - # work leaves a same-repository failure loud. + # was indistinguishable from one that posted, and the only signal was a + # comment nobody noticed was stale. Skipping the case that cannot work leaves + # a same-repository failure loud. if: >- github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository @@ -108,10 +109,11 @@ jobs: with: name: change-report - # One comment that is edited in place, found by the marker the script - # writes. A new comment on every push buries the review under reports of - # itself. A fork's pull request has a read-only token, so this is allowed - # to fail without taking the job with it. + # One report on the pull request, and always the newest thing on the page. + # Editing in place kept it to one comment but left it wherever it was first + # posted, so on a long review it sits pages above the discussion, marked + # only "edited". Every earlier copy is deleted and a fresh one posted, so + # there is still exactly one and it is where a reader will see it. - name: Comment on the pull request env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -120,16 +122,15 @@ jobs: run: | set -euo pipefail # jq slurps the pages into one array. --jq would run once per page, so - # `last` would be the last match on the last page and not overall. + # a match on an earlier page would be missed. existing=$(gh api "repos/$REPO/issues/$PR/comments" --paginate \ - | jq -s 'add | map(select(.body | contains(""))) | last | .id // empty') - if [ -n "$existing" ]; then - gh api -X PATCH "repos/$REPO/issues/comments/$existing" -F body=@report.md >/dev/null - echo "updated comment $existing" - else - gh api -X POST "repos/$REPO/issues/$PR/comments" -F body=@report.md >/dev/null - echo "posted a new comment" - fi + | jq -s 'add | map(select(.body | contains("")) | .id) | .[]') + for id in $existing; do + gh api -X DELETE "repos/$REPO/issues/comments/$id" >/dev/null + echo "deleted comment $id" + done + gh api -X POST "repos/$REPO/issues/$PR/comments" -F body=@report.md >/dev/null + echo "posted the report" test-sdk: name: SDK tests From 9fdb83cde77dc5330d5ccc431d2ad56d96a08496 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 21:44:02 -0700 Subject: [PATCH 51/57] Run the two token-holding jobs from the default branch `ci.yml` is triggered by `pull_request`, and GitHub runs the head revision's copy of it. A same-repository pull request is granted the repository's secrets, so a branch could add a step to the analysis job and read `SONAR_TOKEN`, or to the comment job and read the write token. Splitting the token into a job that checks nothing out did not close that: the job's steps are still written in the branch's copy of the file. Both move to `pr-reports.yml`, triggered by this workflow finishing. GitHub triggers `workflow_run` only for a workflow file that exists on the default branch, and runs that copy, so those two jobs are no longer editable by the pull request they report on. Nothing left in `ci.yml` holds a secret or a write. A `workflow_run` job has no pull request of its own, so the number, head ref and base ref travel in the artifacts as `pr.json`. They are passed through `env` and written with `jq`: a branch name is chosen by whoever opens the pull request and `${{ }}` pastes it into the shell before the shell sees it, so a branch called `$(...)` would otherwise run as a command in a job that now holds a token. The readers parse the file rather than sourcing it, and refuse a ref carrying anything but the characters git needs. On a push there is no `pr.json`, and its absence is what selects a branch analysis over pull request decoration. Checked locally against the three inputs: a real pull request yields the three `sonar.pullrequest` arguments, a branch named `evil$(id)` is refused and reddens the job, and an absent file analyses a branch. `actionlint` passes on both files. What this cannot buy: the scanner needs the head revision in the workspace, so that job still checks the pull request's source out while holding the token. It runs nothing from the tree, and the coverage it reads was produced by the run that triggered it, but the protection is over the workflow definition rather than over what is analysed. The cost, stated because it is not recoverable from the diff: a `workflow_run` workflow does not run until it is on the default branch, so this pull request has no change-report comment and no analysis check until it merges, and neither job can be exercised before then. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 143 +++++++++++-------------------- .github/workflows/pr-reports.yml | 136 +++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 91 deletions(-) create mode 100644 .github/workflows/pr-reports.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e60c9f..c07b61c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,14 +10,21 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true -# Three jobs, run at once. The two suites are the whole cost of this workflow -# and neither reads anything the other writes, so run in sequence they only add -# up. They cannot share a build: both compile the SDK, but one is the package's -# own test action and the other consumes the package as a dependency of +# Four jobs, run at once. The two suites are the whole cost of this workflow and +# neither reads anything the other writes, so run in sequence they only add up. +# They cannot share a build: both compile the SDK, but one is the package's own +# test action and the other consumes the package as a dependency of # Example/PayabliDemo.xcodeproj, and Xcode compiles a dependency with # -suppress-warnings. Same intermediates path, different flags, so pointing both # at one -derivedDataPath makes them invalidate each other's objects rather than # reuse them. +# +# Nothing here is granted a secret or a write. Posting the change report and +# running the analysis both need one, and both live in `pr-reports.yml`, which +# this run triggers when it finishes. That file is read from the default branch, +# so a pull request cannot edit the jobs that hold the tokens; every job below +# is read from the pull request's own revision, which is why none of them may +# hold one. jobs: lint: @@ -45,11 +52,9 @@ jobs: # can redden a pull request is a gate, and this is not one. # # This job runs the pull request's own code — the local action and the script - # under Scripts/ — so it is read-only and holds no token worth taking. The - # comment is posted by the job below, which runs none of it. A step can - # prepend to $GITHUB_PATH for the steps after it, so code from the branch and - # a write token in one job means the code can wrap `gh` and read the token out - # of the environment when the later step calls it. + # under Scripts/ — so it is read-only and holds no token. Posting the report + # needs one, and that happens in `pr-reports.yml`, which runs from the default + # branch where this branch cannot edit it. changes: name: Change report runs-on: macos-15 @@ -75,62 +80,30 @@ jobs: > report.md cat report.md >> "$GITHUB_STEP_SUMMARY" + # `pr-reports.yml` is triggered by this run finishing and has no pull + # request of its own to read, so the identifiers travel with the report. + # + # Through `env` and `jq`, never interpolated into the script. A branch name + # is chosen by whoever opens the pull request, and `${{ }}` pastes it into + # the shell before the shell sees it, so a branch called `$(...)` would run + # as a command. JSON also means the reader does not have to `source` it. + - name: Record the pull request + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_BRANCH: ${{ github.event.pull_request.head.ref }} + PR_BASE: ${{ github.event.pull_request.base.ref }} + run: | + set -euo pipefail + jq -n --arg number "$PR_NUMBER" --arg branch "$PR_BRANCH" --arg base "$PR_BASE" \ + '{number: $number, branch: $branch, base: $base}' > pr.json + - name: Upload the report uses: actions/upload-artifact@v4 with: name: change-report - path: report.md - - # Holds the write token and runs nothing from the pull request: no checkout, - # no local action, no script from the branch. `gh` and `jq` come from the - # runner image, and the report arrives as an artifact, which is data. - # - # ubuntu, because nothing here needs Xcode, and a macOS runner bills at ten - # times the rate for a step that posts one comment. - changes-comment: - name: Change report comment - runs-on: ubuntu-latest - needs: [changes] - # A fork's pull request gets a read-only token, so skip it there rather than - # let it fail. `continue-on-error` used to cover that case, on the job and on - # the step, which made every outcome green: a report that had stopped posting - # was indistinguishable from one that posted, and the only signal was a - # comment nobody noticed was stale. Skipping the case that cannot work leaves - # a same-repository failure loud. - if: >- - github.event_name == 'pull_request' - && github.event.pull_request.head.repo.full_name == github.repository - permissions: - pull-requests: write - - steps: - - name: Download the report - uses: actions/download-artifact@v4 - with: - name: change-report - - # One report on the pull request, and always the newest thing on the page. - # Editing in place kept it to one comment but left it wherever it was first - # posted, so on a long review it sits pages above the discussion, marked - # only "edited". Every earlier copy is deleted and a fresh one posted, so - # there is still exactly one and it is where a reader will see it. - - name: Comment on the pull request - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR: ${{ github.event.pull_request.number }} - REPO: ${{ github.repository }} - run: | - set -euo pipefail - # jq slurps the pages into one array. --jq would run once per page, so - # a match on an earlier page would be missed. - existing=$(gh api "repos/$REPO/issues/$PR/comments" --paginate \ - | jq -s 'add | map(select(.body | contains("")) | .id) | .[]') - for id in $existing; do - gh api -X DELETE "repos/$REPO/issues/comments/$id" >/dev/null - echo "deleted comment $id" - done - gh api -X POST "repos/$REPO/issues/$PR/comments" -F body=@report.md >/dev/null - echo "posted the report" + path: | + report.md + pr.json test-sdk: name: SDK tests @@ -167,11 +140,28 @@ jobs: ./Scripts/xccov-to-sonarqube-generic.sh --include Sources/ \ SDKTests.xcresult > coverage.xml + # As in the report job: the analysis runs from `pr-reports.yml`, which is + # triggered by this run finishing and has no pull request of its own. On a + # push there is none to record and the file is simply absent, which is how + # that workflow tells a branch analysis from a pull request one. + - name: Record the pull request + if: github.event_name == 'pull_request' + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_BRANCH: ${{ github.event.pull_request.head.ref }} + PR_BASE: ${{ github.event.pull_request.base.ref }} + run: | + set -euo pipefail + jq -n --arg number "$PR_NUMBER" --arg branch "$PR_BRANCH" --arg base "$PR_BASE" \ + '{number: $number, branch: $branch, base: $base}' > pr.json + - name: Upload coverage uses: actions/upload-artifact@v4 with: name: coverage - path: coverage.xml + path: | + coverage.xml + pr.json test-demo: name: Sample app step sequences @@ -202,32 +192,3 @@ jobs: - name: Report test failures if: failure() run: ./Scripts/print-test-failures.sh DemoFlowTests.xcresult - - sonar: - name: SonarCloud - runs-on: macos-15 - # Only the SDK suite's coverage is analysed, so this does not wait on the - # sample app. - needs: [test-sdk] - # A fork's pull request has no access to the token, and the scan would fail - # rather than be skipped. - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - steps: - # Analysis reads the git history to decide what is new code. - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Download coverage - uses: actions/download-artifact@v4 - with: - name: coverage - - # Pinned to a commit, not a tag. `v5` is mutable, and this step is handed - # SONAR_TOKEN, so a replaced action would hold a repository secret. Same - # reasoning as the checksummed tool downloads above. - - name: Analyze - uses: SonarSource/sonarqube-scan-action@2f77a1ec69fb1d595b06f35ab27e97605bdef703 # v5.3.2 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/pr-reports.yml b/.github/workflows/pr-reports.yml new file mode 100644 index 0000000..200e8ce --- /dev/null +++ b/.github/workflows/pr-reports.yml @@ -0,0 +1,136 @@ +name: PR reports + +# Everything that needs a token, kept out of reach of the code it reports on. +# +# A `pull_request` workflow runs the head revision's copy of its own file, and a +# same-repository pull request is granted the repository's secrets, so a branch +# can add a step to a job in `ci.yml` and read whatever that job holds. Splitting +# the token into a job that checks out nothing does not close it: the job's steps +# are still written in the branch's copy of the file. +# +# GitHub triggers `workflow_run` only for a workflow file that exists on the +# default branch, and runs that copy. So the two jobs below cannot be edited by +# the pull request they are reporting on. +# +# The consequence to know: a change to this file does not take effect until it +# is on the default branch, and cannot be exercised from a pull request. + +on: + workflow_run: + workflows: [CI] + types: [completed] + +# Granted per job, never here. +permissions: {} + +jobs: + comment: + name: Change report comment + runs-on: ubuntu-latest + # `ci.yml` builds the report on pull requests only, and `ci.yml` cancels a + # run in progress when the next push arrives, which leaves no artifact to + # download. A failed run still has one, because the report job is not a gate. + if: >- + github.event.workflow_run.event == 'pull_request' + && github.event.workflow_run.conclusion != 'cancelled' + permissions: + pull-requests: write + actions: read + + steps: + # From the run that triggered this one, which is not the run this job is + # part of, so the id and a token are both required. + - name: Download the report + uses: actions/download-artifact@v4 + with: + name: change-report + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + # One report on the pull request, and always the newest thing on the page. + # Editing in place kept it to one comment and left it wherever it was first + # posted, which on a long review is pages above the discussion and marked + # only "edited". Every earlier copy is deleted and a fresh one posted. + - name: Comment on the pull request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + # Read, not sourced. The file carries a branch name chosen by whoever + # opened the pull request, and this job holds a write token. + pr=$(jq -r '.number | select(test("^[0-9]+$"))' pr.json) + [ -n "$pr" ] || { echo "pr.json carried no pull request number" >&2; exit 1; } + # jq slurps the pages into one array. --jq would run once per page, so + # a match on an earlier page would be missed. + existing=$(gh api "repos/$REPO/issues/$pr/comments" --paginate \ + | jq -s 'add | map(select(.body | contains("")) | .id) | .[]') + for id in $existing; do + gh api -X DELETE "repos/$REPO/issues/comments/$id" >/dev/null + echo "deleted comment $id" + done + gh api -X POST "repos/$REPO/issues/$pr/comments" -F body=@report.md >/dev/null + echo "posted the report" + + sonar: + name: SonarCloud + runs-on: macos-15 + # No coverage is produced by a run whose suite failed. + if: github.event.workflow_run.conclusion == 'success' + permissions: + contents: read + actions: read + + steps: + # The revision the triggering run tested, not the default branch this + # workflow is read from. Analysis reads the history to decide what is new. + # + # Checking the branch out is not running it: no step here builds or + # executes anything from the tree, and the coverage the scanner reads was + # produced by the run that triggered this one. + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + fetch-depth: 0 + + - name: Download coverage + uses: actions/download-artifact@v4 + with: + name: coverage + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + # `ci.yml` writes pr.json on a pull request and not on a push, so its + # presence is what decides between decorating a pull request and analysing + # a branch. Without these the scanner would file a pull request's numbers + # against the branch and the decoration would never appear. + # + # Read, not sourced, and a ref carrying anything but the characters git + # needs is refused rather than pasted into an argument list. + - name: Read the pull request + id: pr + run: | + set -euo pipefail + if [ ! -f pr.json ]; then + echo "args=" >> "$GITHUB_OUTPUT" + echo "analysing a branch, not a pull request" + exit 0 + fi + number=$(jq -r '.number | select(test("^[0-9]+$"))' pr.json) + branch=$(jq -r '.branch | select(test("^[A-Za-z0-9._/-]+$"))' pr.json) + base=$(jq -r '.base | select(test("^[A-Za-z0-9._/-]+$"))' pr.json) + if [ -z "$number" ] || [ -z "$branch" ] || [ -z "$base" ]; then + echo "pr.json did not carry a usable pull request" >&2 + exit 1 + fi + echo "args=-Dsonar.pullrequest.key=$number -Dsonar.pullrequest.branch=$branch -Dsonar.pullrequest.base=$base" >> "$GITHUB_OUTPUT" + + # Pinned to a commit, not a tag. `v5` is mutable, and this step is handed + # SONAR_TOKEN, so a replaced action would hold a repository secret. + - name: Analyze + uses: SonarSource/sonarqube-scan-action@2f77a1ec69fb1d595b06f35ab27e97605bdef703 # v5.3.2 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + with: + args: ${{ steps.pr.outputs.args }} From 565338ab2eeb646fe6b0d39f78620cbb547aab32 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 22:26:23 -0700 Subject: [PATCH 52/57] Take the pull request from the event, not from the artifact The previous commit moved the tokens into a workflow the branch cannot edit and then handed that workflow a number the branch chose. `ci.yml` runs from the head revision, so `pr.json` was attacker-controlled input: a branch could write another open pull request's number into it and have the trusted workflow delete that pull request's change report and post whatever this branch's script had produced in its place, and file this revision's analysis against it. Both jobs now read `github.event.workflow_run.pull_requests[0]`, which GitHub fills in from the triggering run and a branch cannot write to. `pr.json` is gone from both artifacts rather than validated, since checking the shape of a number says nothing about whose number it is. Both jobs also require the triggering run's head repository to be this one. A fork has no entry in `pull_requests`, so there would be nothing trustworthy to name, and the analysis job would otherwise check a fork's revision out while holding the token. The refs still reach the scanner as command arguments, so they are matched against the characters git needs rather than trusted. Checked locally against the four inputs the step can receive: a pull request yields the three `sonar.pullrequest` arguments, an absent number analyses a branch, and a branch named `evil$(id)` or a number reading `24; rm -rf /` is refused and reddens the job. `actionlint` passes on both files. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 44 ++++-------------------- .github/workflows/pr-reports.yml | 58 ++++++++++++++++++++++---------- 2 files changed, 46 insertions(+), 56 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c07b61c..d127339 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,30 +80,14 @@ jobs: > report.md cat report.md >> "$GITHUB_STEP_SUMMARY" - # `pr-reports.yml` is triggered by this run finishing and has no pull - # request of its own to read, so the identifiers travel with the report. - # - # Through `env` and `jq`, never interpolated into the script. A branch name - # is chosen by whoever opens the pull request, and `${{ }}` pastes it into - # the shell before the shell sees it, so a branch called `$(...)` would run - # as a command. JSON also means the reader does not have to `source` it. - - name: Record the pull request - env: - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_BRANCH: ${{ github.event.pull_request.head.ref }} - PR_BASE: ${{ github.event.pull_request.base.ref }} - run: | - set -euo pipefail - jq -n --arg number "$PR_NUMBER" --arg branch "$PR_BRANCH" --arg base "$PR_BASE" \ - '{number: $number, branch: $branch, base: $base}' > pr.json - + # Only the report. Which pull request it belongs to is read from the + # triggering run's event by `pr-reports.yml`, because anything uploaded + # here is chosen by the branch, and that workflow holds the tokens. - name: Upload the report uses: actions/upload-artifact@v4 with: name: change-report - path: | - report.md - pr.json + path: report.md test-sdk: name: SDK tests @@ -140,28 +124,12 @@ jobs: ./Scripts/xccov-to-sonarqube-generic.sh --include Sources/ \ SDKTests.xcresult > coverage.xml - # As in the report job: the analysis runs from `pr-reports.yml`, which is - # triggered by this run finishing and has no pull request of its own. On a - # push there is none to record and the file is simply absent, which is how - # that workflow tells a branch analysis from a pull request one. - - name: Record the pull request - if: github.event_name == 'pull_request' - env: - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_BRANCH: ${{ github.event.pull_request.head.ref }} - PR_BASE: ${{ github.event.pull_request.base.ref }} - run: | - set -euo pipefail - jq -n --arg number "$PR_NUMBER" --arg branch "$PR_BRANCH" --arg base "$PR_BASE" \ - '{number: $number, branch: $branch, base: $base}' > pr.json - + # Only the coverage, for the same reason as the report above. - name: Upload coverage uses: actions/upload-artifact@v4 with: name: coverage - path: | - coverage.xml - pr.json + path: coverage.xml test-demo: name: Sample app step sequences diff --git a/.github/workflows/pr-reports.yml b/.github/workflows/pr-reports.yml index 200e8ce..5c6c074 100644 --- a/.github/workflows/pr-reports.yml +++ b/.github/workflows/pr-reports.yml @@ -30,9 +30,14 @@ jobs: # `ci.yml` builds the report on pull requests only, and `ci.yml` cancels a # run in progress when the next push arrives, which leaves no artifact to # download. A failed run still has one, because the report job is not a gate. + # + # Same-repository only. A branch in a fork has no entry in `pull_requests` + # below, and this job would have nothing it could trust to say which pull + # request it is reporting on. if: >- github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion != 'cancelled' + && github.event.workflow_run.head_repository.full_name == github.repository permissions: pull-requests: write actions: read @@ -51,16 +56,23 @@ jobs: # Editing in place kept it to one comment and left it wherever it was first # posted, which on a long review is pages above the discussion and marked # only "edited". Every earlier copy is deleted and a fresh one posted. + # Which pull request this is comes from the event, never from the artifact. + # `ci.yml` runs from the branch, so anything it uploads is chosen by whoever + # opened the pull request: a number in a file would let one branch aim this + # job's write token at a different pull request and rewrite its comments. + # GitHub fills in `pull_requests` from the triggering run, and a branch + # cannot write to it. - name: Comment on the pull request env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} + PR: ${{ github.event.workflow_run.pull_requests[0].number }} run: | set -euo pipefail - # Read, not sourced. The file carries a branch name chosen by whoever - # opened the pull request, and this job holds a write token. - pr=$(jq -r '.number | select(test("^[0-9]+$"))' pr.json) - [ -n "$pr" ] || { echo "pr.json carried no pull request number" >&2; exit 1; } + pr=$(printf '%s' "$PR" | grep -E '^[0-9]+$') || { + echo "the triggering run named no pull request" >&2 + exit 1 + } # jq slurps the pages into one array. --jq would run once per page, so # a match on an earlier page would be missed. existing=$(gh api "repos/$REPO/issues/$pr/comments" --paginate \ @@ -75,8 +87,13 @@ jobs: sonar: name: SonarCloud runs-on: macos-15 - # No coverage is produced by a run whose suite failed. - if: github.event.workflow_run.conclusion == 'success' + # No coverage is produced by a run whose suite failed. Same-repository only: + # a fork's pull request has no entry in `pull_requests`, so there would be + # nothing trustworthy to decorate, and this job would be checking a fork's + # revision out while holding the analysis token. + if: >- + github.event.workflow_run.conclusion == 'success' + && github.event.workflow_run.head_repository.full_name == github.repository permissions: contents: read actions: read @@ -101,27 +118,32 @@ jobs: run-id: ${{ github.event.workflow_run.id }} github-token: ${{ secrets.GITHUB_TOKEN }} - # `ci.yml` writes pr.json on a pull request and not on a push, so its - # presence is what decides between decorating a pull request and analysing - # a branch. Without these the scanner would file a pull request's numbers - # against the branch and the decoration would never appear. + # From the event, never from the artifact. `ci.yml` runs from the branch, so + # a number it uploaded would be one the branch chose, and the scanner would + # decorate whichever pull request that named while analysing this revision. + # A `push` run names none, and that is what selects a branch analysis. # - # Read, not sourced, and a ref carrying anything but the characters git - # needs is refused rather than pasted into an argument list. - - name: Read the pull request + # Refs are checked rather than trusted: they reach the scanner as command + # arguments, and a ref carrying anything but the characters git needs is + # refused instead of pasted into the list. + - name: Identify the pull request id: pr + env: + PR: ${{ github.event.workflow_run.pull_requests[0].number }} + PR_BRANCH: ${{ github.event.workflow_run.pull_requests[0].head.ref }} + PR_BASE: ${{ github.event.workflow_run.pull_requests[0].base.ref }} run: | set -euo pipefail - if [ ! -f pr.json ]; then + if [ -z "$PR" ]; then echo "args=" >> "$GITHUB_OUTPUT" echo "analysing a branch, not a pull request" exit 0 fi - number=$(jq -r '.number | select(test("^[0-9]+$"))' pr.json) - branch=$(jq -r '.branch | select(test("^[A-Za-z0-9._/-]+$"))' pr.json) - base=$(jq -r '.base | select(test("^[A-Za-z0-9._/-]+$"))' pr.json) + number=$(printf '%s' "$PR" | grep -E '^[0-9]+$') || number="" + branch=$(printf '%s' "$PR_BRANCH" | grep -E '^[A-Za-z0-9._/-]+$') || branch="" + base=$(printf '%s' "$PR_BASE" | grep -E '^[A-Za-z0-9._/-]+$') || base="" if [ -z "$number" ] || [ -z "$branch" ] || [ -z "$base" ]; then - echo "pr.json did not carry a usable pull request" >&2 + echo "the triggering run named a pull request this cannot use" >&2 exit 1 fi echo "args=-Dsonar.pullrequest.key=$number -Dsonar.pullrequest.branch=$branch -Dsonar.pullrequest.base=$base" >> "$GITHUB_OUTPUT" From a9a34323344e77bd6213b4b533cc5f0bb628a673 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 22:34:22 -0700 Subject: [PATCH 53/57] Pin the workflow token and stop checkout leaving it behind `ci.yml` set no top-level `permissions`, so every job took the repository's default grant. That default is read-only today, and it is a repository setting rather than a property of this file: raising it would hand a write to jobs that run the pull request's own local action and scripts, which is the opposite of what the comment at the top of the file claims. The workflow now pins `contents: read`, and each checkout sets `persist-credentials: false`, since nothing here pushes and the token was otherwise left in `.git/config` for whatever ran next. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d127339..e9b3fc0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,14 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true +# Every job here runs the pull request's own code, so the token it inherits is +# pinned rather than left to the repository's default. That default is read-only +# today and is a setting, not a property of this file; without this line, raising +# it would silently hand a write to code the branch controls, which `checkout` +# leaves in `.git/config` for whatever runs next. +permissions: + contents: read + # Four jobs, run at once. The two suites are the whole cost of this workflow and # neither reads anything the other writes, so run in sequence they only add up. # They cannot share a build: both compile the SDK, but one is the package's own @@ -34,6 +42,10 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + # Nothing here pushes, so the token is not left in `.git/config` for + # the branch's own scripts to find. + persist-credentials: false - uses: ./.github/actions/lint-tools @@ -69,6 +81,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false - uses: ./.github/actions/lint-tools @@ -96,6 +109,10 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + # Nothing here pushes, so the token is not left in `.git/config` for + # the branch's own scripts to find. + persist-credentials: false - uses: ./.github/actions/ios-toolchain @@ -138,6 +155,10 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + # Nothing here pushes, so the token is not left in `.git/config` for + # the branch's own scripts to find. + persist-credentials: false - uses: ./.github/actions/ios-toolchain From de919d8a06614d3fbb41b0fc30e3822569f70432 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 22:34:34 -0700 Subject: [PATCH 54/57] Keep a settled probe answer while the next run is in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The answer was overwritten with "Checking…" the moment a run started. The probe is shared, so a run started on the Configuration tab retracted a verdict a payment tab had already acted on: the backend step went from `.done` to `.inProgress`, which is not finished, so the form step became `.blocked`, `StepRow` dropped the row's content, and the `@StateObject` holding what a payer had typed went with it. The completed probe then returned an empty form. The previous commit covered this for a submission in flight only, by reading `isSubmitting` before the probe. A payer part way through the form has not submitted anything, so that branch never fired. The last settled answer and the set of runs in flight are now separate. A run publishes only when it finishes, so an earlier verdict stands until a new one lands. `check(_:)` reports `.checking` only when there is no earlier answer to keep, which is the first run of all; `display(for:)` does report the run, so a row a person is looking at still says the button did something. The screens read `isRunning(_:)` for the control they would otherwise offer twice. `testARunInFlightKeepsTheAnswerTheLastOneSettledOn` settles one run, holds the next open, and asserts the step still reads `.reachable` while the row reads "Checking…". Restoring the overwrite fails that test and no other. 69 tests in the demo bundle, up from 67. Co-Authored-By: Claude Opus 5 (1M context) --- .../Configuration/ConfigurationQAView.swift | 16 ++--- .../FlowTests/TokenProbeResultsTests.swift | 58 +++++++++++++++++++ .../PayIn/PaymentCaptureQAView.swift | 15 ++--- .../PayIn/PaymentMethodQAView.swift | 15 ++--- .../Shared/TokenProbeResults.swift | 38 +++++++++++- .../TapToPay/PaymentTapToPayQAView.swift | 8 +-- 6 files changed, 120 insertions(+), 30 deletions(-) diff --git a/Example/PayabliDemo/Configuration/ConfigurationQAView.swift b/Example/PayabliDemo/Configuration/ConfigurationQAView.swift index cf891aa..865c7f4 100644 --- a/Example/PayabliDemo/Configuration/ConfigurationQAView.swift +++ b/Example/PayabliDemo/Configuration/ConfigurationQAView.swift @@ -86,7 +86,7 @@ struct ConfigurationQAView: View { .buttonStyle(.bordered) // `isWorking` is this screen's own. A probe started on a tab is in // flight here too, and only the shared answer says so. - .disabled(isWorking || isChecking(tokenProbes.cardPresent)) + .disabled(isWorking || tokenProbes.isRunning(.cardPresent)) Button { runCardNotPresentTokenCheck() } label: { Label("Check card-not-present tokens", systemImage: "key.horizontal") @@ -94,8 +94,8 @@ struct ConfigurationQAView: View { .buttonStyle(.bordered) .disabled( isWorking - || isChecking(tokenProbes.storedMethod) - || isChecking(tokenProbes.capture) + || tokenProbes.isRunning(.storedMethod) + || tokenProbes.isRunning(.capture) ) Button { runHealthCheck() } label: { @@ -106,9 +106,9 @@ struct ConfigurationQAView: View { ForEach( [ - tokenProbes.cardPresent, - tokenProbes.storedMethod, - tokenProbes.capture, + tokenProbes.display(for: .cardPresent), + tokenProbes.display(for: .storedMethod), + tokenProbes.display(for: .capture), healthCheckText ].filter { !$0.isEmpty }, id: \.self @@ -229,10 +229,6 @@ struct ConfigurationQAView: View { } } - private func isChecking(_ answer: String) -> Bool { - TokenCheck.classify(answer) == .checking - } - // MARK: - Actions private func runTokenCheck() { diff --git a/Example/PayabliDemo/FlowTests/TokenProbeResultsTests.swift b/Example/PayabliDemo/FlowTests/TokenProbeResultsTests.swift index 2735479..36728df 100644 --- a/Example/PayabliDemo/FlowTests/TokenProbeResultsTests.swift +++ b/Example/PayabliDemo/FlowTests/TokenProbeResultsTests.swift @@ -92,6 +92,64 @@ final class TokenProbeResultsTests: XCTestCase { ) } + /// The probe is shared, so this run can have been started from another tab + /// while a payer is part way through the form here. A backend step that stops + /// being finished blocks the form row, and `StepRow` takes the row's content + /// down with the `@StateObject` holding what was typed. + func testARunInFlightKeepsTheAnswerTheLastOneSettledOn() async { + let latch = Latch() + let store = TokenProbeResults( + fetchCardPresent: { + let run = await latch.hold() + _ = run + return "token" + }, + fetchStoredMethod: { "" }, + fetchCapture: { "" } + ) + + let first = Task { await store.probeCardPresent() } + await waitUntil("the first run registers") { await latch.count() >= 1 } + await latch.release(1) + await first.value + XCTAssertEqual(store.check(.cardPresent), .reachable) + + let second = Task { await store.probeCardPresent() } + await waitUntil("the second run registers") { await latch.count() >= 2 } + + XCTAssertTrue(store.isRunning(.cardPresent)) + XCTAssertEqual( + store.check(.cardPresent), .reachable, + "a run in flight retracted the answer the step had already acted on" + ) + XCTAssertEqual(store.display(for: .cardPresent), "Checking…") + + await latch.release(2) + await second.value + XCTAssertFalse(store.isRunning(.cardPresent)) + XCTAssertEqual(store.check(.cardPresent), .reachable) + } + + func testTheFirstRunOfAllReportsItselfAsChecking() async { + let latch = Latch() + let store = TokenProbeResults( + fetchCardPresent: { + _ = await latch.hold() + return "token" + }, + fetchStoredMethod: { "" }, + fetchCapture: { "" } + ) + + let run = Task { await store.probeCardPresent() } + await waitUntil("the run registers") { await latch.count() >= 1 } + + XCTAssertEqual(store.check(.cardPresent), .checking) + + await latch.release(1) + await run.value + } + func testASingleRunStillPublishes() async { let store = TokenProbeResults( fetchCardPresent: { "token" }, diff --git a/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift b/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift index 80323ee..5f303e9 100644 --- a/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift +++ b/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift @@ -31,13 +31,14 @@ struct PaymentCaptureQAView: View { } .buttonStyle(.bordered) // The probe is shared, so a run started on another - // tab is this step's `.inProgress` too. The derived - // step is what knows that; a local flag does not. - .disabled(!steps.backend.status.isActionable) - if !tokenProbes.capture.isEmpty { - Text(tokenProbes.capture) + // tab is in flight here too. The store is what knows + // that; a local flag does not. + .disabled(tokenProbes.isRunning(.capture)) + if !tokenProbes.display(for: .capture).isEmpty { + Text(tokenProbes.display(for: .capture)) .font(.caption) - .foregroundColor(tokenProbes.capture.hasPrefix("✗") ? .payabliError : .payabliOnSurfaceVariant) + .foregroundColor(tokenProbes.display(for: .capture) + .hasPrefix("✗") ? .payabliError : .payabliOnSurfaceVariant) } } } @@ -135,7 +136,7 @@ struct PaymentCaptureQAView: View { private var steps: PayInFlowSteps { PayInSteps.forCapture( PayInProgress( - tokenCheck: TokenCheck.classify(tokenProbes.capture), + tokenCheck: tokenProbes.check(.capture), hasResult: paymentFlow.lastResult != nil, resultAcknowledged: resultAcknowledged, isSubmitting: paymentFlow.isSubmitting, diff --git a/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift b/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift index 7e34ff5..acfab52 100644 --- a/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift +++ b/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift @@ -30,13 +30,14 @@ struct PaymentMethodQAView: View { } .buttonStyle(.bordered) // The probe is shared, so a run started on another - // tab is this step's `.inProgress` too. The derived - // step is what knows that; a local flag does not. - .disabled(!steps.backend.status.isActionable) - if !tokenProbes.storedMethod.isEmpty { - Text(tokenProbes.storedMethod) + // tab is in flight here too. The store is what knows + // that; a local flag does not. + .disabled(tokenProbes.isRunning(.storedMethod)) + if !tokenProbes.display(for: .storedMethod).isEmpty { + Text(tokenProbes.display(for: .storedMethod)) .font(.caption) - .foregroundColor(tokenProbes.storedMethod.hasPrefix("✗") ? .payabliError : .payabliOnSurfaceVariant) + .foregroundColor(tokenProbes.display(for: .storedMethod) + .hasPrefix("✗") ? .payabliError : .payabliOnSurfaceVariant) } } } @@ -132,7 +133,7 @@ struct PaymentMethodQAView: View { private var steps: PayInFlowSteps { PayInSteps.forStoringMethod( PayInProgress( - tokenCheck: TokenCheck.classify(tokenProbes.storedMethod), + tokenCheck: tokenProbes.check(.storedMethod), hasResult: paymentFlow.lastResult != nil, resultAcknowledged: resultAcknowledged, isSubmitting: paymentFlow.isSubmitting, diff --git a/Example/PayabliDemo/Shared/TokenProbeResults.swift b/Example/PayabliDemo/Shared/TokenProbeResults.swift index 703939d..320ed86 100644 --- a/Example/PayabliDemo/Shared/TokenProbeResults.swift +++ b/Example/PayabliDemo/Shared/TokenProbeResults.swift @@ -33,10 +33,19 @@ final class TokenProbeResults: ObservableObject { case capture } + /// The last answer each probe settled on. A run in flight does not clear it: + /// the probe is shared, so a run started on the Configuration tab would + /// otherwise retract a verdict a payment tab had already acted on, and a + /// backend step that stops being finished takes the form row down with it, + /// along with the `@StateObject` holding what a payer had typed. @Published private(set) var cardPresent = "" @Published private(set) var storedMethod = "" @Published private(set) var capture = "" + /// Which probes are in flight, kept apart from the answers for the reason + /// above. A screen reads it to disable the control that would start another. + @Published private(set) var running: Set = [] + private let fetches: [Probe: Fetch] /// Which run of each probe is current. `@MainActor` serialises the writes but @@ -75,6 +84,29 @@ final class TokenProbeResults: ObservableObject { } } + func isRunning(_ probe: Probe) -> Bool { + running.contains(probe) + } + + /// What a step sequence should read. A run in flight is the answer only while + /// there is no earlier one to keep; after that the earlier verdict stands + /// until the new one lands, so the sequence does not step backwards and take + /// the form down while a payer is filling it in. + func check(_ probe: Probe) -> TokenCheck { + let answer = answer(for: probe) + if answer.isEmpty, isRunning(probe) { + return .checking + } + return TokenCheck.classify(answer) + } + + /// What a step row should show. Unlike `check`, this does report a run in + /// flight over an earlier answer, because a row a person is looking at should + /// say the button they pressed is doing something. + func display(for probe: Probe) -> String { + isRunning(probe) ? "Checking…" : answer(for: probe) + } + func probeCardPresent() async { await run(.cardPresent, named: "Card-present token endpoint") } @@ -90,7 +122,7 @@ final class TokenProbeResults: ObservableObject { private func run(_ probe: Probe, named name: String) async { let generation = (generations[probe] ?? 0) + 1 generations[probe] = generation - publish("Checking…", to: probe) + running.insert(probe) let answer: String do { @@ -100,8 +132,10 @@ final class TokenProbeResults: ObservableObject { answer = "✗ \(name) failed: \(error.localizedDescription)" } - // A later run of this probe has already answered, so this one is stale. + // A later run of this probe has already answered, so this one is stale + // and leaves both the answer and the in-flight set to that later run. guard generations[probe] == generation else { return } + running.remove(probe) publish(answer, to: probe) } diff --git a/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift b/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift index e12f29d..e6f7f7a 100644 --- a/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift +++ b/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift @@ -74,7 +74,7 @@ struct PaymentTapToPayQAView: View { /// steps can disagree about which is next. private var steps: TapToPayFlowSteps { TapToPaySteps.forCharging( - tokenCheck: TokenCheck.classify(tokenProbes.cardPresent), + tokenCheck: tokenProbes.check(.cardPresent), session: terminal.sessionState, activation: activationOutcome ) @@ -94,9 +94,9 @@ struct PaymentTapToPayQAView: View { // `isWorking` covers this screen's own operations. The probe // is shared, so a run started on another tab is this step's // `.inProgress` and only the derived step knows it. - .disabled(isWorking || !steps.token.status.isActionable) - if !tokenProbes.cardPresent.isEmpty { - Text(tokenProbes.cardPresent) + .disabled(isWorking || tokenProbes.isRunning(.cardPresent)) + if !tokenProbes.display(for: .cardPresent).isEmpty { + Text(tokenProbes.display(for: .cardPresent)) .font(.caption) .foregroundColor(.payabliOnSurfaceVariant) } From c5b3703a2cb50382bda4a4685374edd8ce044fee Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 22:37:41 -0700 Subject: [PATCH 55/57] Delete only the reports this workflow posted The comments to remove were selected by the marker alone, so a reviewer quoting the report in discussion would have been quoting the marker, and this job holds a write token. Review discussion is not this workflow's to delete. The author has to be `github-actions[bot]` as well. Checked against a list holding a reviewer's comment that quotes the marker, another bot's, and the real report: only the real report is selected. On this pull request it still matches the one comment it posted. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/pr-reports.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-reports.yml b/.github/workflows/pr-reports.yml index 5c6c074..c6478fb 100644 --- a/.github/workflows/pr-reports.yml +++ b/.github/workflows/pr-reports.yml @@ -73,10 +73,18 @@ jobs: echo "the triggering run named no pull request" >&2 exit 1 } + # The marker alone is not enough to delete by: a reviewer quoting the + # report would be quoting the marker, and this holds a write token. Only + # this workflow's own comments are ever removed. + # # jq slurps the pages into one array. --jq would run once per page, so # a match on an earlier page would be missed. existing=$(gh api "repos/$REPO/issues/$pr/comments" --paginate \ - | jq -s 'add | map(select(.body | contains("")) | .id) | .[]') + | jq -s 'add + | map(select(.user.login == "github-actions[bot]" + and (.body | contains(""))) + | .id) + | .[]') for id in $existing; do gh api -X DELETE "repos/$REPO/issues/comments/$id" >/dev/null echo "deleted comment $id" From 8e8347ef02cc440c43134f4fca7fef7e07619322 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 22:37:56 -0700 Subject: [PATCH 56/57] Decide where the analysis goes in the workflow, not in the branch The scanner reads `sonar-project.properties` out of the revision it is analysing, and that file names the server and the project. A same-repository branch could point `sonar.host.url` at a server of its own and this step would hand it SONAR_TOKEN, or change the identity and publish the analysis into another project it can reach. Checking the branch out without running it does not cover a tool that takes its instructions from the tree. The host, organization and project key are now given on the command line, which wins over the file, from the workflow a pull request cannot edit. They duplicate the first three lines of `sonar-project.properties` deliberately. What a branch may still choose is what gets measured, which is the rest of that file. Checked directly: the three properties lead the argument list on the pull request path and on the branch path, and a ref that is not one is still refused. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/pr-reports.yml | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-reports.yml b/.github/workflows/pr-reports.yml index c6478fb..7a4890d 100644 --- a/.github/workflows/pr-reports.yml +++ b/.github/workflows/pr-reports.yml @@ -142,8 +142,23 @@ jobs: PR_BASE: ${{ github.event.workflow_run.pull_requests[0].base.ref }} run: | set -euo pipefail + # Where the analysis goes is decided here, not by the branch. The + # scanner reads `sonar-project.properties` out of the revision it is + # analysing, and that file names the server and the project: a branch + # could point `sonar.host.url` at one of its own and be handed + # SONAR_TOKEN, or change the identity and publish into another project. + # A property given on the command line wins over the file, and this file + # is the one a pull request cannot edit. + # + # These three duplicate `sonar-project.properties` on purpose. What the + # branch may still choose is what gets measured, which is the rest of + # that file. + trusted="-Dsonar.host.url=https://sonarcloud.io" + trusted="$trusted -Dsonar.organization=payabli" + trusted="$trusted -Dsonar.projectKey=payabli_sdk-ios" + if [ -z "$PR" ]; then - echo "args=" >> "$GITHUB_OUTPUT" + echo "args=$trusted" >> "$GITHUB_OUTPUT" echo "analysing a branch, not a pull request" exit 0 fi @@ -154,7 +169,7 @@ jobs: echo "the triggering run named a pull request this cannot use" >&2 exit 1 fi - echo "args=-Dsonar.pullrequest.key=$number -Dsonar.pullrequest.branch=$branch -Dsonar.pullrequest.base=$base" >> "$GITHUB_OUTPUT" + echo "args=$trusted -Dsonar.pullrequest.key=$number -Dsonar.pullrequest.branch=$branch -Dsonar.pullrequest.base=$base" >> "$GITHUB_OUTPUT" # Pinned to a commit, not a tag. `v5` is mutable, and this step is handed # SONAR_TOKEN, so a replaced action would hold a repository secret. From 295e2974492cd7c1f943c38161943de0aebbbdb0 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 22:52:00 -0700 Subject: [PATCH 57/57] Run one report and one analysis per pull request at a time `pr-reports.yml` had no concurrency of its own. `ci.yml` cancels a superseded run, but cancelling is not instant: a run already at its last step finishes, and the push that superseded it produces a second, so two report jobs can be in flight for one pull request. Both list the comments, both delete what they found, and the loser of that race deletes a comment that is already gone and reddens on it; on the other ordering both post and the pull request carries two. Each job is now keyed by the pull request the event names, and a newer run takes an older one's place, since the older one is reporting on a revision that has been replaced. The analysis has the same race for the same reason, and was not in the review: two analyses of one pull request reach the server in whichever order they finish, so the later revision's numbers can be overwritten by the earlier one's. A push run names no pull request and is keyed by its branch. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/pr-reports.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/pr-reports.yml b/.github/workflows/pr-reports.yml index 7a4890d..25a553a 100644 --- a/.github/workflows/pr-reports.yml +++ b/.github/workflows/pr-reports.yml @@ -38,6 +38,15 @@ jobs: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion != 'cancelled' && github.event.workflow_run.head_repository.full_name == github.repository + # One of these at a time per pull request. `ci.yml` cancels a superseded run, + # but cancelling is not instant: a run already at its last step finishes, and + # the push that superseded it produces a second. Both would then list the + # comments, both delete what they found, and the loser of that race deletes a + # comment that is already gone and reddens on it. The newer report is the one + # worth having, so it takes the older one's place. + concurrency: + group: change-report-${{ github.event.workflow_run.pull_requests[0].number }} + cancel-in-progress: true permissions: pull-requests: write actions: read @@ -102,6 +111,15 @@ jobs: if: >- github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_repository.full_name == github.repository + # The same race, for the same reason. Two analyses of one pull request reach + # the server in whichever order they finish, so the later revision's numbers + # can be overwritten by the earlier one's. A push run names no pull request + # and is keyed by its branch instead. + concurrency: + group: >- + sonar-${{ github.event.workflow_run.pull_requests[0].number + || github.event.workflow_run.head_branch }} + cancel-in-progress: true permissions: contents: read actions: read