From 48ac2a37ac9e6138db88e25a7401427d84b9a476 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:01:39 +0300 Subject: [PATCH 1/2] Certificate wizard: register Macs, report the right slot, check the profile signs this app Issues #5773 and #5793, whose reports overlap. Four defects, each independent. **A Mac cannot be registered.** CloudSigningService.registerDevice hardcoded "IOS". The service has always accepted MAC_OS -- only the dialog never asked -- so a wizard that offers MAC_APP_DEVELOPMENT offered a profile type nothing could satisfy: isUsableDevice correctly hides an iPhone from a Mac profile's picker, and no Mac could be put in it. The registration dialog now picks the platform, and the UDID hint follows it, because a Mac is registered by its hardware UUID rather than by an iPhone's 25-character identifier. **The overview announced readiness it did not have.** Both cards read row zero of an unfiltered list, so an account holding a Mac certificate and a development profile was told "Apple distribution certificate: Ready -- dtest11 MAC APP DISTRIBUTION" and "App Store profile: Ready -- dtest11 Development". Neither slot was filled. The wizard was never going to USE either for an iOS build -- certificateTypeSatisfies has always been strict, and that part needed no change -- but the panel is why the reporter concluded it was signing iOS builds with Mac App Store assets, and a readiness panel that lies is worse than none. The selection moved to WizardDecisions, where it can be tested without a display, and the profile card now requires the type AND that Apple has not marked it INVALID. **A downloaded "Apple Development" certificate was offered to the release slot.** afterCertificateDownload tested certificateType against IOS_DEVELOPMENT alone, while every other decision in the class goes through isDevelopmentCertificate, which also counts Apple's generic DEVELOPMENT type. So a manual download of an Apple Development certificate would install into codename1.ios.release.certificate and overwrite the unqualified key with it. **"Sync with Apple" could not sync profiles at all**, because the signing service had no endpoint to sync them with -- the companion BuildCloud change adds POST /profiles/reconcile and explains why. The wizard now calls both halves. A 404/405 from the profile half is tolerated so that a wizard newer than the deployed service still syncs certificates instead of failing outright; any other status is reported. Also here: IOSProvisioningPreflight was applying profileCoversBundleId to every app EXTENSION and never to the app itself, so the app's own profile was checked only for readability, expiry and distribution method. A profile issued for a different App ID passed preflight and failed minutes later on the build server with "Provisioning profile ... doesn't match the entitlements file's values for the application-identifier and keychain-access-groups entitlements" -- both of which are $(AppIdentifierPrefix)$(CFBundleIdentifier), which is to say both of them are that comparison, spelled by Xcode after the upload. Both reporters spent a cloud build each to be told it. What it deliberately does not catch is the same bundle id under a different TEAM prefix: nothing in codenameone_settings.properties states the team, so that half stays a server-side failure, and there is a test asserting the limit rather than leaving it implied. The wizard's surefire skipTests was hardcoded true, which -DskipTests=false could not reach, so its 78 existing tests could not be run here or anywhere. It is now the same opt-in property scripts/settings/common uses, for the same reason, with the same default. Verified locally: 81 pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../maven/IOSProvisioningPreflight.java | 55 ++++++++++++ .../maven/IOSProvisioningPreflightTest.java | 74 ++++++++++++++++ scripts/certificatewizard/common/pom.xml | 12 ++- .../certificatewizard/CertificateWizard.java | 59 +++++++++++-- .../api/CloudSigningService.java | 43 +++++++++- .../api/MockSigningService.java | 5 +- .../certificatewizard/api/SigningService.java | 6 +- .../api/WizardDecisions.java | 28 ++++++ .../CertificateWizardErrorBannerTest.java | 4 +- .../CertificateWizardModelTest.java | 86 ++++++++++++++++++- scripts/certificatewizard/specs/openapi.json | 40 +++++++++ 11 files changed, 396 insertions(+), 16 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/IOSProvisioningPreflight.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/IOSProvisioningPreflight.java index 0379ca2d50c..a13043616ef 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/IOSProvisioningPreflight.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/IOSProvisioningPreflight.java @@ -1058,6 +1058,12 @@ private static void collectFileProblems(List problems, Properties setti return; } + Problem wrongApp = checkProfileSignsThisApp(profile, settings, describe, settingKey); + if (wrongApp != null) { + problems.add(wrongApp); + return; + } + if (!checkMethodMismatch) { return; } @@ -1072,6 +1078,55 @@ private static void collectFileProblems(List problems, Properties setti } } + /** + * Whether the profile's App ID covers the bundle identifier this build will stamp on the app. + * + *

