From e8ca429161cb17bb9192a7bef2739b10e8a13d38 Mon Sep 17 00:00:00 2001 From: koraytt Date: Fri, 29 May 2026 14:15:06 +0300 Subject: [PATCH] fix #464: fall back to redownload endpoint on volumeStore 5002 volumeStore stays the primary download endpoint, so every app that works today keeps hitting the same endpoint. On failureType 5002 it falls back to the bag-resolved redownloadProduct. The redownload response disambiguates a genuine license (redownload serves the app) from a transient volumeStore 5002 (redownload cannot serve it -> retry volumeStore) -- the latter previously made list-versions fail on apps that download succeeded on. - per-endpoint version-pin key: volumeStore uses externalVersionId, redownload uses appExtVrsId - remove the 5002 -> ErrPasswordTokenExpired mapping that caused a re-login loop - surface the endpoint customerMessage on empty results instead of a generic 'invalid response' - download, list-versions and get-version-metadata share sendDownloadProduct + downloadProductItem - volumeStore stays hardcoded: the bag's volumeStoreDownloadProduct URL is the enterprise/VPP host and returns HTTP 500 for the client request shape Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/download.go | 23 ++- cmd/get_version_metadata.go | 17 +- cmd/list_versions.go | 16 +- pkg/appstore/appstore_bag.go | 9 +- pkg/appstore/appstore_bag_test.go | 9 +- pkg/appstore/appstore_download.go | 77 ++----- pkg/appstore/appstore_download_test.go | 56 ++++++ pkg/appstore/appstore_endpoint.go | 180 +++++++++++++++++ pkg/appstore/appstore_endpoint_test.go | 189 ++++++++++++++++++ pkg/appstore/appstore_get_version_metadata.go | 67 +------ .../appstore_get_version_metadata_test.go | 67 +++++++ pkg/appstore/appstore_list_versions.go | 64 +----- pkg/appstore/appstore_list_versions_test.go | 45 +++++ 13 files changed, 610 insertions(+), 209 deletions(-) create mode 100644 pkg/appstore/appstore_endpoint.go create mode 100644 pkg/appstore/appstore_endpoint_test.go 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)) + }) + }) })