diff --git a/cmd/download.go b/cmd/download.go index b1e2108f..f5a63b42 100644 --- a/cmd/download.go +++ b/cmd/download.go @@ -43,12 +43,12 @@ func downloadCmd() *cobra.Command { acc = infoResult.Account - if errors.Is(lastErr, appstore.ErrPasswordTokenExpired) { - bagOutput, err := dependencies.AppStore.Bag(appstore.BagInput{}) - if err != nil { - return fmt.Errorf("failed to get bag: %w", err) - } + bagOutput, err := dependencies.AppStore.Bag(appstore.BagInput{}) + if err != nil { + return fmt.Errorf("failed to get bag: %w", err) + } + if errors.Is(lastErr, appstore.ErrPasswordTokenExpired) { loginResult, err := dependencies.AppStore.Login(appstore.LoginInput{ Email: acc.Email, Password: acc.Password, @@ -111,12 +111,13 @@ func downloadCmd() *cobra.Command { } out, err := dependencies.AppStore.Download(appstore.DownloadInput{ - Account: acc, - App: app, - OutputPath: outputPath, - Progress: progress, - ExternalVersionID: externalVersionID, - Platform: platform, + Account: acc, + App: app, + OutputPath: outputPath, + Progress: progress, + ExternalVersionID: externalVersionID, + Platform: platform, + RedownloadEndpoint: bagOutput.RedownloadEndpoint, }) if err != nil { return err diff --git a/cmd/get_version_metadata.go b/cmd/get_version_metadata.go index 3fcd3367..81c17b44 100644 --- a/cmd/get_version_metadata.go +++ b/cmd/get_version_metadata.go @@ -37,12 +37,12 @@ func getVersionMetadataCmd() *cobra.Command { acc = infoResult.Account - if errors.Is(lastErr, appstore.ErrPasswordTokenExpired) { - bagOutput, err := dependencies.AppStore.Bag(appstore.BagInput{}) - if err != nil { - return fmt.Errorf("failed to get bag: %w", err) - } + bagOutput, err := dependencies.AppStore.Bag(appstore.BagInput{}) + if err != nil { + return fmt.Errorf("failed to get bag: %w", err) + } + if errors.Is(lastErr, appstore.ErrPasswordTokenExpired) { loginResult, err := dependencies.AppStore.Login(appstore.LoginInput{ Email: acc.Email, Password: acc.Password, @@ -66,9 +66,10 @@ func getVersionMetadataCmd() *cobra.Command { } out, err := dependencies.AppStore.GetVersionMetadata(appstore.GetVersionMetadataInput{ - Account: acc, - App: app, - VersionID: externalVersionID, + Account: acc, + App: app, + VersionID: externalVersionID, + RedownloadEndpoint: bagOutput.RedownloadEndpoint, }) if err != nil { return err diff --git a/cmd/list_versions.go b/cmd/list_versions.go index a583703c..d5f98d15 100644 --- a/cmd/list_versions.go +++ b/cmd/list_versions.go @@ -36,12 +36,12 @@ func ListVersionsCmd() *cobra.Command { acc = infoResult.Account - if errors.Is(lastErr, appstore.ErrPasswordTokenExpired) { - bagOutput, err := dependencies.AppStore.Bag(appstore.BagInput{}) - if err != nil { - return fmt.Errorf("failed to get bag: %w", err) - } + bagOutput, err := dependencies.AppStore.Bag(appstore.BagInput{}) + if err != nil { + return fmt.Errorf("failed to get bag: %w", err) + } + if errors.Is(lastErr, appstore.ErrPasswordTokenExpired) { loginResult, err := dependencies.AppStore.Login(appstore.LoginInput{ Email: acc.Email, Password: acc.Password, @@ -64,7 +64,11 @@ func ListVersionsCmd() *cobra.Command { app = lookupResult.App } - out, err := dependencies.AppStore.ListVersions(appstore.ListVersionsInput{Account: acc, App: app}) + out, err := dependencies.AppStore.ListVersions(appstore.ListVersionsInput{ + Account: acc, + App: app, + RedownloadEndpoint: bagOutput.RedownloadEndpoint, + }) if err != nil { return err } diff --git a/pkg/appstore/appstore_bag.go b/pkg/appstore/appstore_bag.go index 29108a2f..d6f33655 100644 --- a/pkg/appstore/appstore_bag.go +++ b/pkg/appstore/appstore_bag.go @@ -11,7 +11,8 @@ import ( type BagInput struct{} type BagOutput struct { - AuthEndpoint string + AuthEndpoint string + RedownloadEndpoint string } func (t *appstore) Bag(input BagInput) (BagOutput, error) { @@ -33,7 +34,8 @@ func (t *appstore) Bag(input BagInput) (BagOutput, error) { } return BagOutput{ - AuthEndpoint: res.Data.URLBag.AuthEndpoint, + AuthEndpoint: res.Data.URLBag.AuthEndpoint, + RedownloadEndpoint: res.Data.URLBag.RedownloadEndpoint, }, nil } @@ -42,7 +44,8 @@ type bagResult struct { } type urlBag struct { - AuthEndpoint string `plist:"authenticateAccount,omitempty"` + AuthEndpoint string `plist:"authenticateAccount,omitempty"` + RedownloadEndpoint string `plist:"redownloadProduct,omitempty"` } func (*appstore) bagRequest(guid string) http.Request { diff --git a/pkg/appstore/appstore_bag_test.go b/pkg/appstore/appstore_bag_test.go index ae9f1f27..7606eff0 100644 --- a/pkg/appstore/appstore_bag_test.go +++ b/pkg/appstore/appstore_bag_test.go @@ -86,7 +86,10 @@ var _ = Describe("AppStore (Bag)", func() { }) When("request is successful with authenticateAccount in urlBag", func() { - const testAuthEndpoint = "https://example.com" + const ( + testAuthEndpoint = "https://example.com" + testRedownloadEndpoint = "https://downloaddispatch.itunes.apple.com/r/redownload" + ) BeforeEach(func() { mockMachine.EXPECT(). @@ -105,7 +108,8 @@ var _ = Describe("AppStore (Bag)", func() { StatusCode: gohttp.StatusOK, Data: bagResult{ URLBag: urlBag{ - AuthEndpoint: testAuthEndpoint, + AuthEndpoint: testAuthEndpoint, + RedownloadEndpoint: testRedownloadEndpoint, }, }, }, nil) @@ -115,6 +119,7 @@ var _ = Describe("AppStore (Bag)", func() { out, err := as.Bag(BagInput{}) Expect(err).ToNot(HaveOccurred()) Expect(out.AuthEndpoint).To(Equal(testAuthEndpoint)) + Expect(out.RedownloadEndpoint).To(Equal(testRedownloadEndpoint)) }) }) diff --git a/pkg/appstore/appstore_download.go b/pkg/appstore/appstore_download.go index f86ee77a..2b5d687b 100644 --- a/pkg/appstore/appstore_download.go +++ b/pkg/appstore/appstore_download.go @@ -9,7 +9,6 @@ import ( "strconv" "strings" - "github.com/majd/ipatool/v2/pkg/http" "github.com/schollz/progressbar/v3" "howett.net/plist" ) @@ -19,12 +18,13 @@ var ( ) type DownloadInput struct { - Account Account - App App - OutputPath string - Progress *progressbar.ProgressBar - ExternalVersionID string - Platform Platform + Account Account + App App + OutputPath string + Progress *progressbar.ProgressBar + ExternalVersionID string + Platform Platform + RedownloadEndpoint string } type DownloadOutput struct { @@ -48,38 +48,16 @@ func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) { } } - req := t.downloadRequest(input.Account, input.App, guid, externalVersionID) - - res, err := t.downloadClient.Send(req) + res, err := t.sendDownloadProduct(input.Account, input.App, guid, externalVersionID, input.RedownloadEndpoint) if err != nil { - return DownloadOutput{}, fmt.Errorf("failed to send http request: %w", err) - } - - if res.Data.FailureType == FailureTypePasswordTokenExpired || - res.Data.FailureType == FailureTypeSignInRequired || - res.Data.FailureType == FailureTypeDeviceVerificationFailed || - res.Data.FailureType == FailureTypeLicenseAlreadyExists { - return DownloadOutput{}, ErrPasswordTokenExpired - } - - if res.Data.FailureType == FailureTypeLicenseNotFound { - return DownloadOutput{}, ErrLicenseRequired + return DownloadOutput{}, err } - if res.Data.FailureType != "" && res.Data.CustomerMessage != "" { - return DownloadOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.CustomerMessage), res) - } - - if res.Data.FailureType != "" { - return DownloadOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.FailureType), res) - } - - if len(res.Data.Items) == 0 { - return DownloadOutput{}, NewErrorWithMetadata(errors.New("invalid response"), res) + item, err := downloadProductItem(res) + if err != nil { + return DownloadOutput{}, err } - item := res.Data.Items[0] - version := "unknown" // Read the version from the item metadata @@ -239,37 +217,6 @@ func (t *appstore) downloadFile(src, dst string, progress *progressbar.ProgressB return nil } -func (*appstore) downloadRequest(acc Account, app App, guid string, externalVersionID string) http.Request { - payload := map[string]interface{}{ - "creditDisplay": "", - "guid": guid, - "salableAdamId": app.ID, - } - - if externalVersionID != "" { - payload["externalVersionId"] = externalVersionID - } - - podPrefix := "" - if acc.Pod != "" { - podPrefix = "p" + acc.Pod + "-" - } - - return http.Request{ - URL: fmt.Sprintf("https://%s%s%s?guid=%s", podPrefix, PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathDownload, guid), - Method: http.MethodPOST, - ResponseFormat: http.ResponseFormatXML, - Headers: map[string]string{ - "Content-Type": "application/x-apple-plist", - "iCloud-DSID": acc.DirectoryServicesID, - "X-Dsid": acc.DirectoryServicesID, - }, - Payload: &http.XMLPayload{ - Content: payload, - }, - } -} - func fileName(app App, version string) string { var parts []string diff --git a/pkg/appstore/appstore_download_test.go b/pkg/appstore/appstore_download_test.go index 3432a4ce..113b18c3 100644 --- a/pkg/appstore/appstore_download_test.go +++ b/pkg/appstore/appstore_download_test.go @@ -187,6 +187,62 @@ var _ = Describe("AppStore (Download)", func() { }) }) + When("a licensed tvOS app falls back to the redownload endpoint", func() { + const testRedownload = "https://downloaddispatch.itunes.apple.com/r/redownload" + + BeforeEach(func() { + mockMachine.EXPECT(). + MacAddress(). + Return("00:11:22:33:44:55", nil) + + mockPlatformClient.EXPECT(). + Send(gomock.Any()). + Return(http.Result[platformVersionLookupResult]{ + StatusCode: 200, + Data: platformVersionLookupResult{ + Results: map[string]platformVersionLookupItem{ + "42": { + Offers: []platformVersionLookupOffer{ + {Version: platformVersionLookupVersion{ExternalID: platformVersionExternalID("123456")}}, + }, + }, + }, + }, + }, nil) + + gomock.InOrder( + mockDownloadClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { + Expect(req.URL).To(ContainSubstring(PrivateAppStoreAPIPathDownload)) + payload := req.Payload.(*http.XMLPayload) + Expect(payload.Content).To(HaveKeyWithValue(downloadVersionKeyVolumeStore, "123456")) + Expect(payload.Content).ToNot(HaveKey(downloadVersionKeyRedownload)) + }). + Return(http.Result[downloadResult]{Data: downloadResult{FailureType: FailureTypeLicenseAlreadyExists}}, nil), + mockDownloadClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { + Expect(req.URL).To(HavePrefix(testRedownload)) + payload := req.Payload.(*http.XMLPayload) + Expect(payload.Content).To(HaveKeyWithValue(downloadVersionKeyRedownload, "123456")) + Expect(payload.Content).ToNot(HaveKey(downloadVersionKeyVolumeStore)) + }). + Return(http.Result[downloadResult]{}, errors.New("stop after fallback")), + ) + }) + + It("retries on redownload translating externalVersionId to appExtVrsId", func() { + _, err := as.Download(DownloadInput{ + Account: Account{StoreFront: "143441"}, + App: App{ID: 42}, + Platform: PlatformAppleTV, + RedownloadEndpoint: testRedownload, + }) + Expect(err).To(HaveOccurred()) + }) + }) + DescribeTable("platform uses the standard download request", func(platform Platform) { mockMachine.EXPECT(). diff --git a/pkg/appstore/appstore_endpoint.go b/pkg/appstore/appstore_endpoint.go new file mode 100644 index 00000000..42367e57 --- /dev/null +++ b/pkg/appstore/appstore_endpoint.go @@ -0,0 +1,180 @@ +package appstore + +import ( + "errors" + "fmt" + + "github.com/majd/ipatool/v2/pkg/http" +) + +// The App Store exposes two interchangeable download endpoints that, for a +// consumer Apple ID, serve disjoint sets of apps (issue #464): +// +// - volumeStore (buy.itunes.apple.com/.../volumeStoreDownloadProduct) serves +// apps the account holds no consumer license for. For apps it does hold a +// license for it returns failureType 5002 (FailureTypeLicenseAlreadyExists). +// - redownload (advertised by the bag as redownloadProduct) serves the +// licensed apps. For everything else it returns an empty failureType with a +// " No Longer Available" customerMessage. +// +// volumeStore also returns 5002 *intermittently* for apps it can otherwise serve +// (observed ~1 in 8 calls, typically the first), so a 5002 is not on its own +// proof that an app is licensed. The redownload response disambiguates: if +// redownload serves the app it was genuinely licensed; if redownload cannot +// serve it either, the 5002 was transient and volumeStore is retried. +// +// The two endpoints also disagree on the key used to pin an external version: +// volumeStore reads externalVersionId, redownload reads appExtVrsId. The unused +// key is silently ignored, so the request must carry the right one. +const ( + downloadVersionKeyVolumeStore = "externalVersionId" + downloadVersionKeyRedownload = "appExtVrsId" + + // volumeStoreRetriesAfterFallback is how many extra times volumeStore is + // tried when it returned 5002 but redownload also could not serve the app — + // i.e. when the 5002 was a transient hiccup rather than a real license. + volumeStoreRetriesAfterFallback = 2 +) + +// downloadProductEndpoint identifies a download endpoint and the version-pin key +// it expects. +type downloadProductEndpoint struct { + baseURL string + versionKey string +} + +// volumeStoreEndpoint is the primary endpoint. It is not resolved from the bag: +// the bag advertises a volumeStoreDownloadProduct URL, but that host speaks the +// enterprise/VPP protocol and rejects ipatool's request shape, so the long-lived +// MZFinance URL is used directly. +func (*appstore) volumeStoreEndpoint(acc Account) downloadProductEndpoint { + podPrefix := "" + if acc.Pod != "" { + podPrefix = "p" + acc.Pod + "-" + } + + return downloadProductEndpoint{ + baseURL: fmt.Sprintf("https://%s%s%s", podPrefix, PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathDownload), + versionKey: downloadVersionKeyVolumeStore, + } +} + +// redownloadEndpoint is the fallback endpoint, resolved from the bag. +func redownloadEndpoint(bagURL string) downloadProductEndpoint { + return downloadProductEndpoint{ + baseURL: bagURL, + versionKey: downloadVersionKeyRedownload, + } +} + +func (*appstore) downloadProductRequest(endpoint downloadProductEndpoint, acc Account, app App, guid, externalVersionID string) http.Request { + payload := map[string]interface{}{ + "creditDisplay": "", + "guid": guid, + "salableAdamId": app.ID, + } + + if externalVersionID != "" { + payload[endpoint.versionKey] = externalVersionID + } + + return http.Request{ + URL: fmt.Sprintf("%s?guid=%s", endpoint.baseURL, guid), + Method: http.MethodPOST, + ResponseFormat: http.ResponseFormatXML, + Headers: map[string]string{ + "Content-Type": "application/x-apple-plist", + "iCloud-DSID": acc.DirectoryServicesID, + "X-Dsid": acc.DirectoryServicesID, + }, + Payload: &http.XMLPayload{ + Content: payload, + }, + } +} + +// sendDownloadProduct sends a download-family request to the primary volumeStore +// endpoint, resolving issue #464 with a fallback to the bag-advertised redownload +// endpoint. redownloadBagURL is the bag's redownloadProduct URL; when it is empty +// (a bag that does not advertise the key) only the primary endpoint is used. +// +// On a volumeStore 5002 it tries redownload. If redownload serves the app, that +// response is used (the app was genuinely licensed). If redownload cannot serve +// it either — an empty failureType with no items, the "No Longer Available" +// signature — the 5002 was transient, so volumeStore is retried. +func (t *appstore) sendDownloadProduct(acc Account, app App, guid, externalVersionID, redownloadBagURL string) (http.Result[downloadResult], error) { + volumeStore := t.volumeStoreEndpoint(acc) + + res, err := t.downloadClient.Send(t.downloadProductRequest(volumeStore, acc, app, guid, externalVersionID)) + if err != nil { + return res, fmt.Errorf("failed to send http request: %w", err) + } + + if res.Data.FailureType != FailureTypeLicenseAlreadyExists || redownloadBagURL == "" { + return res, nil + } + + redownloadRes, err := t.downloadClient.Send(t.downloadProductRequest(redownloadEndpoint(redownloadBagURL), acc, app, guid, externalVersionID)) + if err != nil { + return redownloadRes, fmt.Errorf("failed to send http request: %w", err) + } + + // Redownload served the app (genuinely licensed, e.g. Microsoft Teams) or + // returned an actionable failure (auth/license) — use that response. + if len(redownloadRes.Data.Items) > 0 || redownloadRes.Data.FailureType != "" { + return redownloadRes, nil + } + + // Redownload cannot serve the app, so the volumeStore 5002 was a transient + // hiccup rather than a real license. Retry volumeStore. + for i := 0; i < volumeStoreRetriesAfterFallback; i++ { + res, err = t.downloadClient.Send(t.downloadProductRequest(volumeStore, acc, app, guid, externalVersionID)) + if err != nil { + return res, fmt.Errorf("failed to send http request: %w", err) + } + + if res.Data.FailureType != FailureTypeLicenseAlreadyExists { + return res, nil + } + } + + // volumeStore kept returning 5002 and redownload cannot serve the app; the + // redownload response carries the most informative message for the caller. + return redownloadRes, nil +} + +// downloadProductItem interprets a download-family response, returning the first +// song-list item or a typed error. It is shared by Download, ListVersions and +// GetVersionMetadata, which all POST the same payload and decode the same shape. +func downloadProductItem(res http.Result[downloadResult]) (downloadItemResult, error) { + switch { + case res.Data.FailureType == FailureTypePasswordTokenExpired || + res.Data.FailureType == FailureTypeSignInRequired || + res.Data.FailureType == FailureTypeDeviceVerificationFailed: + return downloadItemResult{}, ErrPasswordTokenExpired + case res.Data.FailureType == FailureTypeLicenseNotFound: + return downloadItemResult{}, ErrLicenseRequired + case res.Data.FailureType == FailureTypeLicenseAlreadyExists: + // 5002 means the account already holds a consumer license, so the app + // can only be fetched from the redownload endpoint. Reaching here means + // the fallback was unavailable — the bag advertised no redownloadProduct + // URL. Surface that plainly instead of the endpoint's opaque "An unknown + // error has occurred" customerMessage. + return downloadItemResult{}, NewErrorWithMetadata(errors.New("the App Store requires the redownload endpoint for this app (failureType 5002), but the bag did not advertise one"), res) + case res.Data.FailureType != "" && res.Data.CustomerMessage != "": + return downloadItemResult{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.CustomerMessage), res) + case res.Data.FailureType != "": + return downloadItemResult{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.FailureType), res) + case len(res.Data.Items) == 0: + // The redownload endpoint reports apps it cannot serve with an empty + // failureType and a " No Longer Available" customerMessage; surface + // that rather than a generic "invalid response". + if res.Data.CustomerMessage != "" { + return downloadItemResult{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.CustomerMessage), res) + } + + return downloadItemResult{}, NewErrorWithMetadata(errors.New("invalid response"), res) + default: + return res.Data.Items[0], nil + } +} diff --git a/pkg/appstore/appstore_endpoint_test.go b/pkg/appstore/appstore_endpoint_test.go new file mode 100644 index 00000000..6ce1a79b --- /dev/null +++ b/pkg/appstore/appstore_endpoint_test.go @@ -0,0 +1,189 @@ +package appstore + +import ( + "errors" + + "github.com/majd/ipatool/v2/pkg/http" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("AppStore (download endpoint fallback)", func() { + const redownloadURL = "https://downloaddispatch.itunes.apple.com/r/redownload" + + var ( + ctrl *gomock.Controller + mockDownloadClient *http.MockClient[downloadResult] + st *appstore + ) + + BeforeEach(func() { + ctrl = gomock.NewController(GinkgoT()) + mockDownloadClient = http.NewMockClient[downloadResult](ctrl) + st = &appstore{downloadClient: mockDownloadClient} + }) + + AfterEach(func() { + ctrl.Finish() + }) + + failure := func(ft string) http.Result[downloadResult] { + return http.Result[downloadResult]{Data: downloadResult{FailureType: ft}} + } + success := func() http.Result[downloadResult] { + return http.Result[downloadResult]{Data: downloadResult{Items: []downloadItemResult{{URL: "https://example.com/app.ipa"}}}} + } + noLongerAvailable := func() http.Result[downloadResult] { + return http.Result[downloadResult]{Data: downloadResult{CustomerMessage: "“App” No Longer Available"}} + } + + Describe("sendDownloadProduct", func() { + When("the volumeStore endpoint returns failureType 5002 and a redownload endpoint is available", func() { + It("retries on the redownload endpoint with the appExtVrsId version key", func() { + gomock.InOrder( + mockDownloadClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { + Expect(req.URL).To(ContainSubstring(PrivateAppStoreAPIPathDownload)) + payload := req.Payload.(*http.XMLPayload) + Expect(payload.Content).To(HaveKeyWithValue(downloadVersionKeyVolumeStore, "123")) + Expect(payload.Content).ToNot(HaveKey(downloadVersionKeyRedownload)) + }). + Return(failure(FailureTypeLicenseAlreadyExists), nil), + mockDownloadClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { + Expect(req.URL).To(HavePrefix(redownloadURL)) + payload := req.Payload.(*http.XMLPayload) + Expect(payload.Content).To(HaveKeyWithValue(downloadVersionKeyRedownload, "123")) + Expect(payload.Content).ToNot(HaveKey(downloadVersionKeyVolumeStore)) + }). + Return(success(), nil), + ) + + res, err := st.sendDownloadProduct(Account{}, App{ID: 42}, "GUID", "123", redownloadURL) + Expect(err).ToNot(HaveOccurred()) + Expect(res.Data.Items).To(HaveLen(1)) + }) + }) + + When("the volumeStore endpoint returns 5002 but no redownload endpoint is configured", func() { + It("does not retry and returns the original response", func() { + mockDownloadClient.EXPECT(). + Send(gomock.Any()). + Return(failure(FailureTypeLicenseAlreadyExists), nil). + Times(1) + + res, err := st.sendDownloadProduct(Account{}, App{ID: 42}, "GUID", "", "") + Expect(err).ToNot(HaveOccurred()) + Expect(res.Data.FailureType).To(Equal(FailureTypeLicenseAlreadyExists)) + }) + }) + + When("volumeStore returns a transient 5002 that redownload cannot serve", func() { + It("retries volumeStore and uses its recovered response", func() { + gomock.InOrder( + mockDownloadClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { Expect(req.URL).To(ContainSubstring(PrivateAppStoreAPIPathDownload)) }). + Return(failure(FailureTypeLicenseAlreadyExists), nil), + mockDownloadClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { Expect(req.URL).To(HavePrefix(redownloadURL)) }). + Return(noLongerAvailable(), nil), + mockDownloadClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { Expect(req.URL).To(ContainSubstring(PrivateAppStoreAPIPathDownload)) }). + Return(success(), nil), + ) + + res, err := st.sendDownloadProduct(Account{}, App{ID: 42}, "GUID", "", redownloadURL) + Expect(err).ToNot(HaveOccurred()) + Expect(res.Data.Items).To(HaveLen(1)) + }) + }) + + When("volumeStore keeps returning 5002 and redownload cannot serve the app", func() { + It("returns the redownload response so the caller sees the informative message", func() { + gomock.InOrder( + mockDownloadClient.EXPECT().Send(gomock.Any()).Return(failure(FailureTypeLicenseAlreadyExists), nil), + mockDownloadClient.EXPECT().Send(gomock.Any()).Return(noLongerAvailable(), nil), + mockDownloadClient.EXPECT().Send(gomock.Any()).Return(failure(FailureTypeLicenseAlreadyExists), nil).Times(volumeStoreRetriesAfterFallback), + ) + + res, err := st.sendDownloadProduct(Account{}, App{ID: 42}, "GUID", "", redownloadURL) + Expect(err).ToNot(HaveOccurred()) + _, itemErr := downloadProductItem(res) + Expect(itemErr.Error()).To(ContainSubstring("No Longer Available")) + }) + }) + + When("the volumeStore endpoint succeeds", func() { + It("does not call the redownload endpoint", func() { + mockDownloadClient.EXPECT(). + Send(gomock.Any()). + Return(success(), nil). + Times(1) + + res, err := st.sendDownloadProduct(Account{}, App{ID: 42}, "GUID", "", redownloadURL) + Expect(err).ToNot(HaveOccurred()) + Expect(res.Data.Items).To(HaveLen(1)) + }) + }) + + When("the volumeStore endpoint returns a non-5002 failure", func() { + It("does not fall back", func() { + mockDownloadClient.EXPECT(). + Send(gomock.Any()). + Return(failure(FailureTypeLicenseNotFound), nil). + Times(1) + + res, err := st.sendDownloadProduct(Account{}, App{ID: 42}, "GUID", "", redownloadURL) + Expect(err).ToNot(HaveOccurred()) + Expect(res.Data.FailureType).To(Equal(FailureTypeLicenseNotFound)) + }) + }) + }) + + Describe("downloadProductItem", func() { + It("reports a clear error for a surviving 5002 (no redownload endpoint available)", func() { + _, err := downloadProductItem(http.Result[downloadResult]{Data: downloadResult{ + FailureType: FailureTypeLicenseAlreadyExists, + CustomerMessage: "An unknown error has occurred", + }}) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, ErrPasswordTokenExpired)).To(BeFalse()) + Expect(err.Error()).To(ContainSubstring("redownload endpoint")) + Expect(err.Error()).ToNot(ContainSubstring("An unknown error has occurred")) + }) + + It("surfaces the customerMessage when the redownload endpoint reports the app unavailable", func() { + res := http.Result[downloadResult]{Data: downloadResult{CustomerMessage: "“YouTube” No Longer Available"}} + _, err := downloadProductItem(res) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("No Longer Available")) + }) + + It("maps password-token-expired failures to ErrPasswordTokenExpired", func() { + _, err := downloadProductItem(failure(FailureTypePasswordTokenExpired)) + Expect(errors.Is(err, ErrPasswordTokenExpired)).To(BeTrue()) + }) + + It("maps sign-in-required failures to ErrPasswordTokenExpired", func() { + _, err := downloadProductItem(failure(FailureTypeSignInRequired)) + Expect(errors.Is(err, ErrPasswordTokenExpired)).To(BeTrue()) + }) + + It("maps license-not-found failures to ErrLicenseRequired", func() { + _, err := downloadProductItem(failure(FailureTypeLicenseNotFound)) + Expect(errors.Is(err, ErrLicenseRequired)).To(BeTrue()) + }) + + It("returns the first song-list item on success", func() { + item, err := downloadProductItem(success()) + Expect(err).ToNot(HaveOccurred()) + Expect(item.URL).To(Equal("https://example.com/app.ipa")) + }) + }) +}) diff --git a/pkg/appstore/appstore_get_version_metadata.go b/pkg/appstore/appstore_get_version_metadata.go index efa182be..a1d0b28b 100644 --- a/pkg/appstore/appstore_get_version_metadata.go +++ b/pkg/appstore/appstore_get_version_metadata.go @@ -1,18 +1,16 @@ package appstore import ( - "errors" "fmt" "strings" "time" - - "github.com/majd/ipatool/v2/pkg/http" ) type GetVersionMetadataInput struct { - Account Account - App App - VersionID string + Account Account + App App + VersionID string + RedownloadEndpoint string } type GetVersionMetadataOutput struct { @@ -28,35 +26,16 @@ func (t *appstore) GetVersionMetadata(input GetVersionMetadataInput) (GetVersion guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "") - req := t.getVersionMetadataRequest(input.Account, input.App, guid, input.VersionID) - res, err := t.downloadClient.Send(req) - + res, err := t.sendDownloadProduct(input.Account, input.App, guid, input.VersionID, input.RedownloadEndpoint) if err != nil { - return GetVersionMetadataOutput{}, fmt.Errorf("failed to send http request: %w", err) - } - - if res.Data.FailureType == FailureTypePasswordTokenExpired || res.Data.FailureType == FailureTypeSignInRequired { - return GetVersionMetadataOutput{}, ErrPasswordTokenExpired - } - - if res.Data.FailureType == FailureTypeLicenseNotFound { - return GetVersionMetadataOutput{}, ErrLicenseRequired - } - - if res.Data.FailureType != "" && res.Data.CustomerMessage != "" { - return GetVersionMetadataOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.CustomerMessage), res) - } - - if res.Data.FailureType != "" { - return GetVersionMetadataOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.FailureType), res) + return GetVersionMetadataOutput{}, err } - if len(res.Data.Items) == 0 { - return GetVersionMetadataOutput{}, NewErrorWithMetadata(errors.New("invalid response"), res) + item, err := downloadProductItem(res) + if err != nil { + return GetVersionMetadataOutput{}, err } - item := res.Data.Items[0] - // Do not fall back to item.Metadata here. The App Store download API can // return stale version and release date values, so the IPA Info.plist is the // source of truth and failures should be visible to callers. @@ -67,31 +46,3 @@ func (t *appstore) GetVersionMetadata(input GetVersionMetadataInput) (GetVersion return GetVersionMetadataOutput(metadata), nil } - -func (t *appstore) getVersionMetadataRequest(acc Account, app App, guid string, version string) http.Request { - payload := map[string]interface{}{ - "creditDisplay": "", - "guid": guid, - "salableAdamId": app.ID, - "externalVersionId": version, - } - - podPrefix := "" - if acc.Pod != "" { - podPrefix = "p" + acc.Pod + "-" - } - - return http.Request{ - URL: fmt.Sprintf("https://%s%s%s?guid=%s", podPrefix, PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathDownload, guid), - Method: http.MethodPOST, - ResponseFormat: http.ResponseFormatXML, - Headers: map[string]string{ - "Content-Type": "application/x-apple-plist", - "iCloud-DSID": acc.DirectoryServicesID, - "X-Dsid": acc.DirectoryServicesID, - }, - Payload: &http.XMLPayload{ - Content: payload, - }, - } -} diff --git a/pkg/appstore/appstore_get_version_metadata_test.go b/pkg/appstore/appstore_get_version_metadata_test.go index 8c0a72a8..e943fd20 100644 --- a/pkg/appstore/appstore_get_version_metadata_test.go +++ b/pkg/appstore/appstore_get_version_metadata_test.go @@ -521,4 +521,71 @@ var _ = Describe("AppStore (GetVersionMetadata)", func() { Expect(atomic.LoadInt64(servedBytes)).To(BeNumerically("<", int64(len(ipa)/2))) }) }) + + When("the volumeStore endpoint returns 5002 and a redownload endpoint is available", func() { + const testRedownload = "https://downloaddispatch.itunes.apple.com/r/redownload" + + var ( + server *httptest.Server + releaseDate time.Time + displayVersion string + ) + + BeforeEach(func() { + releaseDate = time.Date(2024, 4, 2, 12, 0, 0, 0, time.UTC) + displayVersion = "2.0.0" + ipa := testIPA(displayVersion, releaseDate.Format(time.RFC3339), time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)) + server, _, _ = testIPAServer(ipa) + + mockMachine.EXPECT(). + MacAddress(). + Return("00:11:22:33:44:55", nil) + + gomock.InOrder( + mockDownloadClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { + Expect(req.URL).To(ContainSubstring(PrivateAppStoreAPIPathDownload)) + payload := req.Payload.(*http.XMLPayload) + Expect(payload.Content).To(HaveKeyWithValue(downloadVersionKeyVolumeStore, "test-version")) + }). + Return(http.Result[downloadResult]{Data: downloadResult{FailureType: FailureTypeLicenseAlreadyExists}}, nil), + mockDownloadClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { + Expect(req.URL).To(HavePrefix(testRedownload)) + payload := req.Payload.(*http.XMLPayload) + Expect(payload.Content).To(HaveKeyWithValue(downloadVersionKeyRedownload, "test-version")) + }). + Return(http.Result[downloadResult]{ + Data: downloadResult{ + Items: []downloadItemResult{ + { + URL: server.URL, + Metadata: map[string]interface{}{ + "releaseDate": "2020-01-01T00:00:00Z", + "bundleShortVersionString": "1.0.0", + }, + }, + }, + }, + }, nil), + ) + }) + + AfterEach(func() { + server.Close() + }) + + It("falls back to the redownload endpoint and returns metadata", func() { + output, err := as.GetVersionMetadata(GetVersionMetadataInput{ + App: App{ID: 1234567890}, + VersionID: "test-version", + RedownloadEndpoint: testRedownload, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(output.DisplayVersion).To(Equal(displayVersion)) + Expect(output.ReleaseDate).To(Equal(releaseDate)) + }) + }) }) diff --git a/pkg/appstore/appstore_list_versions.go b/pkg/appstore/appstore_list_versions.go index dc3c215d..02493820 100644 --- a/pkg/appstore/appstore_list_versions.go +++ b/pkg/appstore/appstore_list_versions.go @@ -1,16 +1,14 @@ package appstore import ( - "errors" "fmt" "strings" - - "github.com/majd/ipatool/v2/pkg/http" ) type ListVersionsInput struct { - Account Account - App App + Account Account + App App + RedownloadEndpoint string } type ListVersionsOutput struct { @@ -26,35 +24,16 @@ func (t *appstore) ListVersions(input ListVersionsInput) (ListVersionsOutput, er guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "") - req := t.listVersionsRequest(input.Account, input.App, guid) - res, err := t.downloadClient.Send(req) - + res, err := t.sendDownloadProduct(input.Account, input.App, guid, "", input.RedownloadEndpoint) if err != nil { - return ListVersionsOutput{}, fmt.Errorf("failed to send http request: %w", err) - } - - if res.Data.FailureType == FailureTypePasswordTokenExpired || res.Data.FailureType == FailureTypeSignInRequired { - return ListVersionsOutput{}, ErrPasswordTokenExpired - } - - if res.Data.FailureType == FailureTypeLicenseNotFound { - return ListVersionsOutput{}, ErrLicenseRequired - } - - if res.Data.FailureType != "" && res.Data.CustomerMessage != "" { - return ListVersionsOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.CustomerMessage), res) - } - - if res.Data.FailureType != "" { - return ListVersionsOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.FailureType), res) + return ListVersionsOutput{}, err } - if len(res.Data.Items) == 0 { - return ListVersionsOutput{}, NewErrorWithMetadata(errors.New("invalid response"), res) + item, err := downloadProductItem(res) + if err != nil { + return ListVersionsOutput{}, err } - item := res.Data.Items[0] - rawIdentifiers, ok := item.Metadata["softwareVersionExternalIdentifiers"].([]interface{}) if !ok { return ListVersionsOutput{}, NewErrorWithMetadata(fmt.Errorf("failed to get version identifiers from item metadata"), item.Metadata) @@ -75,30 +54,3 @@ func (t *appstore) ListVersions(input ListVersionsInput) (ListVersionsOutput, er LatestExternalVersionID: fmt.Sprintf("%v", latestExternalVersionID), }, nil } - -func (t *appstore) listVersionsRequest(acc Account, app App, guid string) http.Request { - payload := map[string]interface{}{ - "creditDisplay": "", - "guid": guid, - "salableAdamId": app.ID, - } - - podPrefix := "" - if acc.Pod != "" { - podPrefix = "p" + acc.Pod + "-" - } - - return http.Request{ - URL: fmt.Sprintf("https://%s%s%s?guid=%s", podPrefix, PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathDownload, guid), - Method: http.MethodPOST, - ResponseFormat: http.ResponseFormatXML, - Headers: map[string]string{ - "Content-Type": "application/x-apple-plist", - "iCloud-DSID": acc.DirectoryServicesID, - "X-Dsid": acc.DirectoryServicesID, - }, - Payload: &http.XMLPayload{ - Content: payload, - }, - } -} diff --git a/pkg/appstore/appstore_list_versions_test.go b/pkg/appstore/appstore_list_versions_test.go index 7b4fb67a..656a54c8 100644 --- a/pkg/appstore/appstore_list_versions_test.go +++ b/pkg/appstore/appstore_list_versions_test.go @@ -312,4 +312,49 @@ var _ = Describe("AppStore (ListVersions)", func() { Expect(out.LatestExternalVersionID).To(Equal(testLatest)) }) }) + + When("the volumeStore endpoint returns 5002 and a redownload endpoint is available", func() { + const ( + testRedownload = "https://downloaddispatch.itunes.apple.com/r/redownload" + testLatest = "87654321" + ) + + BeforeEach(func() { + mockMachine.EXPECT(). + MacAddress(). + Return("00:00:00:00:00:00", nil) + + gomock.InOrder( + mockDownloadClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { + Expect(req.URL).To(ContainSubstring(PrivateAppStoreAPIPathDownload)) + }). + Return(http.Result[downloadResult]{Data: downloadResult{FailureType: FailureTypeLicenseAlreadyExists}}, nil), + mockDownloadClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { + Expect(req.URL).To(HavePrefix(testRedownload)) + }). + Return(http.Result[downloadResult]{ + Data: downloadResult{ + Items: []downloadItemResult{ + { + Metadata: map[string]interface{}{ + "softwareVersionExternalIdentifiers": []interface{}{"12345678", testLatest}, + "softwareVersionExternalIdentifier": testLatest, + }, + }, + }, + }, + }, nil), + ) + }) + + It("falls back to the redownload endpoint and returns versions", func() { + out, err := as.ListVersions(ListVersionsInput{RedownloadEndpoint: testRedownload}) + Expect(err).ToNot(HaveOccurred()) + Expect(out.LatestExternalVersionID).To(Equal(testLatest)) + }) + }) })