This is the check that was missing while {@link #profileCoversBundleId} was already + * being applied to every app EXTENSION: the app's own profile was only ever checked for + * readability, expiry and distribution method, so a profile belonging to a different App ID + * passed preflight and failed minutes later on the build server with + * {@code Provisioning profile "..." doesn't match the entitlements file's values for the + * application-identifier and keychain-access-groups entitlements}. Both of those entitlements + * are {@code $(AppIdentifierPrefix)$(CFBundleIdentifier)}, which is to say both of them are + * this comparison, spelled by Xcode after the upload (issues #5773 and #5793). + * + *

Fatal, because there is nothing ambiguous left by the time it fires: a wildcard App ID + * is matched by {@link #profileCoversBundleId}, an unresolved or absent package name is not + * judged at all, and a profile that names no App ID has already returned above. What remains + * is a profile Apple issued for a different application. + * + *

What it does NOT catch is the same bundle identifier under a different TEAM prefix, and + * that half of the Xcode message stays a build-server failure. {@code profileCoversBundleId} + * compares the App ID pattern with the prefix stripped, deliberately: nothing in + * {@code codenameone_settings.properties} states the team, so the only honest comparison here + * is the one that does not need it. + * + * @return the problem, or null when the profile covers this app or nothing here can tell + */ + private static Problem checkProfileSignsThisApp(Profile profile, Properties settings, + String describe, String settingKey) { + String bundleId = trimmed(settings.getProperty("codename1.packageName")); + if (bundleId == null || bundleId.isEmpty() || bundleId.indexOf("${") >= 0) { + // The same rule the rest of this class follows: what it cannot resolve, it may not + // judge. A build with no package name fails elsewhere, and with a better message. + return null; + } + if (profile.applicationIdentifier == null || profile.applicationIdentifier.isEmpty()) { + return null; + } + if (profileCoversBundleId(profile.applicationIdentifier, bundleId)) { + return null; + } + return new Problem("The provisioning profile " + describe + " is for App ID " + + appIdPattern(profile.applicationIdentifier) + ", which cannot sign \"" + bundleId + + "\" (codename1.packageName). Signing fails on the application-identifier and " + + "keychain-access-groups entitlements, because both of them are the App ID.\n" + + "Either point " + settingKey + " at a profile issued for " + bundleId + + ", or set codename1.packageName to the bundle ID the profile was issued for. " + + "The certificate wizard creates a matching profile from the project's own " + + "package name.", true); + } + /** * The type mismatch {@code xcodebuild -exportArchive} would reject minutes into the * cloud build. Enterprise profiles only warn: Xcode accepts an in-house profile for diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/IOSProvisioningPreflightTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/IOSProvisioningPreflightTest.java index 96f0b1176b2..429a77486d5 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/IOSProvisioningPreflightTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/IOSProvisioningPreflightTest.java @@ -89,6 +89,15 @@ private static String development(String name) { + "Entitlementsget-task-allow\n"); } + /** An App Store profile that names the App ID it was issued for, the way Apple's do. */ + private static String appStoreFor(String name, String applicationIdentifier) { + return profile(name, FUTURE, + "Entitlements" + + "get-task-allow" + + "application-identifier" + applicationIdentifier + "" + + "\n"); + } + private static String enterprise(String name) { return profile(name, FUTURE, "ProvisionsAllDevices\n" @@ -186,6 +195,71 @@ public void expiredProfileIsRefused() throws Exception { assertFatal(check(settings(f, true, null), true), "Old Profile", "expired on"); } + // ---- the profile is for a different app ---- + + /** + * Issues #5773 and #5793: an App Store profile for one App ID, configured on a project whose + * package name is another. Both reporters spent a cloud build each to be told + * "Provisioning profile ... doesn't match the entitlements file's values for the + * application-identifier and keychain-access-groups entitlements", which is this, after the + * upload. Every fact needed was on disk before the build was sent. + */ + @Test + public void profileForAnotherAppIsRefused() throws Exception { + Properties p = settings(write(appStoreFor("dtest11 STORE", "ABCD1234.com.example.other")), + true, "app-store"); + p.setProperty("codename1.packageName", "com.example.app"); + assertFatal(check(p, true), "dtest11 STORE", "com.example.other", "com.example.app", + "application-identifier", "keychain-access-groups"); + } + + /** The same profile, on the project it was actually issued for. */ + @Test + public void profileForThisAppPasses() throws Exception { + Properties p = settings(write(appStoreFor("Store", "ABCD1234.com.example.app")), + true, "app-store"); + p.setProperty("codename1.packageName", "com.example.app"); + assertTrue(check(p, true).isEmpty()); + } + + /** A wildcard App ID covers the bundle id, and refusing it would block a working build. */ + @Test + public void wildcardProfileIsNotRefused() throws Exception { + Properties p = settings(write(appStoreFor("Wildcard", "ABCD1234.com.example.*")), + true, "app-store"); + p.setProperty("codename1.packageName", "com.example.app"); + assertTrue(check(p, true).isEmpty()); + } + + /** + * The team prefix is not part of the comparison, so a profile from another team under the + * same bundle id still passes here. Asserted rather than left implied: it is the half of the + * Xcode message this check cannot answer, because nothing in the settings states the team. + */ + @Test + public void aDifferentTeamPrefixIsNotJudged() throws Exception { + Properties p = settings(write(appStoreFor("Other Team", "ZZZZ9999.com.example.app")), + true, "app-store"); + p.setProperty("codename1.packageName", "com.example.app"); + assertTrue(check(p, true).isEmpty()); + } + + /** No package name to compare against, and a profile is not refused on a guess. */ + @Test + public void absentPackageNameIsNotJudged() throws Exception { + Properties p = settings(write(appStoreFor("Store", "ABCD1234.com.example.other")), + true, "app-store"); + assertTrue(check(p, true).isEmpty()); + } + + /** A profile that names no App ID at all says nothing either way. */ + @Test + public void profileWithoutAnAppIdIsNotJudged() throws Exception { + Properties p = settings(write(appStore("Store")), true, "app-store"); + p.setProperty("codename1.packageName", "com.example.app"); + assertTrue(check(p, true).isEmpty()); + } + // ---- the combinations that must NOT be refused ---- @Test diff --git a/scripts/certificatewizard/common/pom.xml b/scripts/certificatewizard/common/pom.xml index d30aeb1520b..b73bf458410 100644 --- a/scripts/certificatewizard/common/pom.xml +++ b/scripts/certificatewizard/common/pom.xml @@ -8,6 +8,10 @@ cn1-certificatewizard-common jar + + + true + cn1-certificatewizard-common Shared UI and service code for the Codename One Certificate Wizard. @@ -152,7 +156,13 @@ org.apache.maven.plugins maven-surefire-plugin - true + + ${cn1.certificatewizard.skipTests} diff --git a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/CertificateWizard.java b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/CertificateWizard.java index af3094d27cc..20258ffc86d 100644 --- a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/CertificateWizard.java +++ b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/CertificateWizard.java @@ -449,12 +449,21 @@ private void overviewPage() { Container setup = new Container(new GridLayout(1, 3)); setupCard(setup, "ASC API Key", state.credential.configured() ? "Connected" : "Not configured", state.credential.configured() ? state.credential.keyId() : "-"); + // Each card answers for ITS OWN slot. Both used to show row zero of an unfiltered list, + // so an account holding a Mac certificate and a development profile was told "Apple + // distribution certificate: Ready -- MAC APP DISTRIBUTION" and "App Store profile: + // Ready -- Development" (issue #5773). Neither slot was filled, and the reporter + // reasonably concluded the wizard was signing iOS builds with Mac App Store assets. It was + // not -- WizardDecisions.certificateTypeSatisfies has always been strict about this -- but + // a readiness panel that reports readiness it does not have is worse than no panel. + SigningState.Certificate distribution = WizardDecisions.distributionCertificateForOverview(state); setupCard(setup, "Apple distribution certificate", - state.certificates.isEmpty() ? "None active" : "Ready", - state.certificates.isEmpty() ? "-" : state.certificates.get(0).displayName()); + distribution == null ? "None active" : "Ready", + distribution == null ? "-" : distribution.displayName()); + SigningState.Profile appStore = WizardDecisions.appStoreProfileForOverview(state); setupCard(setup, "App Store profile", - state.profiles.isEmpty() ? "None yet" : "Ready", - state.profiles.isEmpty() ? "-" : state.profiles.get(0).name()); + appStore == null ? "None yet" : "Ready", + appStore == null ? "-" : appStore.name()); page.add(setup); label(page, "YOUR ASSETS", "CWNavLabel"); Container metrics = new Container(new GridLayout(1, 4)); @@ -1144,13 +1153,46 @@ private void bundleDialog(String initialIdentifier, String initialName, String p showModal(d); } + /// Registers a device, on the platform the developer picks. + /// + /// The platform used to be hardcoded to iOS, which made the Mac profile types this wizard + /// offers unreachable: a Mac profile may only name MAC_OS devices ([WizardDecisions#isUsableDevice]), + /// an iOS device is correctly hidden from its picker, and no Mac device could be registered + /// here to put in it. The result was a profile type you could select and never create + /// (issue #5773). The service has always accepted either platform -- only this dialog did not + /// ask. + /// + /// A Mac is registered by its Provisioning UDID (the hardware UUID), not by the 25-character + /// identifier an iPhone has, so the hint follows the selection. private void deviceDialog() { InteractionDialog d = modal("Register device"); + final String[] platform = {"IOS"}; + label(d, "Platform", "CWFieldLabel"); + final String[] platformValues = {"IOS", "MAC_OS"}; + // Spelled out rather than derived with toLowerCase(): these are component names, and + // String.toLowerCase() is locale sensitive with no root-locale overload in this runtime, + // so on a Turkish device the "I" of "IOS" folds to a dotless i and the name changes. + final String[] platformNames = {"pick.devicePlatform.ios", "pick.devicePlatform.mac_os"}; + final Button[] platformButtons = {segment("iPhone or iPad", true), segment("Mac", false)}; TextField name = field("Device name", "QA iPhone"); TextField udid = field("UDID", "00008120-000A1C3E0C68201E"); + for (int i = 0; i < platformButtons.length; i++) { + platformButtons[i].setName(platformNames[i]); + final int index = i; + platformButtons[i].addActionListener(e -> { + platform[0] = platformValues[index]; + updateSegmentButtons(platformButtons, platformValues, platform[0]); + boolean mac = "MAC_OS".equals(platform[0]); + name.setHint(mac ? "Build Mac" : "QA iPhone"); + udid.setHint(mac ? "Provisioning UDID (hardware UUID)" + : "00008120-000A1C3E0C68201E"); + d.revalidate(); + }); + } + d.add(actionRow(Component.LEFT, platformButtons[0], platformButtons[1])); d.add(name).add(udid); Button save = primary("Register", "modal.device.submit"); - save.addActionListener(e -> { d.dispose(); service.registerDevice(name.getText(), udid.getText(), r -> afterMutation(r, "Device registered")); }); + save.addActionListener(e -> { d.dispose(); service.registerDevice(name.getText(), udid.getText(), platform[0], r -> afterMutation(r, "Device registered")); }); addDialogActions(d, save); showModal(d); } @@ -2727,7 +2769,12 @@ private void afterCertificateDownload(SigningService.Result r, SigningSt } latestCertificatePath = r.value; latestCertificatePassword = password == null ? "" : password; - latestAssetsDebug = "IOS_DEVELOPMENT".equals(c.certificateType()); + // isDevelopmentCertificate, not an equality test against IOS_DEVELOPMENT. Apple's generic + // "Apple Development" type (DEVELOPMENT) supersedes the platform-specific one and every + // other decision in this class already reads it that way; only here did it fall through to + // "release", which offered to install a development certificate into + // codename1.ios.release.certificate and overwrite the unqualified key with it. + latestAssetsDebug = isDevelopmentCertificate(c.certificateType()); offerInstall(); } diff --git a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/CloudSigningService.java b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/CloudSigningService.java index e303466689f..e8cc6c80c98 100644 --- a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/CloudSigningService.java +++ b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/CloudSigningService.java @@ -199,8 +199,44 @@ public void createCertificate(String certificateType, String displayName, OnComp r -> done(r, callback)); } + /// "Sync with Apple": reconcile BOTH certificates and provisioning profiles. + /// + /// This used to call the certificate reconcile alone, and there was no profile reconcile to + /// call -- the profile list was served from rows this wizard had itself created, so a sync + /// could not bring it into step with Apple no matter how often it ran. That is issue #5793: + /// a list showing 11 of an account's 24 profiles, a row for a profile deleted in the portal + /// that answers "Apple no longer has this item" straight after a sync, and a create refused + /// for a duplicate name that is invisible here. + /// + /// Sequential rather than parallel, and profiles second: the certificate pass is the one + /// whose failure means the key is unusable, and reporting that is more useful than reporting + /// whichever of the two happened to answer first. public void reconcile(OnComplete> callback) { - certificatesApi.reconcileCertificates(bearerToken, r -> done(r, callback)); + certificatesApi.reconcileCertificates(bearerToken, r -> { + if (!ok(r)) { + callback.completed(Result.fail(error(r))); + return; + } + profilesApi.reconcileProfiles(bearerToken, rr -> { + if (ok(rr) || endpointAbsent(rr)) { + // endpointAbsent: a wizard newer than the signing service it is talking to. + // The two ship separately, so for the window where an older service is + // deployed the certificate half must still work rather than the whole sync + // failing on a route that does not exist yet. Every other status is a real + // failure and is reported. + callback.completed(Result.ok(null)); + return; + } + callback.completed(Result.fail(error(rr))); + }); + }); + } + + /// Whether this reply means the route is not there at all, as opposed to the request being + /// refused. 404 is what a service without the profile reconcile answers; 405 is what one + /// answers that has the /profiles/{id} routes but not this one. + private static boolean endpointAbsent(Response r) { + return r != null && (r.getResponseCode() == 404 || r.getResponseCode() == 405); } public void revokeCertificate(Long id, OnComplete> callback) { @@ -251,8 +287,9 @@ public void enablePushCapability(String bundleIdAppleId, OnComplete bearerToken, r -> done(r, callback)); } - public void registerDevice(String name, String udid, OnComplete> callback) { - devicesApi.registerDevice(new RegisterDeviceRequest(name, udid, "IOS"), bearerToken, r -> done(r, callback)); + public void registerDevice(String name, String udid, String platform, OnComplete> callback) { + String plat = platform == null || platform.trim().isEmpty() ? "IOS" : platform.trim(); + devicesApi.registerDevice(new RegisterDeviceRequest(name, udid, plat), bearerToken, r -> done(r, callback)); } public void createProfile(String name, String profileType, String bundleIdAppleId, List certificateAppleIds, diff --git a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/MockSigningService.java b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/MockSigningService.java index d9e2e5d5717..20ca1b16f52 100644 --- a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/MockSigningService.java +++ b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/MockSigningService.java @@ -254,8 +254,9 @@ public List appGroupAssociation(String bundleIdAppleId) { return assoc == null ? new ArrayList() : new ArrayList(assoc); } - public void registerDevice(String name, String udid, OnComplete> callback) { - devices.add(new SigningState.Device("DEV_" + (++seq), name, udid, "IOS", "ENABLED")); + public void registerDevice(String name, String udid, String platform, OnComplete> callback) { + String plat = platform == null || platform.trim().isEmpty() ? "IOS" : platform.trim(); + devices.add(new SigningState.Device("DEV_" + (++seq), name, udid, plat, "ENABLED")); callback.completed(Result.ok(null)); } diff --git a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/SigningService.java b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/SigningService.java index 2a7314fe545..d92b0313d6f 100644 --- a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/SigningService.java +++ b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/SigningService.java @@ -46,7 +46,11 @@ default void createBundleId(String identifier, String name, boolean push, OnComp /// and the capability has to be asserted on it separately or the build stamps an /// aps-environment entitlement the profile does not grant. void enablePushCapability(String bundleIdAppleId, OnComplete> callback); - void registerDevice(String name, String udid, OnComplete> callback); + /// Registers a device on `platform`, which is Apple's BundleIdPlatform -- IOS or + /// MAC_OS. A Mac has to be registrable or the Mac profile types are unreachable: + /// a Mac profile may name only MAC_OS devices, so hardcoding IOS here left a + /// profile type selectable and never creatable (issue #5773). + void registerDevice(String name, String udid, String platform, OnComplete> callback); void createProfile(String name, String profileType, String bundleIdAppleId, List certificateAppleIds, List deviceAppleIds, OnComplete> callback); void deleteProfile(Long id, OnComplete> callback); diff --git a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/WizardDecisions.java b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/WizardDecisions.java index 7cdffed9c1f..5ef0eb3fa57 100644 --- a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/WizardDecisions.java +++ b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/WizardDecisions.java @@ -153,6 +153,34 @@ public static List compatibleCertificates(SigningState return out; } + /// The certificate the overview's "Apple distribution certificate" card is about, or null when + /// the account has none. + /// + /// It reported `certificates.get(0)` -- the first row of an unfiltered list -- so an account + /// holding a Mac certificate was told "Apple distribution certificate: Ready" beside the name + /// of a MAC APP DISTRIBUTION certificate, which cannot sign an iOS build and was never going + /// to be chosen for one (issue #5773). Nothing downstream was wrong; the panel was. Answering + /// from [#compatibleCertificates] makes the card agree with the code that actually picks. + public static SigningState.Certificate distributionCertificateForOverview(SigningState state) { + List compatible = compatibleCertificates(state, "IOS_APP_STORE"); + return compatible.isEmpty() ? null : compatible.get(0); + } + + /// The profile the overview's "App Store profile" card is about, or null when there is none. + /// + /// Same defect, same shape: `profiles.get(0)` called a Development profile the App Store + /// profile. Both the type and the state matter -- a profile Apple has marked INVALID is one + /// the next build cannot sign with, so calling it "Ready" is the same false assurance. + public static SigningState.Profile appStoreProfileForOverview(SigningState state) { + for (SigningState.Profile p : state.profiles) { + if ("IOS_APP_STORE".equals(p.profileType()) + && (p.status() == null || "ACTIVE".equals(p.status()))) { + return p; + } + } + return null; + } + /// The one input still missing before a profile can be created, phrased for the user, or null /// when nothing is. Reported in the same order the dialog lays the sections out, so the /// message always points at the first thing above the button rather than at whichever check diff --git a/scripts/certificatewizard/common/src/test/java/com/codename1/certificatewizard/CertificateWizardErrorBannerTest.java b/scripts/certificatewizard/common/src/test/java/com/codename1/certificatewizard/CertificateWizardErrorBannerTest.java index 5ebabddd354..85809d68073 100644 --- a/scripts/certificatewizard/common/src/test/java/com/codename1/certificatewizard/CertificateWizardErrorBannerTest.java +++ b/scripts/certificatewizard/common/src/test/java/com/codename1/certificatewizard/CertificateWizardErrorBannerTest.java @@ -276,8 +276,8 @@ public void enablePushCapability(String bundleId, OnComplete> cb) { delegate.enablePushCapability(bundleId, cb); } - public void registerDevice(String name, String udid, OnComplete> cb) { - delegate.registerDevice(name, udid, cb); + public void registerDevice(String name, String udid, String platform, OnComplete> cb) { + delegate.registerDevice(name, udid, platform, cb); } public void createProfile(String name, String type, String bundleId, List certs, diff --git a/scripts/certificatewizard/common/src/test/java/com/codename1/certificatewizard/CertificateWizardModelTest.java b/scripts/certificatewizard/common/src/test/java/com/codename1/certificatewizard/CertificateWizardModelTest.java index 26cb2fbbf94..aebfee4b349 100644 --- a/scripts/certificatewizard/common/src/test/java/com/codename1/certificatewizard/CertificateWizardModelTest.java +++ b/scripts/certificatewizard/common/src/test/java/com/codename1/certificatewizard/CertificateWizardModelTest.java @@ -78,6 +78,90 @@ void compatibleCertificatesMustBeExportableForAutoSetupReuse() { assertEquals("APPLE_EXPORTABLE", compatible.get(0).appleCertId()); } + /** + * Issue #5773: the overview card headed "Apple distribution certificate" showed + * {@code certificates.get(0)} -- the first row of an unfiltered list -- and so announced + * "Ready" beside a MAC APP DISTRIBUTION certificate on an account that had no iOS + * distribution certificate at all. The wizard never used that certificate for an iOS build + * (compatibleCertificates has always been strict), but a readiness panel that reports + * readiness it does not have is why the reporter concluded it did. + */ + @Test + void overviewDistributionCardIgnoresAMacCertificate() { + long now = System.currentTimeMillis(); + List certs = new ArrayList(); + certs.add(new SigningState.Certificate(1L, "APPLE_MAC", "MAC_APP_DISTRIBUTION", + "dtest11 MAC APP DISTRIBUTION", "SER1", now + 300L * 86400000L, "ACTIVE", true)); + SigningState macOnly = new SigningState(new SigningState.Credential(true, "KEY", "ISSUER"), + certs, null, null, null, null, null); + + assertNull(WizardDecisions.distributionCertificateForOverview(macOnly)); + + certs.add(new SigningState.Certificate(2L, "APPLE_IOS", "IOS_DISTRIBUTION", + "dtest11 Apple Distribution", "SER2", now + 300L * 86400000L, "ACTIVE", true)); + SigningState both = new SigningState(new SigningState.Credential(true, "KEY", "ISSUER"), + certs, null, null, null, null, null); + + assertEquals("APPLE_IOS", WizardDecisions.distributionCertificateForOverview(both).appleCertId()); + } + + /** + * The other half of the same card row: "App Store profile: Ready -- dtest11 Development". + * A development profile is not an App Store profile, and a profile Apple has marked INVALID + * is not one the next build can sign with either. + */ + @Test + void overviewAppStoreCardIgnoresOtherProfiles() { + List profiles = new ArrayList(); + profiles.add(new SigningState.Profile(1L, "P_DEV", "dtest11 Development", + "IOS_APP_DEVELOPMENT", "com.example.app", "u1", null, "ACTIVE")); + SigningState devOnly = new SigningState(new SigningState.Credential(true, "KEY", "ISSUER"), + null, null, null, profiles, null, null); + + assertNull(WizardDecisions.appStoreProfileForOverview(devOnly)); + + profiles.add(new SigningState.Profile(2L, "P_STALE", "dtest11 App Store", + "IOS_APP_STORE", "com.example.app", "u2", null, "INVALID")); + SigningState stale = new SigningState(new SigningState.Credential(true, "KEY", "ISSUER"), + null, null, null, profiles, null, null); + + assertNull(WizardDecisions.appStoreProfileForOverview(stale)); + + profiles.add(new SigningState.Profile(3L, "P_STORE", "dtest11 App Store", + "IOS_APP_STORE", "com.example.app", "u3", null, "ACTIVE")); + SigningState ready = new SigningState(new SigningState.Credential(true, "KEY", "ISSUER"), + null, null, null, profiles, null, null); + + assertEquals("P_STORE", WizardDecisions.appStoreProfileForOverview(ready).appleProfileId()); + } + + /** + * A Mac has to be registrable or the Mac profile types the wizard offers cannot be created: + * isUsableDevice correctly refuses an iOS device for a Mac profile, and the registration + * dialog hardcoded IOS, so the picker for a Mac Development profile was always empty + * (issue #5773). + */ + @Test + void aMacCanBeRegisteredAndIsOfferedToMacProfilesOnly() { + MockSigningService service = new MockSigningService(); + service.registerDevice("Build Mac", "11111111-2222-3333-4444-555555555555", "MAC_OS", + r -> assertTrue(r.ok)); + final SigningState[] state = new SigningState[1]; + service.refresh(r -> state[0] = r.value); + + SigningState.Device mac = null; + for (SigningState.Device d : state[0].devices) { + if ("Build Mac".equals(d.name())) { + mac = d; + } + } + assertNotNull(mac); + assertEquals("MAC_OS", mac.platform()); + assertTrue(WizardDecisions.isUsableDevice(mac, "MAC_APP_DEVELOPMENT")); + assertFalse(WizardDecisions.isUsableDevice(mac, "IOS_APP_DEVELOPMENT")); + assertFalse(WizardDecisions.usableDevices(state[0], "MAC_APP_DEVELOPMENT").isEmpty()); + } + @Test void createProfileValidationRequiresDevicesOnlyWhenNeeded() { List certs = new ArrayList(); @@ -335,7 +419,7 @@ void mockServiceMutationsUpdateSnapshot() { assertEquals(certCount + 1, after[0].certificates.size()); service.createBundleId("com.example.newapp", "New App", true, r -> assertTrue(r.ok)); - service.registerDevice("QA", "00008120-000A1C3E0C68201E", r -> assertTrue(r.ok)); + service.registerDevice("QA", "00008120-000A1C3E0C68201E", "IOS", r -> assertTrue(r.ok)); service.refresh(r -> after[0] = r.value); assertTrue(after[0].bundleIds.size() >= 3); assertTrue(after[0].devices.size() >= 3); diff --git a/scripts/certificatewizard/specs/openapi.json b/scripts/certificatewizard/specs/openapi.json index 579938e78b6..cbfd2cd5461 100644 --- a/scripts/certificatewizard/specs/openapi.json +++ b/scripts/certificatewizard/specs/openapi.json @@ -659,6 +659,46 @@ } } }, + "/appsec/7.0/apple/profiles/reconcile": { + "post": { + "tags": [ + "Profiles" + ], + "summary": "Reconcile local state with Apple", + "description": "Imports every provisioning profile Apple lists that we do not have, and removes rows whose Apple resource is gone. Paired with the certificate reconcile behind \"Sync with Apple\": without it the profile list only ever held profiles this wizard created.", + "operationId": "reconcileProfiles", + "responses": { + "200": { + "description": "Profiles after reconciliation", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProfileDTO" + } + } + } + } + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/AscKeyProblem" + }, + "422": { + "$ref": "#/components/responses/AppleRejected" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "502": { + "$ref": "#/components/responses/AppleError" + } + } + } + }, "/appsec/7.0/apple/profiles/{id}/download": { "get": { "tags": [ From b63b0e1fa801177c98965681597a3b23d85a6857 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:20:25 +0300 Subject: [PATCH 2/2] Overview: the App Store profile card speaks for THIS project Review finding on #5815, and correct -- and it is the same defect this PR set out to fix, in a narrower form that the companion BuildCloud change makes more likely rather than less. appStoreProfileForOverview returned the first ACTIVE IOS_APP_STORE profile on the account without looking at its bundle id. Before this PR the card showed profiles.get(0), so any profile at all; after it, any App Store profile on the account. Once "Sync with Apple" imports the whole account rather than only what this wizard created (codenameone/BuildCloud#147), a team that ships more than one app is likely to have another app's App Store profile matched first -- announced "Ready" here, and then refused for this project by the very preflight check added in this same PR. So the selection now matches the bundle id too, and the doc lists all three things that have to line up, since each has been wrong in this one card. A blank or unreadable project identifier skips the filter rather than guessing it, which is the rule IOSProvisioningPreflight follows for the same reason: a card reading "None yet" beside a perfectly good profile is its own kind of wrong. The certificate card is deliberately NOT scoped this way. An Apple Distribution certificate signs anything the team ships; a provisioning profile is issued for one App ID. That asymmetry is noted at the call site so it does not read as an oversight. 82 wizard tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../certificatewizard/CertificateWizard.java | 6 ++- .../api/WizardDecisions.java | 30 ++++++++++++--- .../CertificateWizardModelTest.java | 38 +++++++++++++++++-- 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/CertificateWizard.java b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/CertificateWizard.java index 20258ffc86d..25e2806b410 100644 --- a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/CertificateWizard.java +++ b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/CertificateWizard.java @@ -460,7 +460,11 @@ private void overviewPage() { setupCard(setup, "Apple distribution certificate", distribution == null ? "None active" : "Ready", distribution == null ? "-" : distribution.displayName()); - SigningState.Profile appStore = WizardDecisions.appStoreProfileForOverview(state); + // Scoped to this project's bundle id. The certificate card is not, and that asymmetry is + // deliberate: an Apple Distribution certificate signs anything the team ships, while a + // provisioning profile is issued for one App ID and another app's is no use here. + SigningState.Profile appStore = WizardDecisions.appStoreProfileForOverview( + state, projectBundleIdentifier()); setupCard(setup, "App Store profile", appStore == null ? "None yet" : "Ready", appStore == null ? "-" : appStore.name()); diff --git a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/WizardDecisions.java b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/WizardDecisions.java index 5ef0eb3fa57..ce96b4f05bd 100644 --- a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/WizardDecisions.java +++ b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/WizardDecisions.java @@ -169,14 +169,32 @@ public static SigningState.Certificate distributionCertificateForOverview(Signin /// The profile the overview's "App Store profile" card is about, or null when there is none. /// /// Same defect, same shape: `profiles.get(0)` called a Development profile the App Store - /// profile. Both the type and the state matter -- a profile Apple has marked INVALID is one - /// the next build cannot sign with, so calling it "Ready" is the same false assurance. - public static SigningState.Profile appStoreProfileForOverview(SigningState state) { + /// profile. Three things have to match, and each has been wrong in this card at some point: + /// + /// - the TYPE, because a Development profile is not an App Store profile; + /// - the STATE, because a profile Apple has marked INVALID is one the next build cannot + /// sign with, and "Ready" is the same false assurance; + /// - the BUNDLE ID, because the card speaks for THIS project. An account accumulates + /// profiles for every app its team ships, and once "Sync with Apple" imports the whole + /// account rather than only what this wizard created, another app's App Store profile is + /// the likely first match -- announced "Ready" here and then refused by + /// IOSProvisioningPreflight, which compares the same two things. + /// + /// `projectBundleId` null or blank means the project's identifier could not be read, and the + /// filter is skipped rather than guessed: the same rule the preflight follows, because a card + /// reading "None yet" beside a perfectly good profile is its own kind of wrong. + public static SigningState.Profile appStoreProfileForOverview(SigningState state, + String projectBundleId) { + String wanted = projectBundleId == null ? null : projectBundleId.trim(); for (SigningState.Profile p : state.profiles) { - if ("IOS_APP_STORE".equals(p.profileType()) - && (p.status() == null || "ACTIVE".equals(p.status()))) { - return p; + if (!"IOS_APP_STORE".equals(p.profileType()) + || !(p.status() == null || "ACTIVE".equals(p.status()))) { + continue; + } + if (wanted != null && !wanted.isEmpty() && !wanted.equals(p.bundleId())) { + continue; } + return p; } return null; } diff --git a/scripts/certificatewizard/common/src/test/java/com/codename1/certificatewizard/CertificateWizardModelTest.java b/scripts/certificatewizard/common/src/test/java/com/codename1/certificatewizard/CertificateWizardModelTest.java index aebfee4b349..7f4cce39bb3 100644 --- a/scripts/certificatewizard/common/src/test/java/com/codename1/certificatewizard/CertificateWizardModelTest.java +++ b/scripts/certificatewizard/common/src/test/java/com/codename1/certificatewizard/CertificateWizardModelTest.java @@ -118,21 +118,53 @@ void overviewAppStoreCardIgnoresOtherProfiles() { SigningState devOnly = new SigningState(new SigningState.Credential(true, "KEY", "ISSUER"), null, null, null, profiles, null, null); - assertNull(WizardDecisions.appStoreProfileForOverview(devOnly)); + assertNull(WizardDecisions.appStoreProfileForOverview(devOnly, "com.example.app")); profiles.add(new SigningState.Profile(2L, "P_STALE", "dtest11 App Store", "IOS_APP_STORE", "com.example.app", "u2", null, "INVALID")); SigningState stale = new SigningState(new SigningState.Credential(true, "KEY", "ISSUER"), null, null, null, profiles, null, null); - assertNull(WizardDecisions.appStoreProfileForOverview(stale)); + assertNull(WizardDecisions.appStoreProfileForOverview(stale, "com.example.app")); profiles.add(new SigningState.Profile(3L, "P_STORE", "dtest11 App Store", "IOS_APP_STORE", "com.example.app", "u3", null, "ACTIVE")); SigningState ready = new SigningState(new SigningState.Credential(true, "KEY", "ISSUER"), null, null, null, profiles, null, null); - assertEquals("P_STORE", WizardDecisions.appStoreProfileForOverview(ready).appleProfileId()); + assertEquals("P_STORE", WizardDecisions.appStoreProfileForOverview(ready, "com.example.app").appleProfileId()); + } + + /** + * The card speaks for THIS project. Once "Sync with Apple" imports the whole account rather + * than only what this wizard created, another app's App Store profile is the likely first + * match -- and announcing it "Ready" here is the same false assurance as before, with the + * added twist that IOSProvisioningPreflight then refuses that very profile. + */ + @Test + void overviewAppStoreCardIgnoresAnotherAppsProfile() { + List profiles = new ArrayList(); + profiles.add(new SigningState.Profile(1L, "P_OTHER", "Someone Else App Store", + "IOS_APP_STORE", "com.example.other", "u1", null, "ACTIVE")); + SigningState other = new SigningState(new SigningState.Credential(true, "KEY", "ISSUER"), + null, null, null, profiles, null, null); + + assertNull(WizardDecisions.appStoreProfileForOverview(other, "com.example.app")); + + // Unknown project identifier: skipped rather than guessed, the same rule the build + // preflight follows. "None yet" beside a good profile is its own kind of wrong. + assertEquals("P_OTHER", + WizardDecisions.appStoreProfileForOverview(other, null).appleProfileId()); + assertEquals("P_OTHER", + WizardDecisions.appStoreProfileForOverview(other, " ").appleProfileId()); + + profiles.add(new SigningState.Profile(2L, "P_MINE", "My App Store", + "IOS_APP_STORE", "com.example.app", "u2", null, "ACTIVE")); + SigningState both = new SigningState(new SigningState.Credential(true, "KEY", "ISSUER"), + null, null, null, profiles, null, null); + + assertEquals("P_MINE", + WizardDecisions.appStoreProfileForOverview(both, "com.example.app").appleProfileId()); } /**