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..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 @@ -449,12 +449,25 @@ 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()); + // 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", - 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 +1157,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 +2773,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..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 @@ -153,6 +153,52 @@ 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. 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()))) { + continue; + } + if (wanted != null && !wanted.isEmpty() && !wanted.equals(p.bundleId())) { + continue; + } + 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..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 @@ -78,6 +78,122 @@ 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, "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, "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, "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()); + } + + /** + * 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 +451,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": [