diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a981f05..2b695f35 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,7 @@ jobs: with: go-version: '1.19.3' cache: true + - run: go generate github.com/majd/ipatool/... - run: go test -v github.com/majd/ipatool/... build: name: Build diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index a9ad6a80..10307149 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -15,4 +15,5 @@ jobs: with: go-version: '1.19.3' cache: true + - run: go generate github.com/majd/ipatool/... - run: go test -v github.com/majd/ipatool/... diff --git a/.gitignore b/.gitignore index 2a6a68f9..09eaee07 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ .AppleDouble .LSOverride .vscode/ -.idea/ \ No newline at end of file +.idea/ +**/*_mock.go \ No newline at end of file diff --git a/README.md b/README.md index 49889b80..55e17b6a 100644 --- a/README.md +++ b/README.md @@ -129,9 +129,10 @@ The tool can be compiled using the Go toolchain. $ go build -o ipatool ``` -Unit tests can be executed with the following command. +Unit tests can be executed with the following commands. ```shell +$ go generate github.com/majd/ipatool/... $ go test -v github.com/majd/ipatool/... ``` diff --git a/cmd/auth.go b/cmd/auth.go index b458534b..3a0c0b71 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -1,15 +1,20 @@ package cmd import ( + "bufio" + "errors" + "fmt" "github.com/99designs/keyring" + "github.com/avast/retry-go" "github.com/majd/ipatool/pkg/appstore" - "github.com/majd/ipatool/pkg/log" - "github.com/pkg/errors" + "github.com/majd/ipatool/pkg/util" "github.com/spf13/cobra" + "golang.org/x/term" + "os" + "strings" + "time" ) -var keychainPassphrase string - func authCmd() *cobra.Command { cmd := &cobra.Command{ Use: "auth", @@ -28,6 +33,19 @@ func authCmd() *cobra.Command { } func loginCmd() *cobra.Command { + promptForAuthCode := func() (string, error) { + reader := bufio.NewReader(os.Stdin) + authCode, err := reader.ReadString('\n') + if err != nil { + return "", err + } + + authCode = strings.Trim(authCode, "\n") + authCode = strings.Trim(authCode, "\r") + + return authCode, nil + } + var email string var password string var authCode string @@ -36,29 +54,72 @@ func loginCmd() *cobra.Command { Use: "login", Short: "Login to the App Store", RunE: func(cmd *cobra.Command, args []string) error { - store, err := newAppStore(cmd, keychainPassphrase) - if err != nil { - return errors.Wrap(err, "failed to create appstore client") + interactive := cmd.Context().Value("interactive").(bool) + + if password == "" && !interactive { + return errors.New("password is required when not running in interactive mode; use the \"--password\" flag") } - logger := cmd.Context().Value("logger").(log.Logger) - out, err := store.Login(email, password, authCode) - if err != nil { - if err == appstore.ErrAuthCodeRequired { - logger.Log().Msg("2FA code is required; run the command again and supply a code using the `--auth-code` flag") - return nil - } + if password == "" && interactive { + dependencies.Logger.Log().Msg("enter password:") - return err + bytes, err := term.ReadPassword(int(os.Stdin.Fd())) + if err != nil { + return fmt.Errorf("failed to read password: %w", err) + } + password = string(bytes) } - logger.Log(). - Str("name", out.Name). - Str("email", out.Email). - Bool("success", true). - Send() + var lastErr error - return nil + return retry.Do(func() error { + if errors.Is(lastErr, appstore.ErrAuthCodeRequired) && interactive { + dependencies.Logger.Log().Msg("enter 2FA code:") + + var err error + authCode, err = promptForAuthCode() + if err != nil { + return fmt.Errorf("failed to read auth code: %w", err) + } + } + + dependencies.Logger.Verbose(). + Str("password", password). + Str("email", email). + Str("authCode", util.IfEmpty(authCode, "")). + Msg("logging in") + + output, err := dependencies.AppStore.Login(appstore.LoginInput{ + Email: email, + Password: password, + AuthCode: authCode, + }) + if err != nil { + if errors.Is(err, appstore.ErrAuthCodeRequired) && !interactive { + dependencies.Logger.Log().Msg("2FA code is required; run the command again and supply a code using the `--auth-code` flag") + return nil + } + + return err + } + + dependencies.Logger.Log(). + Str("name", output.Account.Name). + Str("email", output.Account.Email). + Bool("success", true). + Send() + + return nil + }, + retry.LastErrorOnly(true), + retry.DelayType(retry.FixedDelay), + retry.Delay(time.Millisecond), + retry.Attempts(2), + retry.RetryIf(func(err error) bool { + lastErr = err + return errors.Is(err, appstore.ErrAuthCodeRequired) + }), + ) }, } @@ -76,20 +137,14 @@ func infoCmd() *cobra.Command { Use: "info", Short: "Show current account info", RunE: func(cmd *cobra.Command, args []string) error { - appstore, err := newAppStore(cmd, keychainPassphrase) - if err != nil { - return errors.Wrap(err, "failed to create appstore client") - } - - out, err := appstore.Info() + output, err := dependencies.AppStore.AccountInfo() if err != nil { return err } - logger := cmd.Context().Value("logger").(log.Logger) - logger.Log(). - Str("name", out.Name). - Str("email", out.Email). + dependencies.Logger.Log(). + Str("name", output.Account.Name). + Str("email", output.Account.Email). Bool("success", true). Send() @@ -103,19 +158,12 @@ func revokeCmd() *cobra.Command { Use: "revoke", Short: "Revoke your App Store credentials", RunE: func(cmd *cobra.Command, args []string) error { - appstore, err := newAppStore(cmd, keychainPassphrase) - if err != nil { - return errors.Wrap(err, "failed to create appstore client") - } - - err = appstore.Revoke() + err := dependencies.AppStore.Revoke() if err != nil { return err } - logger := cmd.Context().Value("logger").(log.Logger) - logger.Log().Bool("success", true).Send() - + dependencies.Logger.Log().Bool("success", true).Send() return nil }, } diff --git a/cmd/common.go b/cmd/common.go index 2f4c0e7f..a3ee285e 100644 --- a/cmd/common.go +++ b/cmd/common.go @@ -1,13 +1,17 @@ package cmd import ( + "errors" + "fmt" "github.com/99designs/keyring" "github.com/juju/persistent-cookiejar" "github.com/majd/ipatool/pkg/appstore" + "github.com/majd/ipatool/pkg/http" "github.com/majd/ipatool/pkg/keychain" "github.com/majd/ipatool/pkg/log" "github.com/majd/ipatool/pkg/util" - "github.com/pkg/errors" + "github.com/majd/ipatool/pkg/util/machine" + "github.com/majd/ipatool/pkg/util/operatingsystem" "github.com/rs/zerolog" "github.com/spf13/cobra" "golang.org/x/exp/slices" @@ -18,60 +22,64 @@ import ( "strings" ) -func newCookieJar() (*cookiejar.Jar, error) { - machine := util.NewMachine(util.MachineArgs{ - OperatingSystem: util.NewOperatingSystem(), - }) - jar, err := cookiejar.New(&cookiejar.Options{ - Filename: filepath.Join(machine.HomeDirectory(), ConfigDirectoryName, CookieJarFileName), - }) - if err != nil { - return nil, errors.Wrap(err, "failed to create cookie jar") - } - - return jar, nil +var dependencies = Dependencies{} +var keychainPassphrase string + +type Dependencies struct { + Logger log.Logger + OS operatingsystem.OperatingSystem + Machine machine.Machine + CookieJar http.CookieJar + Keychain keychain.Keychain + AppStore appstore.AppStore + KeyringBackendType keyring.BackendType } -func keyringBackendType() keyring.BackendType { - allowedBackends := []keyring.BackendType{ - keyring.KeychainBackend, - keyring.SecretServiceBackend, - } - - for _, backend := range allowedBackends { - if slices.Contains(keyring.AvailableBackends(), backend) { - return backend - } +// newLogger returns a new logger instance. +func newLogger(format OutputFormat, verbose bool) log.Logger { + var writer io.Writer + switch format { + case OutputFormatJSON: + writer = zerolog.SyncWriter(os.Stdout) + case OutputFormatText: + writer = log.NewWriter() } - - return keyring.FileBackend + return log.NewLogger(log.Args{ + Verbose: verbose, + Writer: writer, + }) } -func newKeyring(logger log.Logger, passphrase string, interactive bool) (keyring.Keyring, error) { - machine := util.NewMachine(util.MachineArgs{ - OperatingSystem: util.NewOperatingSystem(), - }) +// newCookieJar returns a new cookie jar instance. +func newCookieJar(machine machine.Machine) http.CookieJar { + path := filepath.Join(machine.HomeDirectory(), ConfigDirectoryName, CookieJarFileName) + return util.Must(cookiejar.New(&cookiejar.Options{ + Filename: path, + })) +} - ring, err := keyring.Open(keyring.Config{ +// newKeychain returns a new keychain instance. +func newKeychain(machine machine.Machine, logger log.Logger, backendType keyring.BackendType, interactive bool) keychain.Keychain { + ring := util.Must(keyring.Open(keyring.Config{ AllowedBackends: []keyring.BackendType{ - keyringBackendType(), + backendType, }, ServiceName: KeychainServiceName, FileDir: filepath.Join(machine.HomeDirectory(), ConfigDirectoryName), FilePasswordFunc: func(s string) (string, error) { - if passphrase == "" && !interactive { + if keychainPassphrase == "" && !interactive { return "", errors.New("keychain passphrase is required when not running in interactive mode; use the \"--keychain-passphrase\" flag") } - if passphrase != "" { - return passphrase, nil + if keychainPassphrase != "" { + return keychainPassphrase, nil } path := strings.Split(s, " unlock ")[1] logger.Log().Msgf("enter passphrase to unlock %s (this is separate from your Apple ID password): ", path) bytes, err := term.ReadPassword(int(os.Stdin.Fd())) if err != nil { - return "", errors.Wrap(err, "failed to read password") + return "", fmt.Errorf("failed to read password: %w", err) } password := string(bytes) @@ -80,90 +88,60 @@ func newKeyring(logger log.Logger, passphrase string, interactive bool) (keyring return password, nil }, - }) - if err != nil { - return nil, errors.Wrap(err, "failed to open keyring") - } - - return ring, nil + })) + return keychain.New(keychain.Args{Keyring: ring}) } -func configureConfigDirectory() error { - os := util.NewOperatingSystem() - machine := util.NewMachine(util.MachineArgs{ - OperatingSystem: os, - }) +// keyringBackendType returns the backend type for the keyring. +func keyringBackendType() keyring.BackendType { + allowedBackends := []keyring.BackendType{ + keyring.KeychainBackend, + keyring.SecretServiceBackend, + } - configDirectoryPath := filepath.Join(machine.HomeDirectory(), ConfigDirectoryName) - _, err := os.Stat(configDirectoryPath) - if err != nil && os.IsNotExist(err) { - err = os.MkdirAll(configDirectoryPath, 0700) - if err != nil { - return errors.Wrap(err, "failed to create config directory") + for _, backend := range allowedBackends { + if slices.Contains(keyring.AvailableBackends(), backend) { + return backend } - } else if err != nil { - return errors.Wrap(err, "could not read metadata") } - return nil -} - -func parseOutputFormat(value string) (OutputFormat, error) { - switch value { - case "json": - return OutputFormatJSON, nil - case "text": - return OutputFormatText, nil - default: - return OutputFormatJSON, errors.Errorf("invalid output format: %s", value) - } + return keyring.FileBackend } -func newLogger(format OutputFormat, verbose bool) log.Logger { - var writer io.Writer - - switch format { - case OutputFormatJSON: - writer = zerolog.SyncWriter(os.Stdout) - case OutputFormatText: - writer = log.NewWriter() - } - - return log.NewLogger(log.LoggerArgs{ - Verbose: verbose, - Writer: writer, +// initWithCommand initializes the dependencies of the command. +func initWithCommand(cmd *cobra.Command) { + verbose := cmd.Flag("verbose").Value.String() == "true" + interactive, _ := cmd.Context().Value("interactive").(bool) + format := util.Must(OutputFormatFromString(cmd.Flag("format").Value.String())) + + dependencies.Logger = newLogger(format, verbose) + dependencies.OS = operatingsystem.New() + dependencies.Machine = machine.New(machine.Args{OS: dependencies.OS}) + dependencies.CookieJar = newCookieJar(dependencies.Machine) + dependencies.KeyringBackendType = keyringBackendType() + dependencies.Keychain = newKeychain(dependencies.Machine, dependencies.Logger, dependencies.KeyringBackendType, interactive) + dependencies.AppStore = appstore.NewAppStore(appstore.Args{ + CookieJar: dependencies.CookieJar, + OperatingSystem: dependencies.OS, + Keychain: dependencies.Keychain, + Machine: dependencies.Machine, }) -} -func newAppStore( - cmd *cobra.Command, - keychainPassphrase string, -) (appstore.AppStore, error) { - logger := cmd.Context().Value("logger").(log.Logger) - interactive := cmd.Context().Value("interactive").(bool) - - cookieJar, err := newCookieJar() - if err != nil { - return nil, errors.Wrap(err, "failed to create cookie jar") - } - - os := util.NewOperatingSystem() + util.Must("", createConfigDirectory()) +} - keyring, err := newKeyring(logger, keychainPassphrase, interactive) - if err != nil { - return nil, errors.Wrap(err, "failed to create keyring") +// createConfigDirectory creates the configuration directory for the CLI tool, if needed. +func createConfigDirectory() error { + configDirectoryPath := filepath.Join(dependencies.Machine.HomeDirectory(), ConfigDirectoryName) + _, err := dependencies.OS.Stat(configDirectoryPath) + if err != nil && dependencies.OS.IsNotExist(err) { + err = dependencies.OS.MkdirAll(configDirectoryPath, 0700) + if err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + } else if err != nil { + return fmt.Errorf("could not read metadata: %w", err) } - return appstore.NewAppStore(appstore.AppStoreArgs{ - Logger: logger, - CookieJar: cookieJar, - Keychain: keychain.NewKeychain(keychain.KeychainArgs{ - Keyring: keyring, - }), - Interactive: interactive, - Machine: util.NewMachine(util.MachineArgs{ - OperatingSystem: os, - }), - OperatingSystem: os, - }), nil + return nil } diff --git a/cmd/constants.go b/cmd/constants.go index a8ec869d..518f0d1f 100644 --- a/cmd/constants.go +++ b/cmd/constants.go @@ -1,14 +1,5 @@ package cmd -import "github.com/thediveo/enumflag/v2" - -type OutputFormat enumflag.Flag - -const ( - OutputFormatText OutputFormat = iota - OutputFormatJSON -) - const ( ConfigDirectoryName = ".ipatool" CookieJarFileName = "cookies" diff --git a/cmd/download.go b/cmd/download.go index a09aa0ef..aaccd22e 100644 --- a/cmd/download.go +++ b/cmd/download.go @@ -1,14 +1,17 @@ package cmd import ( + "errors" "github.com/99designs/keyring" - "github.com/majd/ipatool/pkg/log" - "github.com/pkg/errors" + "github.com/avast/retry-go" + "github.com/majd/ipatool/pkg/appstore" + "github.com/schollz/progressbar/v3" "github.com/spf13/cobra" + "os" + "time" ) func downloadCmd() *cobra.Command { - var keychainPassphrase string var acquireLicense bool var outputPath string var bundleID string @@ -17,20 +20,88 @@ func downloadCmd() *cobra.Command { Use: "download", Short: "Download (encrypted) iOS app packages from the App Store", RunE: func(cmd *cobra.Command, args []string) error { - appstore, err := newAppStore(cmd, keychainPassphrase) - if err != nil { - return errors.Wrap(err, "failed to create appstore client") - } + var lastErr error + var acc appstore.Account - out, err := appstore.Download(bundleID, outputPath, acquireLicense) - if err != nil { - return err - } + return retry.Do(func() error { + infoResult, err := dependencies.AppStore.AccountInfo() + if err != nil { + return err + } - logger := cmd.Context().Value("logger").(log.Logger) - logger.Log().Str("output", out.DestinationPath).Bool("success", true).Send() + acc = infoResult.Account - return nil + if errors.Is(lastErr, appstore.ErrPasswordTokenExpired) { + loginResult, err := dependencies.AppStore.Login(appstore.LoginInput{Email: acc.Email, Password: acc.Password}) + if err != nil { + return err + } + + acc = loginResult.Account + } + + lookupResult, err := dependencies.AppStore.Lookup(appstore.LookupInput{Account: acc, BundleID: bundleID}) + if err != nil { + return err + } + + if errors.Is(lastErr, appstore.ErrLicenseRequired) { + err := dependencies.AppStore.Purchase(appstore.PurchaseInput{Account: acc, App: lookupResult.App}) + if err != nil { + return err + } + } + + progress := progressbar.NewOptions64(1, + progressbar.OptionSetDescription("downloading"), + progressbar.OptionSetWriter(os.Stdout), + progressbar.OptionShowBytes(true), + progressbar.OptionSetWidth(20), + progressbar.OptionFullWidth(), + progressbar.OptionThrottle(65*time.Millisecond), + progressbar.OptionShowCount(), + progressbar.OptionClearOnFinish(), + progressbar.OptionSpinnerType(14), + progressbar.OptionSetRenderBlankState(true), + progressbar.OptionSetElapsedTime(false), + progressbar.OptionSetPredictTime(false), + ) + + out, err := dependencies.AppStore.Download(appstore.DownloadInput{Account: acc, App: lookupResult.App, OutputPath: outputPath, Progress: progress}) + if err != nil { + return err + } + + err = dependencies.AppStore.ReplicateSinf(appstore.ReplicateSinfInput{Sinfs: out.Sinfs, PackagePath: out.DestinationPath}) + if err != nil { + return err + } + + dependencies.Logger.Log(). + Str("output", out.DestinationPath). + Bool("success", true). + Send() + + return nil + }, + retry.LastErrorOnly(true), + retry.DelayType(retry.FixedDelay), + retry.Delay(time.Millisecond), + retry.Attempts(2), + retry.RetryIf(func(err error) bool { + lastErr = err + + if errors.Is(err, appstore.ErrPasswordTokenExpired) { + return true + } + + if errors.Is(err, appstore.ErrLicenseRequired) && acquireLicense { + return true + } + + return false + }), + ) }, } diff --git a/cmd/output_format.go b/cmd/output_format.go new file mode 100644 index 00000000..1ed9d5e4 --- /dev/null +++ b/cmd/output_format.go @@ -0,0 +1,24 @@ +package cmd + +import ( + "fmt" + "github.com/thediveo/enumflag/v2" +) + +type OutputFormat enumflag.Flag + +const ( + OutputFormatText OutputFormat = iota + OutputFormatJSON +) + +func OutputFormatFromString(value string) (OutputFormat, error) { + switch value { + case "json": + return OutputFormatJSON, nil + case "text": + return OutputFormatText, nil + default: + return OutputFormatJSON, fmt.Errorf("invalid output format '%s'", value) + } +} diff --git a/cmd/purchase.go b/cmd/purchase.go index 56b71ca5..37c1a682 100644 --- a/cmd/purchase.go +++ b/cmd/purchase.go @@ -1,34 +1,63 @@ package cmd import ( + "errors" "github.com/99designs/keyring" - "github.com/majd/ipatool/pkg/log" - "github.com/pkg/errors" + "github.com/avast/retry-go" + "github.com/majd/ipatool/pkg/appstore" "github.com/spf13/cobra" + "time" ) func purchaseCmd() *cobra.Command { - var keychainPassphrase string var bundleID string cmd := &cobra.Command{ Use: "purchase", Short: "Obtain a license for the app from the App Store", RunE: func(cmd *cobra.Command, args []string) error { - appstore, err := newAppStore(cmd, keychainPassphrase) - if err != nil { - return errors.Wrap(err, "failed to create appstore client") - } + var lastErr error + var acc appstore.Account - err = appstore.Purchase(bundleID) - if err != nil { - return err - } + return retry.Do(func() error { + infoResult, err := dependencies.AppStore.AccountInfo() + if err != nil { + return err + } - logger := cmd.Context().Value("logger").(log.Logger) - logger.Log().Bool("success", true).Send() + acc = infoResult.Account - return nil + if errors.Is(lastErr, appstore.ErrPasswordTokenExpired) { + loginResult, err := dependencies.AppStore.Login(appstore.LoginInput{Email: acc.Email, Password: acc.Password}) + if err != nil { + return err + } + + acc = loginResult.Account + } + + lookupResult, err := dependencies.AppStore.Lookup(appstore.LookupInput{Account: acc, BundleID: bundleID}) + if err != nil { + return err + } + + err = dependencies.AppStore.Purchase(appstore.PurchaseInput{Account: acc, App: lookupResult.App}) + if err != nil { + return err + } + + dependencies.Logger.Log().Bool("success", true).Send() + return nil + }, + retry.LastErrorOnly(true), + retry.DelayType(retry.FixedDelay), + retry.Delay(time.Millisecond), + retry.Attempts(2), + retry.RetryIf(func(err error) bool { + lastErr = err + return errors.Is(err, appstore.ErrPasswordTokenExpired) + }), + ) }, } diff --git a/cmd/root.go b/cmd/root.go index 69b11bf0..b9ba7ee9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,10 +1,12 @@ package cmd import ( - "github.com/pkg/errors" + "errors" + "github.com/majd/ipatool/pkg/appstore" "github.com/spf13/cobra" "github.com/thediveo/enumflag/v2" "golang.org/x/net/context" + "reflect" ) var version = "dev" @@ -20,17 +22,10 @@ func rootCmd() *cobra.Command { SilenceErrors: true, SilenceUsage: true, Version: version, - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - ctx := context.WithValue(context.Background(), "logger", newLogger(format, verbose)) - ctx = context.WithValue(ctx, "interactive", nonInteractive == false) + PersistentPreRun: func(cmd *cobra.Command, args []string) { + ctx := context.WithValue(context.Background(), "interactive", nonInteractive == false) cmd.SetContext(ctx) - - err := configureConfigDirectory() - if err != nil { - return errors.Wrap(err, "failed to configure config directory") - } - - return nil + initWithCommand(cmd) }, } @@ -57,17 +52,21 @@ func Execute() (exitCode int) { if err != nil { exitCode = 1 - logger := newLogger(OutputFormatText, false) - outputFormat, parseErr := parseOutputFormat(cmd.Flag("format").Value.String()) - if parseErr != nil { - logger.Error().Err(parseErr).Send() - return + if reflect.ValueOf(dependencies).IsZero() { + initWithCommand(cmd) } - logger = newLogger(outputFormat, cmd.Flag("verbose").Value.String() == "true") + var appstoreErr *appstore.Error + if errors.As(err, &appstoreErr) { + dependencies.Logger.Verbose().Stack(). + Err(err). + Interface("metadata", appstoreErr.Metadata). + Send() + } else { + dependencies.Logger.Verbose().Stack().Err(err).Send() + } - logger.Verbose().Stack().Err(err).Send() - logger.Error(). + dependencies.Logger.Error(). Err(err). Bool("success", false). Send() diff --git a/cmd/search.go b/cmd/search.go index 5124fa29..dde1d01d 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -3,13 +3,10 @@ package cmd import ( "github.com/99designs/keyring" "github.com/majd/ipatool/pkg/appstore" - "github.com/majd/ipatool/pkg/log" - "github.com/pkg/errors" "github.com/spf13/cobra" ) func searchCmd() *cobra.Command { - var keychainPassphrase string var limit int64 cmd := &cobra.Command{ @@ -17,18 +14,24 @@ func searchCmd() *cobra.Command { Short: "Search for iOS apps available on the App Store", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - store, err := newAppStore(cmd, keychainPassphrase) + infoResult, err := dependencies.AppStore.AccountInfo() if err != nil { - return errors.Wrap(err, "failed to create appstore client") + return err } - out, err := store.Search(args[0], limit) + output, err := dependencies.AppStore.Search(appstore.SearchInput{ + Account: infoResult.Account, + Term: args[0], + Limit: limit, + }) if err != nil { return err } - logger := cmd.Context().Value("logger").(log.Logger) - logger.Log().Int("count", out.Count).Array("apps", appstore.Apps(out.Results)).Send() + dependencies.Logger.Log(). + Int("count", output.Count). + Array("apps", appstore.Apps(output.Results)). + Send() return nil }, diff --git a/go.mod b/go.mod index 33a6e956..46ef0807 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,10 @@ go 1.19 require ( github.com/99designs/keyring v1.2.1 - github.com/golang/mock v1.6.0 + github.com/golang/mock v1.7.0-rc.1 github.com/juju/persistent-cookiejar v1.0.0 github.com/onsi/ginkgo/v2 v2.5.0 github.com/onsi/gomega v1.24.0 - github.com/pkg/errors v0.9.1 github.com/rs/zerolog v1.28.0 github.com/spf13/cobra v1.6.1 github.com/thediveo/enumflag/v2 v2.0.1 @@ -20,6 +19,7 @@ require ( require ( github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect + github.com/avast/retry-go v3.0.0+incompatible // indirect github.com/danieljoos/wincred v1.1.2 // indirect github.com/dvsekhvalnov/jose2go v1.5.0 // indirect github.com/go-logr/logr v1.2.3 // indirect @@ -29,15 +29,18 @@ require ( github.com/inconshreveable/mousetrap v1.0.1 // indirect github.com/juju/go4 v0.0.0-20160222163258-40d72ab9641a // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.16 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mtibben/percent v0.2.1 // indirect - github.com/rivo/uniseg v0.4.3 // indirect - github.com/schollz/progressbar/v3 v3.12.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/schollz/progressbar/v3 v3.13.1 // indirect github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/mod v0.8.0 // indirect golang.org/x/sys v0.6.0 // indirect golang.org/x/text v0.8.0 // indirect + golang.org/x/tools v0.6.0 // indirect gopkg.in/errgo.v1 v1.0.1 // indirect gopkg.in/retry.v1 v1.0.3 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 4a74fd05..dd8cee6d 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMb github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= github.com/99designs/keyring v1.2.1 h1:tYLp1ULvO7i3fI5vE21ReQuj99QFSs7lGm0xWyJo87o= github.com/99designs/keyring v1.2.1/go.mod h1:fc+wB5KTk9wQ9sDx0kFXB3A0MaeGHM9AwRStKOQ5vOA= +github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0= +github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0= @@ -20,6 +22,8 @@ github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U= +github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/google/go-cmp v0.2.1-0.20190312032427-6f77996f0c42/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= @@ -45,6 +49,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= @@ -61,18 +67,16 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw= -github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/clock v0.0.0-20190514195947-2896927a307a h1:3QH7VyOaaiUHNrA9Se4YQIRkDTCw1EJls9xTUCaCeRM= github.com/rogpeppe/clock v0.0.0-20190514195947-2896927a307a/go.mod h1:4r5QyqhjIWCcK8DO4KMclc5Iknq5qVBAlbYYzAbUScQ= github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY= github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/schollz/progressbar/v3 v3.12.1 h1:JAhtIrLWAn6/p7i82SrpSG3fgAwlAxi+Sy12r4AzBvQ= -github.com/schollz/progressbar/v3 v3.12.1/go.mod h1:g7QSuwyGpqCjVQPFZXA31MSxtrhka9Y9LMdF+XT77/Y= +github.com/schollz/progressbar/v3 v3.13.1 h1:o8rySDYiQ59Mwzy2FELeHY5ZARXZTVJC7iHD6PEFUiE= +github.com/schollz/progressbar/v3 v3.13.1/go.mod h1:xvrbki8kfT1fzWzBT/UZd9L6GA+jdL7HAgq2RFnO6fQ= github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= @@ -86,16 +90,19 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/thediveo/enumflag/v2 v2.0.1 h1:2bmWZPD2uSARDsOjXIdLRlNcYBFNF9xX0RNUNF2vKic= github.com/thediveo/enumflag/v2 v2.0.1/go.mod h1:SyxyCNvv0QeRtZ7fjuaUz4FRLC3cWuDiD7QdORU0MGg= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e h1:+WEEuIdZHnUeJJmEUjyYC2gfUMj69yZXw17EnHg/otA= golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= +golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0= -golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -104,34 +111,30 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A= -golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= -golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.2.0 h1:z85xZCsEl7bi/KwbNADeBYoOP0++7W1ipu+aGnpwzRM= -golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= +golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/appstore/appstore.go b/pkg/appstore/appstore.go index 27f1900c..21b15c96 100644 --- a/pkg/appstore/appstore.go +++ b/pkg/appstore/appstore.go @@ -3,61 +3,61 @@ package appstore import ( "github.com/majd/ipatool/pkg/http" "github.com/majd/ipatool/pkg/keychain" - "github.com/majd/ipatool/pkg/log" - "github.com/majd/ipatool/pkg/util" - "io" - "os" + "github.com/majd/ipatool/pkg/util/machine" + "github.com/majd/ipatool/pkg/util/operatingsystem" ) type AppStore interface { - Login(email, password, authCode string) (LoginOutput, error) - Info() (InfoOutput, error) + // Login authenticates with the App Store. + Login(input LoginInput) (LoginOutput, error) + // AccountInfo returns the information of the authenticated account. + AccountInfo() (AccountInfoOutput, error) + // Revoke revokes the active credentials. Revoke() error - Lookup(bundleID string) (LookupOutput, error) - Search(term string, limit int64) (SearchOutput, error) - Purchase(bundleID string) error - Download(bundleID string, outputPath string, acquireLicense bool) (DownloadOutput, error) + // Lookup looks apps up based on the specified bundle identifier. + Lookup(input LookupInput) (LookupOutput, error) + // Search searches the App Store for apps matching the specified term. + Search(input SearchInput) (SearchOutput, error) + // Purchase acquires a license for the desired app. + // Note: only free apps are supported. + Purchase(input PurchaseInput) error + // Download downloads the IPA package from the App Store to the desired location. + Download(input DownloadInput) (DownloadOutput, error) + // ReplicateSinf replicates the sinf for the IPA package. + ReplicateSinf(input ReplicateSinfInput) error } type appstore struct { keychain keychain.Keychain - loginClient http.Client[LoginResult] - searchClient http.Client[SearchResult] - purchaseClient http.Client[PurchaseResult] - downloadClient http.Client[DownloadResult] + loginClient http.Client[loginResult] + searchClient http.Client[searchResult] + purchaseClient http.Client[purchaseResult] + downloadClient http.Client[downloadResult] httpClient http.Client[interface{}] - ioReader io.Reader - machine util.Machine - os util.OperatingSystem - logger log.Logger - interactive bool + machine machine.Machine + os operatingsystem.OperatingSystem } -type AppStoreArgs struct { +type Args struct { Keychain keychain.Keychain CookieJar http.CookieJar - Logger log.Logger - OperatingSystem util.OperatingSystem - Machine util.Machine - Interactive bool + OperatingSystem operatingsystem.OperatingSystem + Machine machine.Machine } -func NewAppStore(args AppStoreArgs) AppStore { - clientArgs := http.ClientArgs{ +func NewAppStore(args Args) AppStore { + clientArgs := http.Args{ CookieJar: args.CookieJar, } return &appstore{ keychain: args.Keychain, - loginClient: http.NewClient[LoginResult](clientArgs), - searchClient: http.NewClient[SearchResult](clientArgs), - purchaseClient: http.NewClient[PurchaseResult](clientArgs), - downloadClient: http.NewClient[DownloadResult](clientArgs), + loginClient: http.NewClient[loginResult](clientArgs), + searchClient: http.NewClient[searchResult](clientArgs), + purchaseClient: http.NewClient[purchaseResult](clientArgs), + downloadClient: http.NewClient[downloadResult](clientArgs), httpClient: http.NewClient[interface{}](clientArgs), - ioReader: os.Stdin, machine: args.Machine, os: args.OperatingSystem, - logger: args.Logger, - interactive: args.Interactive, } } diff --git a/pkg/appstore/appstore_account_info.go b/pkg/appstore/appstore_account_info.go new file mode 100644 index 00000000..38d5c649 --- /dev/null +++ b/pkg/appstore/appstore_account_info.go @@ -0,0 +1,27 @@ +package appstore + +import ( + "encoding/json" + "fmt" +) + +type AccountInfoOutput struct { + Account Account +} + +func (t *appstore) AccountInfo() (AccountInfoOutput, error) { + data, err := t.keychain.Get("account") + if err != nil { + return AccountInfoOutput{}, fmt.Errorf("failed to get account: %w", err) + } + + var acc Account + err = json.Unmarshal(data, &acc) + if err != nil { + return AccountInfoOutput{}, fmt.Errorf("failed to unmarshal json: %w", err) + } + + return AccountInfoOutput{ + Account: acc, + }, nil +} diff --git a/pkg/appstore/appstore_info_test.go b/pkg/appstore/appstore_account_info_test.go similarity index 68% rename from pkg/appstore/appstore_info_test.go rename to pkg/appstore/appstore_account_info_test.go index d9e30336..166e840b 100644 --- a/pkg/appstore/appstore_info_test.go +++ b/pkg/appstore/appstore_account_info_test.go @@ -1,15 +1,15 @@ package appstore import ( + "errors" "fmt" "github.com/golang/mock/gomock" "github.com/majd/ipatool/pkg/keychain" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/pkg/errors" ) -var _ = Describe("AppStore (Info)", func() { +var _ = Describe("AppStore (AccountInfo)", func() { var ( ctrl *gomock.Controller appstore AppStore @@ -19,7 +19,7 @@ var _ = Describe("AppStore (Info)", func() { BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) mockKeychain = keychain.NewMockKeychain(ctrl) - appstore = NewAppStore(AppStoreArgs{ + appstore = NewAppStore(Args{ Keychain: mockKeychain, }) }) @@ -28,52 +28,49 @@ var _ = Describe("AppStore (Info)", func() { ctrl.Finish() }) - When("keychain returns error", func() { - var testErr = errors.New("test error") + When("keychain returns valid data", func() { + const ( + testEmail = "test-email" + testName = "test-name" + ) BeforeEach(func() { mockKeychain.EXPECT(). Get("account"). - Return([]byte{}, testErr) + Return([]byte(fmt.Sprintf("{\"email\": \"%s\", \"name\": \"%s\"}", testEmail, testName)), nil) }) - It("returns wrapped error", func() { - _, err := appstore.Info() - Expect(err).To(MatchError(ContainSubstring(testErr.Error()))) - Expect(err).To(MatchError(ContainSubstring(ErrGetAccount.Error()))) + It("returns output", func() { + out, err := appstore.AccountInfo() + Expect(err).ToNot(HaveOccurred()) + Expect(out.Account.Email).To(Equal(testEmail)) + Expect(out.Account.Name).To(Equal(testName)) }) }) - When("keychain returns invalid data", func() { + When("keychain returns error", func() { BeforeEach(func() { mockKeychain.EXPECT(). Get("account"). - Return([]byte("..."), nil) + Return([]byte{}, errors.New("")) }) - It("fails to unmarshall JSON data", func() { - _, err := appstore.Info() - Expect(err).To(MatchError(ContainSubstring(ErrUnmarshal.Error()))) + It("returns wrapped error", func() { + _, err := appstore.AccountInfo() + Expect(err).To(HaveOccurred()) }) }) - When("keychain returns valid data", func() { - const ( - testEmail = "test-email" - testName = "test-name" - ) - + When("keychain returns invalid data", func() { BeforeEach(func() { mockKeychain.EXPECT(). Get("account"). - Return([]byte(fmt.Sprintf("{\"email\": \"%s\", \"name\": \"%s\"}", testEmail, testName)), nil) + Return([]byte("..."), nil) }) - It("returns output", func() { - out, err := appstore.Info() - Expect(err).ToNot(HaveOccurred()) - Expect(out.Email).To(Equal(testEmail)) - Expect(out.Name).To(Equal(testName)) + It("fails to unmarshall JSON data", func() { + _, err := appstore.AccountInfo() + Expect(err).To(HaveOccurred()) }) }) }) diff --git a/pkg/appstore/appstore_download.go b/pkg/appstore/appstore_download.go index 2f6bef52..2c581c3c 100644 --- a/pkg/appstore/appstore_download.go +++ b/pkg/appstore/appstore_download.go @@ -2,221 +2,143 @@ package appstore import ( "archive/zip" - "bytes" + "errors" "fmt" "github.com/majd/ipatool/pkg/http" - "github.com/majd/ipatool/pkg/util" - "github.com/pkg/errors" "github.com/schollz/progressbar/v3" "howett.net/plist" "io" "os" - "path/filepath" "strings" - "time" ) -type DownloadSinfResult struct { - ID int64 `plist:"id,omitempty"` - Data []byte `plist:"sinf,omitempty"` -} - -type DownloadItemResult struct { - HashMD5 string `plist:"md5,omitempty"` - URL string `plist:"URL,omitempty"` - Sinfs []DownloadSinfResult `plist:"sinfs,omitempty"` - Metadata map[string]interface{} `plist:"metadata,omitempty"` -} - -type DownloadResult struct { - FailureType string `plist:"failureType,omitempty"` - CustomerMessage string `plist:"customerMessage,omitempty"` - Items []DownloadItemResult `plist:"songList,omitempty"` -} - -type PackageManifest struct { - SinfPaths []string `plist:"SinfPaths,omitempty"` -} +var ( + ErrLicenseRequired = errors.New("license is required") +) -type PackageInfo struct { - BundleExecutable string `plist:"CFBundleExecutable,omitempty"` +type DownloadInput struct { + Account Account + App App + OutputPath string + Progress *progressbar.ProgressBar } type DownloadOutput struct { DestinationPath string + Sinfs []Sinf } -func (a *appstore) Download(bundleID string, outputPath string, acquireLicense bool) (DownloadOutput, error) { - acc, err := a.account() - if err != nil { - return DownloadOutput{}, errors.Wrap(err, ErrGetAccount.Error()) - } - - countryCode, err := a.countryCodeFromStoreFront(acc.StoreFront) - if err != nil { - return DownloadOutput{}, errors.Wrap(err, ErrInvalidCountryCode.Error()) - } - - app, err := a.lookup(bundleID, countryCode) +func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) { + destination, err := t.resolveDestinationPath(input.App, input.OutputPath) if err != nil { - return DownloadOutput{}, errors.Wrap(err, ErrAppLookup.Error()) + return DownloadOutput{}, fmt.Errorf("failed to resolve destination path: %w", err) } - destination, err := a.resolveDestinationPath(app, outputPath) + macAddr, err := t.machine.MacAddress() if err != nil { - return DownloadOutput{}, errors.Wrap(err, ErrResolveDestinationPath.Error()) - } - - macAddr, err := a.machine.MacAddress() - if err != nil { - return DownloadOutput{}, errors.Wrap(err, ErrGetMAC.Error()) + return DownloadOutput{}, fmt.Errorf("failed to get mac address: %w", err) } guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "") - a.logger.Verbose().Str("mac", macAddr).Str("guid", guid).Send() - - err = a.download(acc, app, destination, guid, acquireLicense, true) - if err != nil { - return DownloadOutput{}, errors.Wrap(err, ErrDownloadFile.Error()) - } - - return DownloadOutput{ - DestinationPath: destination, - }, nil -} -func (a *appstore) download(acc Account, app App, dst, guid string, acquireLicense, attemptToRenewCredentials bool) error { - req := a.downloadRequest(acc, app, guid) + req := t.downloadRequest(input.Account, input.App, guid) - res, err := a.downloadClient.Send(req) + res, err := t.downloadClient.Send(req) if err != nil { - return errors.Wrap(err, ErrRequest.Error()) + return DownloadOutput{}, fmt.Errorf("failed to send http request: %w", err) } if res.Data.FailureType == FailureTypePasswordTokenExpired { - if attemptToRenewCredentials { - a.logger.Verbose().Msg("retrieving new password token") - acc, err = a.login(acc.Email, acc.Password, "", guid, 0, true) - if err != nil { - return errors.Wrap(err, ErrPasswordTokenExpired.Error()) - } - - return a.download(acc, app, dst, guid, acquireLicense, false) - } - - return ErrPasswordTokenExpired - } - - if res.Data.FailureType == FailureTypeLicenseNotFound && acquireLicense { - a.logger.Verbose().Msg("attempting to acquire license") - err = a.purchase(app.BundleID, guid, true) - if err != nil { - return errors.Wrap(err, ErrPurchase.Error()) - } - - return a.download(acc, app, dst, guid, false, attemptToRenewCredentials) + return DownloadOutput{}, ErrPasswordTokenExpired } if res.Data.FailureType == FailureTypeLicenseNotFound { - return ErrLicenseRequired + return DownloadOutput{}, ErrLicenseRequired } if res.Data.FailureType != "" && res.Data.CustomerMessage != "" { - a.logger.Verbose().Interface("response", res).Send() - return errors.New(res.Data.CustomerMessage) + return DownloadOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.CustomerMessage), res) } if res.Data.FailureType != "" { - a.logger.Verbose().Interface("response", res).Send() - return ErrGeneric + return DownloadOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.FailureType), res) } if len(res.Data.Items) == 0 { - a.logger.Verbose().Interface("response", res).Send() - return ErrInvalidResponse + return DownloadOutput{}, NewErrorWithMetadata(errors.New("invalid response"), res) } item := res.Data.Items[0] - err = a.downloadFile(fmt.Sprintf("%s.tmp", dst), item.URL) + err = t.downloadFile(item.URL, fmt.Sprintf("%s.tmp", destination), input.Progress) if err != nil { - return errors.Wrap(err, ErrDownloadFile.Error()) + return DownloadOutput{}, fmt.Errorf("failed to download file: %w", err) } - err = a.applyPatches(item, acc, fmt.Sprintf("%s.tmp", dst), dst) + err = t.applyPatches(item, input.Account, fmt.Sprintf("%s.tmp", destination), destination) if err != nil { - return errors.Wrap(err, ErrPatchApp.Error()) + return DownloadOutput{}, fmt.Errorf("failed to apply patches: %w", err) } - err = a.os.Remove(fmt.Sprintf("%s.tmp", dst)) + err = t.os.Remove(fmt.Sprintf("%s.tmp", destination)) if err != nil { - return errors.Wrap(err, ErrRemoveTempFile.Error()) + return DownloadOutput{}, fmt.Errorf("failed to remove file: %w", err) } - return nil + return DownloadOutput{ + DestinationPath: destination, + Sinfs: item.Sinfs, + }, nil } -func (a *appstore) downloadFile(dst, sourceURL string) (err error) { - req, err := a.httpClient.NewRequest("GET", sourceURL, nil) +type downloadItemResult struct { + HashMD5 string `plist:"md5,omitempty"` + URL string `plist:"URL,omitempty"` + Sinfs []Sinf `plist:"sinfs,omitempty"` + Metadata map[string]interface{} `plist:"metadata,omitempty"` +} + +type downloadResult struct { + FailureType string `plist:"failureType,omitempty"` + CustomerMessage string `plist:"customerMessage,omitempty"` + Items []downloadItemResult `plist:"songList,omitempty"` +} + +func (t *appstore) downloadFile(src, dst string, progress *progressbar.ProgressBar) (err error) { + req, err := t.httpClient.NewRequest("GET", src, nil) if err != nil { - return errors.Wrap(err, ErrCreateRequest.Error()) + return fmt.Errorf("failed to create request: %w", err) } - res, err := a.httpClient.Do(req) + res, err := t.httpClient.Do(req) if err != nil { - return errors.Wrap(err, ErrRequest.Error()) + return fmt.Errorf("request failed: %w", err) } + defer res.Body.Close() - defer func() { - if closeErr := res.Body.Close(); closeErr != err && err == nil { - err = closeErr - } - }() - - file, err := a.os.OpenFile(dst, os.O_CREATE|os.O_WRONLY, 0644) + file, err := t.os.OpenFile(dst, os.O_CREATE|os.O_WRONLY, 0644) if err != nil { - return errors.Wrap(err, ErrOpenFile.Error()) + return fmt.Errorf("failed to open file: %w", err) } - defer func() { - if closeErr := file.Close(); closeErr != err && err == nil { - err = closeErr - } - }() - - sizeMB := float64(res.ContentLength) / (1 << 20) - a.logger.Verbose().Str("size", fmt.Sprintf("%.2fMB", sizeMB)).Msg("downloading") - - if a.interactive { - bar := progressbar.NewOptions64(res.ContentLength, - progressbar.OptionSetDescription("downloading"), - progressbar.OptionSetWriter(os.Stdout), - progressbar.OptionShowBytes(true), - progressbar.OptionSetWidth(20), - progressbar.OptionFullWidth(), - progressbar.OptionThrottle(65*time.Millisecond), - progressbar.OptionShowCount(), - progressbar.OptionClearOnFinish(), - progressbar.OptionSpinnerType(14), - progressbar.OptionSetRenderBlankState(true), - progressbar.OptionSetElapsedTime(false), - progressbar.OptionSetPredictTime(false), - ) - - _, err = io.Copy(io.MultiWriter(file, bar), res.Body) + defer file.Close() + + if progress != nil { + progress.ChangeMax64(res.ContentLength) + _, err = io.Copy(io.MultiWriter(file, progress), res.Body) } else { _, err = io.Copy(file, res.Body) } if err != nil { - return errors.Wrap(err, ErrFileWrite.Error()) + return fmt.Errorf("failed to write file: %w", err) } return nil } func (*appstore) downloadRequest(acc Account, app App, guid string) http.Request { - host := fmt.Sprintf("%s-%s", PriavteAppStoreAPIDomainPrefixWithoutAuthCode, PrivateAppStoreAPIDomain) + host := fmt.Sprintf("%s-%s", PrivateAppStoreAPIDomainPrefixWithoutAuthCode, PrivateAppStoreAPIDomain) return http.Request{ URL: fmt.Sprintf("https://%s%s?guid=%s", host, PrivateAppStoreAPIPathDownload, guid), Method: http.MethodPOST, @@ -243,21 +165,21 @@ func fileName(app App) string { app.Version) } -func (a *appstore) resolveDestinationPath(app App, path string) (string, error) { +func (t *appstore) resolveDestinationPath(app App, path string) (string, error) { file := fileName(app) if path == "" { - workdir, err := a.os.Getwd() + workdir, err := t.os.Getwd() if err != nil { - return "", errors.Wrap(err, ErrGetCurrentDirectory.Error()) + return "", fmt.Errorf("failed to get current directory: %w", err) } return fmt.Sprintf("%s/%s", workdir, file), nil } - isDir, err := a.isDirectory(path) + isDir, err := t.isDirectory(path) if err != nil { - return "", errors.Wrap(err, ErrCheckDirectory.Error()) + return "", fmt.Errorf("failed to determine whether path is a directory: %w", err) } if isDir { @@ -267,10 +189,10 @@ func (a *appstore) resolveDestinationPath(app App, path string) (string, error) return path, nil } -func (a *appstore) isDirectory(path string) (bool, error) { - info, err := a.os.Stat(path) +func (t *appstore) isDirectory(path string) (bool, error) { + info, err := t.os.Stat(path) if err != nil && !os.IsNotExist(err) { - return false, errors.Wrap(err, ErrGetFileMetadata.Error()) + return false, fmt.Errorf("failed to read file metadata: %w", err) } if info == nil { @@ -280,183 +202,51 @@ func (a *appstore) isDirectory(path string) (bool, error) { return info.IsDir(), nil } -func (a *appstore) applyPatches(item DownloadItemResult, acc Account, src, dst string) (err error) { - dstFile, err := a.os.OpenFile(dst, os.O_CREATE|os.O_WRONLY, 0644) +func (t *appstore) applyPatches(item downloadItemResult, acc Account, src, dst string) error { + srcZip, err := zip.OpenReader(src) if err != nil { - return errors.Wrap(err, ErrOpenFile.Error()) + return fmt.Errorf("failed to open zip reader: %w", err) } + defer srcZip.Close() - srcZip, err := zip.OpenReader(src) + dstFile, err := t.os.OpenFile(dst, os.O_CREATE|os.O_WRONLY, 0644) if err != nil { - return errors.Wrap(err, ErrOpenZipFile.Error()) + return fmt.Errorf("failed to open file: %w", err) } - defer func() { - if closeErr := srcZip.Close(); closeErr != err && err == nil { - err = closeErr - } - }() dstZip := zip.NewWriter(dstFile) - defer func() { - if closeErr := dstZip.Close(); closeErr != err && err == nil { - err = closeErr - } - }() - - manifestData := new(bytes.Buffer) - infoData := new(bytes.Buffer) + defer dstZip.Close() - appBundle, err := a.replicateZip(srcZip, dstZip, infoData, manifestData) + err = t.replicateZip(srcZip, dstZip) if err != nil { - return errors.Wrap(err, ErrReplicateZip.Error()) + return fmt.Errorf("failed to replicate zip: %w", err) } - err = a.writeMetadata(item.Metadata, acc, dstZip) + err = t.writeMetadata(item.Metadata, acc, dstZip) if err != nil { - return errors.Wrap(err, ErrWriteMetadataFile.Error()) - } - - if manifestData.Len() > 0 { - err = a.applySinfPatches(item, dstZip, manifestData.Bytes(), appBundle) - if err != nil { - return errors.Wrap(err, ErrApplyPatches.Error()) - } - } else { - err = a.applyLegacySinfPatches(item, dstZip, infoData.Bytes(), appBundle) - if err != nil { - return errors.Wrap(err, ErrApplyLegacyPatches.Error()) - } + return fmt.Errorf("failed to write metadata: %w", err) } return nil } -func (a *appstore) writeMetadata(metadata map[string]interface{}, acc Account, zip *zip.Writer) error { +func (t *appstore) writeMetadata(metadata map[string]interface{}, acc Account, zip *zip.Writer) error { metadata["apple-id"] = acc.Email metadata["userName"] = acc.Email metadataFile, err := zip.Create("iTunesMetadata.plist") if err != nil { - return errors.Wrap(err, ErrCreateMetadataFile.Error()) + return fmt.Errorf("failed to create file: %w", err) } data, err := plist.Marshal(metadata, plist.BinaryFormat) if err != nil { - return errors.Wrap(err, ErrEncodeMetadataFile.Error()) + return fmt.Errorf("failed to marshal data: %w", err) } _, err = metadataFile.Write(data) if err != nil { - return errors.Wrap(err, ErrWriteMetadataFile.Error()) - } - - return nil -} - -func (a *appstore) replicateZip(src *zip.ReadCloser, dst *zip.Writer, info *bytes.Buffer, manifest *bytes.Buffer) (appBundle string, err error) { - for _, file := range src.File { - srcFile, err := file.OpenRaw() - if err != nil { - return "", errors.Wrap(err, ErrOpenFile.Error()) - } - - if strings.HasSuffix(file.Name, ".app/SC_Info/Manifest.plist") { - srcFileD, err := file.Open() - if err != nil { - return "", errors.Wrap(err, ErrDecompressManifestFile.Error()) - } - - _, err = io.Copy(manifest, srcFileD) - if err != nil { - return "", errors.Wrap(err, ErrGetManifestFile.Error()) - } - } - - if strings.Contains(file.Name, ".app/Info.plist") { - srcFileD, err := file.Open() - if err != nil { - return "", errors.Wrap(err, ErrDecompressInfoFile.Error()) - } - - if !strings.Contains(file.Name, "/Watch/") { - appBundle = filepath.Base(strings.TrimSuffix(file.Name, ".app/Info.plist")) - } - - _, err = io.Copy(info, srcFileD) - if err != nil { - return "", errors.Wrap(err, ErrGetInfoFile.Error()) - } - } - - header := file.FileHeader - dstFile, err := dst.CreateRaw(&header) - if err != nil { - return "", errors.Wrap(err, ErrCreateDestinationFile.Error()) - } - - _, err = io.Copy(dstFile, srcFile) - if err != nil { - return "", errors.Wrap(err, ErrFileWrite.Error()) - } - } - - if appBundle == "" { - return "", ErrGetBundleName - } - - return appBundle, nil -} - -func (a *appstore) applySinfPatches(item DownloadItemResult, zip *zip.Writer, manifestData []byte, appBundle string) error { - var manifest PackageManifest - _, err := plist.Unmarshal(manifestData, &manifest) - if err != nil { - return errors.Wrap(err, ErrUnmarshal.Error()) - } - - zipped, err := util.Zip(item.Sinfs, manifest.SinfPaths) - if err != nil { - return errors.Wrap(err, ErrZipSinfs.Error()) - } - - for _, pair := range zipped { - sp := fmt.Sprintf("Payload/%s.app/%s", appBundle, pair.Second) - a.logger.Verbose().Str("path", sp).Msg("writing sinf data") - - file, err := zip.Create(sp) - if err != nil { - return errors.Wrap(err, ErrCreateSinfFile.Error()) - } - - _, err = file.Write(pair.First.Data) - if err != nil { - return errors.Wrap(err, ErrWriteSinfData.Error()) - } - } - - return nil -} - -func (a *appstore) applyLegacySinfPatches(item DownloadItemResult, zip *zip.Writer, infoData []byte, appBundle string) error { - a.logger.Verbose().Msg("applying legacy sinf patches") - - var info PackageInfo - _, err := plist.Unmarshal(infoData, &info) - if err != nil { - return errors.Wrap(err, ErrUnmarshal.Error()) - } - - sp := fmt.Sprintf("Payload/%s.app/SC_Info/%s.sinf", appBundle, info.BundleExecutable) - a.logger.Verbose().Str("path", sp).Msg("writing sinf data") - - file, err := zip.Create(sp) - if err != nil { - return errors.Wrap(err, ErrCreateSinfFile.Error()) - } - - _, err = file.Write(item.Sinfs[0].Data) - if err != nil { - return errors.Wrap(err, ErrWriteSinfData.Error()) + return fmt.Errorf("failed to write data: %w", err) } return nil diff --git a/pkg/appstore/appstore_download_test.go b/pkg/appstore/appstore_download_test.go index d9320027..c7084134 100644 --- a/pkg/appstore/appstore_download_test.go +++ b/pkg/appstore/appstore_download_test.go @@ -1,14 +1,14 @@ package appstore import ( - zip "archive/zip" + "archive/zip" "errors" "fmt" "github.com/golang/mock/gomock" "github.com/majd/ipatool/pkg/http" "github.com/majd/ipatool/pkg/keychain" - "github.com/majd/ipatool/pkg/log" - "github.com/majd/ipatool/pkg/util" + "github.com/majd/ipatool/pkg/util/machine" + "github.com/majd/ipatool/pkg/util/operatingsystem" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "howett.net/plist" @@ -22,40 +22,32 @@ var _ = Describe("AppStore (Download)", func() { var ( ctrl *gomock.Controller mockKeychain *keychain.MockKeychain - mockSearchClient *http.MockClient[SearchResult] - mockDownloadClient *http.MockClient[DownloadResult] - mockPurchaseClient *http.MockClient[PurchaseResult] - mockLoginClient *http.MockClient[LoginResult] + mockDownloadClient *http.MockClient[downloadResult] + mockPurchaseClient *http.MockClient[purchaseResult] + mockLoginClient *http.MockClient[loginResult] mockHTTPClient *http.MockClient[interface{}] - mockLogger *log.MockLogger - mockOS *util.MockOperatingSystem - mockMachine *util.MockMachine + mockOS *operatingsystem.MockOperatingSystem + mockMachine *machine.MockMachine as AppStore - testErr = errors.New("testErr") ) BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) mockKeychain = keychain.NewMockKeychain(ctrl) - mockLogger = log.NewMockLogger(ctrl) - mockSearchClient = http.NewMockClient[SearchResult](ctrl) - mockDownloadClient = http.NewMockClient[DownloadResult](ctrl) - mockLoginClient = http.NewMockClient[LoginResult](ctrl) - mockPurchaseClient = http.NewMockClient[PurchaseResult](ctrl) + mockDownloadClient = http.NewMockClient[downloadResult](ctrl) + mockLoginClient = http.NewMockClient[loginResult](ctrl) + mockPurchaseClient = http.NewMockClient[purchaseResult](ctrl) mockHTTPClient = http.NewMockClient[interface{}](ctrl) - mockOS = util.NewMockOperatingSystem(ctrl) - mockMachine = util.NewMockMachine(ctrl) + mockOS = operatingsystem.NewMockOperatingSystem(ctrl) + mockMachine = machine.NewMockMachine(ctrl) as = &appstore{ keychain: mockKeychain, loginClient: mockLoginClient, - searchClient: mockSearchClient, purchaseClient: mockPurchaseClient, downloadClient: mockDownloadClient, httpClient: mockHTTPClient, machine: mockMachine, os: mockOS, - logger: mockLogger, - interactive: true, } }) @@ -63,123 +55,40 @@ var _ = Describe("AppStore (Download)", func() { ctrl.Finish() }) - When("not logged in", func() { - BeforeEach(func() { - mockKeychain.EXPECT(). - Get("account"). - Return([]byte{}, testErr) - }) - - It("returns error", func() { - _, err := as.Download("", "", false) - Expect(err).To(MatchError(ContainSubstring(ErrGetAccount.Error()))) - }) - }) - - When("fails to determine country code", func() { - BeforeEach(func() { - mockKeychain.EXPECT(). - Get("account"). - Return([]byte("{\"storeFront\":\"\"}"), nil) - }) - - It("returns error", func() { - _, err := as.Download("", "", false) - Expect(err).To(MatchError(ContainSubstring(ErrInvalidCountryCode.Error()))) - }) - }) - - When("fails to find app", func() { - BeforeEach(func() { - mockKeychain.EXPECT(). - Get("account"). - Return([]byte("{\"storeFront\":\"143441\"}"), nil) - - mockSearchClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[SearchResult]{}, testErr) - }) - - It("returns error", func() { - _, err := as.Download("", "", false) - Expect(err).To(MatchError(ContainSubstring(ErrAppLookup.Error()))) - }) - }) - When("fails to resolve output path", func() { BeforeEach(func() { - mockKeychain.EXPECT(). - Get("account"). - Return([]byte("{\"storeFront\":\"143441\"}"), nil) - - mockSearchClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[SearchResult]{ - StatusCode: 200, - Data: SearchResult{ - Count: 1, - Results: []App{{}}, - }, - }, nil) - mockOS.EXPECT(). Stat(gomock.Any()). - Return(nil, testErr) + Return(nil, errors.New("")) }) It("returns error", func() { - _, err := as.Download("", "test-out", false) - Expect(err).To(MatchError(ContainSubstring("failed to resolve destination path"))) + _, err := as.Download(DownloadInput{ + OutputPath: "test-out", + }) + Expect(err).To(HaveOccurred()) }) }) When("fails to read MAC address", func() { BeforeEach(func() { - mockKeychain.EXPECT(). - Get("account"). - Return([]byte("{\"storeFront\":\"143441\"}"), nil) - - mockSearchClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[SearchResult]{ - StatusCode: 200, - Data: SearchResult{ - Count: 1, - Results: []App{{}}, - }, - }, nil) - mockOS.EXPECT(). Getwd(). Return("", nil) mockMachine.EXPECT(). MacAddress(). - Return("", testErr) + Return("", errors.New("")) }) It("returns error", func() { - _, err := as.Download("", "", false) - Expect(err).To(MatchError(ContainSubstring(ErrGetMAC.Error()))) + _, err := as.Download(DownloadInput{}) + Expect(err).To(HaveOccurred()) }) }) When("request fails", func() { BeforeEach(func() { - mockKeychain.EXPECT(). - Get("account"). - Return([]byte("{\"storeFront\":\"143441\"}"), nil) - - mockSearchClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[SearchResult]{ - StatusCode: 200, - Data: SearchResult{ - Count: 1, - Results: []App{{}}, - }, - }, nil) - mockOS.EXPECT(). Getwd(). Return("", nil) @@ -190,35 +99,17 @@ var _ = Describe("AppStore (Download)", func() { mockDownloadClient.EXPECT(). Send(gomock.Any()). - Return(http.Result[DownloadResult]{}, testErr) - - mockLogger.EXPECT(). - Verbose(). - Return(nil) + Return(http.Result[downloadResult]{}, errors.New("")) }) It("returns error", func() { - _, err := as.Download("", "", false) - Expect(err).To(MatchError(ContainSubstring(ErrRequest.Error()))) + _, err := as.Download(DownloadInput{}) + Expect(err).To(HaveOccurred()) }) }) When("password token is expired", func() { BeforeEach(func() { - mockKeychain.EXPECT(). - Get("account"). - Return([]byte("{\"storeFront\":\"143441\"}"), nil) - - mockSearchClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[SearchResult]{ - StatusCode: 200, - Data: SearchResult{ - Count: 1, - Results: []App{{}}, - }, - }, nil) - mockOS.EXPECT(). Getwd(). Return("", nil) @@ -229,83 +120,21 @@ var _ = Describe("AppStore (Download)", func() { mockDownloadClient.EXPECT(). Send(gomock.Any()). - Return(http.Result[DownloadResult]{ - Data: DownloadResult{ + Return(http.Result[downloadResult]{ + Data: downloadResult{ FailureType: FailureTypePasswordTokenExpired, }, }, nil) - - mockLogger.EXPECT(). - Verbose(). - Return(nil) }) - When("attempts to renew credentials", func() { - BeforeEach(func() { - mockLogger.EXPECT(). - Verbose(). - Return(nil). - Times(2) - }) - - When("fails to renew credentials", func() { - BeforeEach(func() { - mockLoginClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[LoginResult]{}, testErr) - }) - - It("returns error", func() { - _, err := as.Download("", "", false) - Expect(err).To(MatchError(ContainSubstring(ErrPasswordTokenExpired.Error()))) - }) - }) - - When("successfully renews credentials", func() { - BeforeEach(func() { - mockLoginClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[LoginResult]{ - Data: LoginResult{}, - }, nil) - - mockKeychain.EXPECT(). - Set("account", gomock.Any()). - Return(nil) - - mockDownloadClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[DownloadResult]{ - Data: DownloadResult{ - FailureType: FailureTypePasswordTokenExpired, - }, - }, nil) - }) - - It("attempts to download app", func() { - _, err := as.Download("", "", false) - Expect(err).To(MatchError(ContainSubstring(ErrPasswordTokenExpired.Error()))) - }) - }) + It("returns error", func() { + _, err := as.Download(DownloadInput{}) + Expect(err).To(HaveOccurred()) }) }) When("license is missing", func() { BeforeEach(func() { - mockKeychain.EXPECT(). - Get("account"). - Return([]byte("{\"storeFront\":\"143441\"}"), nil) - - mockSearchClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[SearchResult]{ - StatusCode: 200, - Data: SearchResult{ - Count: 1, - Results: []App{{}}, - }, - }, nil) - mockOS.EXPECT(). Getwd(). Return("", nil) @@ -316,97 +145,21 @@ var _ = Describe("AppStore (Download)", func() { mockDownloadClient.EXPECT(). Send(gomock.Any()). - Return(http.Result[DownloadResult]{ - Data: DownloadResult{ + Return(http.Result[downloadResult]{ + Data: downloadResult{ FailureType: FailureTypeLicenseNotFound, }, }, nil) - - mockLogger.EXPECT(). - Verbose(). - Return(nil) }) It("returns error", func() { - _, err := as.Download("", "", false) - Expect(err).To(MatchError(ContainSubstring(ErrLicenseRequired.Error()))) - }) - - When("attempts to acquire license", func() { - BeforeEach(func() { - mockLogger.EXPECT(). - Verbose(). - Return(nil) - }) - - When("license is acquired", func() { - BeforeEach(func() { - mockKeychain.EXPECT(). - Get("account"). - Return([]byte("{\"storeFront\":\"143441\"}"), nil) - - mockPurchaseClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[PurchaseResult]{ - StatusCode: 200, - Data: PurchaseResult{ - JingleDocType: "purchaseSuccess", - Status: 0, - }, - }, nil) - - mockSearchClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[SearchResult]{ - StatusCode: 200, - Data: SearchResult{ - Count: 1, - Results: []App{{}}, - }, - }, nil) - - mockDownloadClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[DownloadResult]{}, testErr) - }) - - It("attempts to download app", func() { - _, err := as.Download("", "", true) - Expect(err).To(MatchError(ContainSubstring(testErr.Error()))) - }) - }) - - When("fails to acquire license", func() { - BeforeEach(func() { - mockKeychain.EXPECT(). - Get("account"). - Return([]byte{}, testErr) - }) - - It("returns error", func() { - _, err := as.Download("", "", true) - Expect(err).To(MatchError(ContainSubstring(ErrPurchase.Error()))) - }) - }) + _, err := as.Download(DownloadInput{}) + Expect(err).To(HaveOccurred()) }) }) When("store API returns error", func() { BeforeEach(func() { - mockKeychain.EXPECT(). - Get("account"). - Return([]byte("{\"storeFront\":\"143441\"}"), nil) - - mockSearchClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[SearchResult]{ - StatusCode: 200, - Data: SearchResult{ - Count: 1, - Results: []App{{}}, - }, - }, nil) - mockOS.EXPECT(). Getwd(). Return("", nil) @@ -414,28 +167,23 @@ var _ = Describe("AppStore (Download)", func() { mockMachine.EXPECT(). MacAddress(). Return("", nil) - - mockLogger.EXPECT(). - Verbose(). - Return(nil). - Times(2) }) When("response contains customer message", func() { BeforeEach(func() { mockDownloadClient.EXPECT(). Send(gomock.Any()). - Return(http.Result[DownloadResult]{ - Data: DownloadResult{ + Return(http.Result[downloadResult]{ + Data: downloadResult{ FailureType: "test-failure", - CustomerMessage: testErr.Error(), + CustomerMessage: errors.New("").Error(), }, }, nil) }) It("returns customer message as error", func() { - _, err := as.Download("", "", true) - Expect(err).To(MatchError(ContainSubstring(testErr.Error()))) + _, err := as.Download(DownloadInput{}) + Expect(err).To(HaveOccurred()) }) }) @@ -443,36 +191,22 @@ var _ = Describe("AppStore (Download)", func() { BeforeEach(func() { mockDownloadClient.EXPECT(). Send(gomock.Any()). - Return(http.Result[DownloadResult]{ - Data: DownloadResult{ + Return(http.Result[downloadResult]{ + Data: downloadResult{ FailureType: "test-failure", }, }, nil) }) It("returns generic error", func() { - _, err := as.Download("", "", true) - Expect(err).To(MatchError(ContainSubstring(ErrGeneric.Error()))) + _, err := as.Download(DownloadInput{}) + Expect(err).To(HaveOccurred()) }) }) }) When("store API returns no items", func() { BeforeEach(func() { - mockKeychain.EXPECT(). - Get("account"). - Return([]byte("{\"storeFront\":\"143441\"}"), nil) - - mockSearchClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[SearchResult]{ - StatusCode: 200, - Data: SearchResult{ - Count: 1, - Results: []App{{}}, - }, - }, nil) - mockOS.EXPECT(). Getwd(). Return("", nil) @@ -481,42 +215,23 @@ var _ = Describe("AppStore (Download)", func() { MacAddress(). Return("", nil) - mockLogger.EXPECT(). - Verbose(). - Return(nil). - Times(2) - mockDownloadClient.EXPECT(). Send(gomock.Any()). - Return(http.Result[DownloadResult]{ - Data: DownloadResult{ - Items: []DownloadItemResult{}, + Return(http.Result[downloadResult]{ + Data: downloadResult{ + Items: []downloadItemResult{}, }, }, nil) }) It("returns error", func() { - _, err := as.Download("", "", true) - Expect(err).To(MatchError(ContainSubstring("received 0 items from the App Store"))) + _, err := as.Download(DownloadInput{}) + Expect(err).To(HaveOccurred()) }) }) When("fails to download file", func() { BeforeEach(func() { - mockKeychain.EXPECT(). - Get("account"). - Return([]byte("{\"storeFront\":\"143441\"}"), nil) - - mockSearchClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[SearchResult]{ - StatusCode: 200, - Data: SearchResult{ - Count: 1, - Results: []App{{}}, - }, - }, nil) - mockOS.EXPECT(). Getwd(). Return("", nil) @@ -525,15 +240,11 @@ var _ = Describe("AppStore (Download)", func() { MacAddress(). Return("", nil) - mockLogger.EXPECT(). - Verbose(). - Return(nil) - mockDownloadClient.EXPECT(). Send(gomock.Any()). - Return(http.Result[DownloadResult]{ - Data: DownloadResult{ - Items: []DownloadItemResult{{}}, + Return(http.Result[downloadResult]{ + Data: downloadResult{ + Items: []downloadItemResult{{}}, }, }, nil) }) @@ -542,12 +253,12 @@ var _ = Describe("AppStore (Download)", func() { BeforeEach(func() { mockHTTPClient.EXPECT(). NewRequest("GET", gomock.Any(), nil). - Return(nil, testErr) + Return(nil, errors.New("")) }) It("returns error", func() { - _, err := as.Download("", "", true) - Expect(err).To(MatchError(ContainSubstring(ErrCreateRequest.Error()))) + _, err := as.Download(DownloadInput{}) + Expect(err).To(HaveOccurred()) }) }) @@ -559,12 +270,12 @@ var _ = Describe("AppStore (Download)", func() { mockHTTPClient.EXPECT(). Do(gomock.Any()). - Return(nil, testErr) + Return(nil, errors.New("")) }) It("returns error", func() { - _, err := as.Download("", "", true) - Expect(err).To(MatchError(ContainSubstring(ErrRequest.Error()))) + _, err := as.Download(DownloadInput{}) + Expect(err).To(HaveOccurred()) }) }) @@ -582,12 +293,12 @@ var _ = Describe("AppStore (Download)", func() { mockOS.EXPECT(). OpenFile(gomock.Any(), gomock.Any(), gomock.Any()). - Return(nil, testErr) + Return(nil, errors.New("")) }) It("returns error", func() { - _, err := as.Download("", "", true) - Expect(err).To(MatchError(ContainSubstring("failed to open file"))) + _, err := as.Download(DownloadInput{}) + Expect(err).To(HaveOccurred()) }) }) @@ -606,15 +317,11 @@ var _ = Describe("AppStore (Download)", func() { mockOS.EXPECT(). OpenFile(gomock.Any(), gomock.Any(), gomock.Any()). Return(nil, nil) - - mockLogger.EXPECT(). - Verbose(). - Return(nil) }) It("returns error", func() { - _, err := as.Download("", "", true) - Expect(err).To(MatchError(ContainSubstring(ErrFileWrite.Error()))) + _, err := as.Download(DownloadInput{}) + Expect(err).To(HaveOccurred()) }) }) }) @@ -627,37 +334,18 @@ var _ = Describe("AppStore (Download)", func() { testFile, err = os.CreateTemp("", "test_file") Expect(err).ToNot(HaveOccurred()) - mockKeychain.EXPECT(). - Get("account"). - Return([]byte("{\"storeFront\":\"143441\"}"), nil) - - mockSearchClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[SearchResult]{ - StatusCode: 200, - Data: SearchResult{ - Count: 1, - Results: []App{{}}, - }, - }, nil) - mockMachine.EXPECT(). MacAddress(). Return("", nil) - mockLogger.EXPECT(). - Verbose(). - Return(nil). - Times(2) - mockDownloadClient.EXPECT(). Send(gomock.Any()). - Return(http.Result[DownloadResult]{ - Data: DownloadResult{ - Items: []DownloadItemResult{ + Return(http.Result[downloadResult]{ + Data: downloadResult{ + Items: []downloadItemResult{ { Metadata: map[string]interface{}{}, - Sinfs: []DownloadSinfResult{ + Sinfs: []Sinf{ { ID: 0, Data: []byte("test-sinf-data"), @@ -693,12 +381,8 @@ var _ = Describe("AppStore (Download)", func() { Getwd(). Return("", nil) - mockOS.EXPECT(). - OpenFile(gomock.Any(), gomock.Any(), gomock.Any()). - Return(nil, testErr) - - _, err := as.Download("", "", true) - Expect(err).To(MatchError(ContainSubstring(ErrOpenFile.Error()))) + _, err := as.Download(DownloadInput{}) + Expect(err).To(HaveOccurred()) testData, err := os.ReadFile(testFile.Name()) Expect(err).ToNot(HaveOccurred()) @@ -706,9 +390,10 @@ var _ = Describe("AppStore (Download)", func() { }) When("successfully applies patches", func() { - var tmpFile *os.File - var zipFile *zip.Writer - var outputPath string + var ( + tmpFile *os.File + outputPath string + ) BeforeEach(func() { var err error @@ -723,10 +408,6 @@ var _ = Describe("AppStore (Download)", func() { return os.OpenFile(name, flag, perm) }) - mockLogger.EXPECT(). - Verbose(). - Return(nil) - mockOS.EXPECT(). Stat(gomock.Any()). Return(nil, nil) @@ -734,142 +415,34 @@ var _ = Describe("AppStore (Download)", func() { mockOS.EXPECT(). Remove(tmpFile.Name()). Return(nil) - }) - AfterEach(func() { - err := os.Remove(tmpFile.Name()) + zipFile := zip.NewWriter(tmpFile) + w, err := zipFile.Create("Payload/Test.app/Info.plist") Expect(err).ToNot(HaveOccurred()) - }) - - When("app uses legacy FairPlay protection", func() { - BeforeEach(func() { - zipFile = zip.NewWriter(tmpFile) - w, err := zipFile.Create("Payload/Test.app/Info.plist") - Expect(err).ToNot(HaveOccurred()) - info, err := plist.Marshal(map[string]interface{}{ - "CFBundleExecutable": "Test", - }, plist.BinaryFormat) - Expect(err).ToNot(HaveOccurred()) - - _, err = w.Write(info) - Expect(err).ToNot(HaveOccurred()) - - err = zipFile.Close() - Expect(err).ToNot(HaveOccurred()) + info, err := plist.Marshal(map[string]interface{}{ + "CFBundleExecutable": "Test", + }, plist.BinaryFormat) + Expect(err).ToNot(HaveOccurred()) - mockLogger.EXPECT(). - Verbose(). - Return(nil) - }) + _, err = w.Write(info) + Expect(err).ToNot(HaveOccurred()) - It("succeeds", func() { - out, err := as.Download("", outputPath, true) - Expect(err).ToNot(HaveOccurred()) - Expect(len(out.DestinationPath)).ToNot(Equal(0)) - }) + err = zipFile.Close() + Expect(err).ToNot(HaveOccurred()) }) - When("app uses modern FairPlay protection", func() { - BeforeEach(func() { - zipFile = zip.NewWriter(tmpFile) - w, err := zipFile.Create("Payload/Test.app/SC_Info/Manifest.plist") - Expect(err).ToNot(HaveOccurred()) - - manifest, err := plist.Marshal(PackageManifest{ - SinfPaths: []string{ - "SC_Info/TestApp.sinf", - }, - }, plist.BinaryFormat) - Expect(err).ToNot(HaveOccurred()) - - _, err = w.Write(manifest) - Expect(err).ToNot(HaveOccurred()) - - w, err = zipFile.Create("Payload/Test.app/Info.plist") - Expect(err).ToNot(HaveOccurred()) - - info, err := plist.Marshal(map[string]interface{}{ - "CFBundleExecutable": "Test", - }, plist.BinaryFormat) - Expect(err).ToNot(HaveOccurred()) - - _, err = w.Write(info) - Expect(err).ToNot(HaveOccurred()) - - err = zipFile.Close() - Expect(err).ToNot(HaveOccurred()) - }) - - It("succeeds", func() { - outputPath := strings.TrimSuffix(tmpFile.Name(), ".tmp") - out, err := as.Download("", outputPath, true) - Expect(err).ToNot(HaveOccurred()) - Expect(len(out.DestinationPath)).ToNot(Equal(0)) - }) + AfterEach(func() { + err := os.Remove(tmpFile.Name()) + Expect(err).ToNot(HaveOccurred()) }) - When("app uses modern FairPlay protection and has Watch app", func() { - BeforeEach(func() { - zipFile = zip.NewWriter(tmpFile) - w, err := zipFile.Create("Payload/Test.app/SC_Info/Manifest.plist") - Expect(err).ToNot(HaveOccurred()) - - manifest, err := plist.Marshal(PackageManifest{ - SinfPaths: []string{ - "SC_Info/TestApp.sinf", - }, - }, plist.BinaryFormat) - Expect(err).ToNot(HaveOccurred()) - - _, err = w.Write(manifest) - Expect(err).ToNot(HaveOccurred()) - - w, err = zipFile.Create("Payload/Test.app/Info.plist") - Expect(err).ToNot(HaveOccurred()) - - info, err := plist.Marshal(map[string]interface{}{ - "CFBundleExecutable": "Test", - }, plist.BinaryFormat) - Expect(err).ToNot(HaveOccurred()) - - _, err = w.Write(info) - Expect(err).ToNot(HaveOccurred()) - - w, err = zipFile.Create("Payload/Test.app/Watch/Test Watch App.app/Info.plist") - Expect(err).ToNot(HaveOccurred()) - - watchInfo, err := plist.Marshal(map[string]interface{}{ - "WKWatchKitApp": true, - }, plist.BinaryFormat) - Expect(err).ToNot(HaveOccurred()) - - _, err = w.Write(watchInfo) - Expect(err).ToNot(HaveOccurred()) - - err = zipFile.Close() - Expect(err).ToNot(HaveOccurred()) - }) - - It("sinf is in the correct path", func() { - outputPath := strings.TrimSuffix(tmpFile.Name(), ".tmp") - out, err := as.Download("", outputPath, true) - Expect(err).ToNot(HaveOccurred()) - Expect(len(out.DestinationPath)).ToNot(Equal(0)) - - r, err := zip.OpenReader(outputPath) - Expect(err).ToNot(HaveOccurred()) - defer r.Close() - - found := false - for _, f := range r.File { - if f.Name == "Payload/Test Watch App.app/SC_Info/TestApp.sinf" { - found = true - break - } - } - Expect(found).To(BeFalse()) + It("succeeds", func() { + out, err := as.Download(DownloadInput{ + OutputPath: outputPath, }) + Expect(err).ToNot(HaveOccurred()) + Expect(len(out.DestinationPath)).ToNot(Equal(0)) }) }) }) diff --git a/pkg/appstore/appstore_info.go b/pkg/appstore/appstore_info.go deleted file mode 100644 index c6fbe9e5..00000000 --- a/pkg/appstore/appstore_info.go +++ /dev/null @@ -1,38 +0,0 @@ -package appstore - -import ( - "encoding/json" - "github.com/pkg/errors" -) - -type InfoOutput struct { - Name string - Email string -} - -func (a *appstore) Info() (InfoOutput, error) { - acc, err := a.account() - if err != nil { - return InfoOutput{}, errors.Wrap(err, ErrGetAccount.Error()) - } - - return InfoOutput{ - Name: acc.Name, - Email: acc.Email, - }, nil -} - -func (a *appstore) account() (Account, error) { - data, err := a.keychain.Get("account") - if err != nil { - return Account{}, errors.Wrap(err, ErrGetKeychainItem.Error()) - } - - var acc Account - err = json.Unmarshal(data, &acc) - if err != nil { - return Account{}, errors.Wrap(err, ErrUnmarshal.Error()) - } - - return acc, err -} diff --git a/pkg/appstore/appstore_login.go b/pkg/appstore/appstore_login.go index f6dde11f..296934d3 100644 --- a/pkg/appstore/appstore_login.go +++ b/pkg/appstore/appstore_login.go @@ -1,117 +1,84 @@ package appstore import ( - "bufio" "encoding/json" + "errors" "fmt" "github.com/majd/ipatool/pkg/http" - "github.com/majd/ipatool/pkg/util" - "github.com/pkg/errors" - "os" "strings" ) -type LoginAddressResult struct { - FirstName string `plist:"firstName,omitempty"` - LastName string `plist:"lastName,omitempty"` -} - -type LoginAccountResult struct { - Email string `plist:"appleId,omitempty"` - Address LoginAddressResult `plist:"address,omitempty"` -} +var ( + ErrAuthCodeRequired = errors.New("auth code is required") +) -type LoginResult struct { - FailureType string `plist:"failureType,omitempty"` - CustomerMessage string `plist:"customerMessage,omitempty"` - Account LoginAccountResult `plist:"accountInfo,omitempty"` - DirectoryServicesID string `plist:"dsPersonId,omitempty"` - PasswordToken string `plist:"passwordToken,omitempty"` +type LoginInput struct { + Email string + Password string + AuthCode string } type LoginOutput struct { - Name string - Email string + Account Account } -func (a *appstore) Login(email, password, authCode string) (LoginOutput, error) { - if password == "" && !a.interactive { - return LoginOutput{}, ErrPasswordRequired - } - - if password == "" && a.interactive { - a.logger.Log().Msg("enter password:") - - var err error - password, err = a.promptForPassword() - if err != nil { - return LoginOutput{}, errors.Wrap(err, ErrGetData.Error()) - } - } - - macAddr, err := a.machine.MacAddress() +func (t *appstore) Login(input LoginInput) (LoginOutput, error) { + macAddr, err := t.machine.MacAddress() if err != nil { - return LoginOutput{}, errors.Wrap(err, ErrGetMAC.Error()) + return LoginOutput{}, fmt.Errorf("failed to get mac address: %w", err) } guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "") - a.logger.Verbose().Str("mac", macAddr).Str("guid", guid).Send() - acc, err := a.login(email, password, authCode, guid, 0, false) + acc, err := t.login(input.Email, input.Password, input.AuthCode, guid, 0) if err != nil { - return LoginOutput{}, errors.Wrap(err, ErrLogin.Error()) + return LoginOutput{}, err } return LoginOutput{ - Name: acc.Name, - Email: acc.Email, + Account: acc, }, nil } -func (a *appstore) login(email, password, authCode, guid string, attempt int, failOnAuthCodeRequirement bool) (Account, error) { - a.logger.Verbose(). - Int("attempt", attempt). - Str("password", password). - Str("email", email). - Str("authCode", util.IfEmpty(authCode, "")). - Msg("sending login request") +type loginAddressResult struct { + FirstName string `plist:"firstName,omitempty"` + LastName string `plist:"lastName,omitempty"` +} - request := a.loginRequest(email, password, authCode, guid) - res, err := a.loginClient.Send(request) +type loginAccountResult struct { + Email string `plist:"appleId,omitempty"` + Address loginAddressResult `plist:"address,omitempty"` +} + +type loginResult struct { + FailureType string `plist:"failureType,omitempty"` + CustomerMessage string `plist:"customerMessage,omitempty"` + Account loginAccountResult `plist:"accountInfo,omitempty"` + DirectoryServicesID string `plist:"dsPersonId,omitempty"` + PasswordToken string `plist:"passwordToken,omitempty"` +} + +func (t *appstore) login(email, password, authCode, guid string, attempt int) (Account, error) { + request := t.loginRequest(email, password, authCode, guid) + res, err := t.loginClient.Send(request) if err != nil { - return Account{}, errors.Wrap(err, ErrRequest.Error()) + return Account{}, fmt.Errorf("request failed: %w", err) } if attempt == 0 && res.Data.FailureType == FailureTypeInvalidCredentials { - return a.login(email, password, authCode, guid, 1, failOnAuthCodeRequirement) + return t.login(email, password, authCode, guid, 1) } if res.Data.FailureType != "" && res.Data.CustomerMessage != "" { - a.logger.Verbose().Interface("response", res).Send() - return Account{}, errors.New(res.Data.CustomerMessage) + return Account{}, NewErrorWithMetadata(errors.New(res.Data.CustomerMessage), res) } if res.Data.FailureType != "" { - a.logger.Verbose().Interface("response", res).Send() - return Account{}, ErrGeneric + return Account{}, NewErrorWithMetadata(errors.New("something went wrong"), res) } if res.Data.FailureType == "" && authCode == "" && res.Data.CustomerMessage == CustomerMessageBadLogin { - if failOnAuthCodeRequirement { - return Account{}, ErrAuthCodeRequired - } - - if a.interactive { - a.logger.Log().Msg("enter 2FA code:") - authCode, err = a.promptForAuthCode() - if err != nil { - return Account{}, errors.Wrap(err, ErrGetData.Error()) - } - - return a.login(email, password, authCode, guid, 0, failOnAuthCodeRequirement) - } else { - return Account{}, ErrAuthCodeRequired - } + return Account{}, ErrAuthCodeRequired } addr := res.Data.Account.Address @@ -126,18 +93,18 @@ func (a *appstore) login(email, password, authCode, guid string, attempt int, fa data, err := json.Marshal(acc) if err != nil { - return Account{}, errors.Wrap(err, ErrMarshal.Error()) + return Account{}, fmt.Errorf("failed to marshal json: %w", err) } - err = a.keychain.Set("account", data) + err = t.keychain.Set("account", data) if err != nil { - return Account{}, errors.Wrap(err, ErrSetKeychainItem.Error()) + return Account{}, fmt.Errorf("failed to save account in keychain: %w", err) } return acc, nil } -func (a *appstore) loginRequest(email, password, authCode, guid string) http.Request { +func (t *appstore) loginRequest(email, password, authCode, guid string) http.Request { attempt := "4" if authCode != "" { attempt = "2" @@ -145,7 +112,7 @@ func (a *appstore) loginRequest(email, password, authCode, guid string) http.Req return http.Request{ Method: http.MethodPOST, - URL: a.authDomain(authCode, guid), + URL: t.authDomain(authCode, guid), ResponseFormat: http.ResponseFormatXML, Headers: map[string]string{ "Content-Type": "application/x-www-form-urlencoded", @@ -164,34 +131,12 @@ func (a *appstore) loginRequest(email, password, authCode, guid string) http.Req } } -func (a *appstore) promptForAuthCode() (string, error) { - reader := bufio.NewReader(a.ioReader) - authCode, err := reader.ReadString('\n') - if err != nil { - return "", errors.Wrap(err, ErrGetData.Error()) - } - - authCode = strings.Trim(authCode, "\n") - authCode = strings.Trim(authCode, "\r") - - return authCode, nil -} - func (*appstore) authDomain(authCode, guid string) string { - prefix := PriavteAppStoreAPIDomainPrefixWithoutAuthCode + prefix := PrivateAppStoreAPIDomainPrefixWithoutAuthCode if authCode != "" { - prefix = PriavteAppStoreAPIDomainPrefixWithAuthCode + prefix = PrivateAppStoreAPIDomainPrefixWithAuthCode } return fmt.Sprintf( "https://%s-%s%s?guid=%s", prefix, PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathAuthenticate, guid) } - -func (a *appstore) promptForPassword() (string, error) { - password, err := a.machine.ReadPassword(int(os.Stdin.Fd())) - if err != nil { - return "", errors.Wrap(err, ErrGetData.Error()) - } - - return string(password), nil -} diff --git a/pkg/appstore/appstore_login_test.go b/pkg/appstore/appstore_login_test.go index 1f06ca36..0652e263 100644 --- a/pkg/appstore/appstore_login_test.go +++ b/pkg/appstore/appstore_login_test.go @@ -2,18 +2,15 @@ package appstore import ( "encoding/json" + "errors" "fmt" "github.com/golang/mock/gomock" "github.com/majd/ipatool/pkg/http" "github.com/majd/ipatool/pkg/keychain" - "github.com/majd/ipatool/pkg/log" - "github.com/majd/ipatool/pkg/util" + "github.com/majd/ipatool/pkg/util/machine" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/pkg/errors" - "os" "strings" - "syscall" ) var _ = Describe("AppStore (Login)", func() { @@ -28,101 +25,42 @@ var _ = Describe("AppStore (Login)", func() { ctrl *gomock.Controller as AppStore mockKeychain *keychain.MockKeychain - mockClient *http.MockClient[LoginResult] - mockMachine *util.MockMachine - mockLogger *log.MockLogger - testErr = errors.New("test") + mockClient *http.MockClient[loginResult] + mockMachine *machine.MockMachine ) BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) mockKeychain = keychain.NewMockKeychain(ctrl) - mockClient = http.NewMockClient[LoginResult](ctrl) - mockMachine = util.NewMockMachine(ctrl) - mockLogger = log.NewMockLogger(ctrl) + mockClient = http.NewMockClient[loginResult](ctrl) + mockMachine = machine.NewMockMachine(ctrl) as = &appstore{ keychain: mockKeychain, loginClient: mockClient, - ioReader: os.Stdin, machine: mockMachine, - logger: mockLogger, - interactive: true, } - - mockLogger.EXPECT(). - Verbose(). - Return(nil). - MaxTimes(4) }) AfterEach(func() { ctrl.Finish() }) - When("not running in interactive mode and password is not supplied", func() { - BeforeEach(func() { - as.(*appstore).interactive = false - }) - - It("returns error", func() { - _, err := as.Login("", "", "") - Expect(err).To(MatchError(ContainSubstring("password is required"))) - }) - }) - - When("prompts user for password", func() { - BeforeEach(func() { - mockLogger.EXPECT(). - Log(). - Return(nil) - }) - - When("user enters password", func() { - BeforeEach(func() { - mockMachine.EXPECT(). - MacAddress(). - Return("", errors.New("success")) - - mockMachine.EXPECT(). - ReadPassword(syscall.Stdin). - Return([]byte(testPassword), nil) - }) - - It("succeeds", func() { - _, err := as.Login("", "", "") - Expect(err).To(MatchError(ContainSubstring("success"))) - }) - }) - - When("fails to read password from stdin", func() { - BeforeEach(func() { - mockMachine.EXPECT(). - ReadPassword(syscall.Stdin). - Return(nil, testErr) - }) - - It("returns error", func() { - _, err := as.Login("", "", "") - Expect(err).To(MatchError(ContainSubstring(ErrGetData.Error()))) - }) - }) - }) - When("fails to read Machine's MAC address", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). - Return("", testErr) + Return("", errors.New("")) }) It("returns error", func() { - _, err := as.Login("", testPassword, "") - Expect(err).To(MatchError(ContainSubstring(testErr.Error()))) - Expect(err).To(MatchError(ContainSubstring(ErrGetMAC.Error()))) + _, err := as.Login(LoginInput{ + Password: testPassword, + }) + Expect(err).To(HaveOccurred()) }) }) - When("sucessfully reads machine's MAC address", func() { + When("successfully reads machine's MAC address", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). @@ -133,23 +71,23 @@ var _ = Describe("AppStore (Login)", func() { BeforeEach(func() { mockClient.EXPECT(). Send(gomock.Any()). - Return(http.Result[LoginResult]{}, testErr) + Return(http.Result[loginResult]{}, errors.New("")) }) It("returns wrapped error", func() { - _, err := as.Login("", testPassword, "") - Expect(err).To(MatchError(ContainSubstring(testErr.Error()))) + _, err := as.Login(LoginInput{ + Password: testPassword, + }) + Expect(err).To(HaveOccurred()) }) }) When("store API returns invalid first response", func() { - const testCustomerMessage = "test" - BeforeEach(func() { mockClient.EXPECT(). Send(gomock.Any()). - Return(http.Result[LoginResult]{ - Data: LoginResult{ + Return(http.Result[loginResult]{ + Data: loginResult{ FailureType: FailureTypeInvalidCredentials, CustomerMessage: "test", }, @@ -158,8 +96,10 @@ var _ = Describe("AppStore (Login)", func() { }) It("retries one more time", func() { - _, err := as.Login("", testPassword, "") - Expect(err).To(MatchError(ContainSubstring(testCustomerMessage))) + _, err := as.Login(LoginInput{ + Password: testPassword, + }) + Expect(err).To(HaveOccurred()) }) }) @@ -167,108 +107,38 @@ var _ = Describe("AppStore (Login)", func() { BeforeEach(func() { mockClient.EXPECT(). Send(gomock.Any()). - Return(http.Result[LoginResult]{ - Data: LoginResult{ + Return(http.Result[loginResult]{ + Data: loginResult{ FailureType: "random-error", }, }, nil) }) It("returns error", func() { - _, err := as.Login("", testPassword, "") - Expect(err).To(MatchError(ContainSubstring(ErrGeneric.Error()))) + _, err := as.Login(LoginInput{ + Password: testPassword, + }) + Expect(err).To(HaveOccurred()) }) }) When("store API requires 2FA code", func() { - When("not running in interactive mode", func() { - BeforeEach(func() { - as.(*appstore).interactive = false - - mockClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[LoginResult]{ - Data: LoginResult{ - FailureType: "", - CustomerMessage: CustomerMessageBadLogin, - }, - }, nil) - }) - - It("returns error", func() { - _, err := as.Login("", testPassword, "") - Expect(err).To(MatchError(ContainSubstring(ErrAuthCodeRequired.Error()))) - }) - }) - - When("user enters 2FA code", func() { - BeforeEach(func() { - mockLogger.EXPECT(). - Log(). - Return(nil) - - mockKeychain.EXPECT(). - Set("account", gomock.Any()). - Return(nil) - - gomock.InOrder( - mockClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[LoginResult]{ - Data: LoginResult{ - FailureType: "", - CustomerMessage: CustomerMessageBadLogin, - }, - }, nil), - mockClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[LoginResult]{ - Data: LoginResult{ - Account: LoginAccountResult{ - Email: testEmail, - Address: LoginAddressResult{ - FirstName: testFirstName, - LastName: testLastName, - }, - }, - }, - }, nil), - ) - - as.(*appstore).ioReader = strings.NewReader("123456\n") - }) - - It("successfully authenticates", func() { - out, err := as.Login("", testPassword, "") - Expect(err).ToNot(HaveOccurred()) - Expect(out.Email).To(Equal(testEmail)) - Expect(out.Name).To(Equal(strings.Join([]string{testFirstName, testLastName}, " "))) - - }) + BeforeEach(func() { + mockClient.EXPECT(). + Send(gomock.Any()). + Return(http.Result[loginResult]{ + Data: loginResult{ + FailureType: "", + CustomerMessage: CustomerMessageBadLogin, + }, + }, nil) }) - When("prompts user for 2FA code", func() { - BeforeEach(func() { - mockLogger.EXPECT(). - Log(). - Return(nil) - - mockClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[LoginResult]{ - Data: LoginResult{ - FailureType: "", - CustomerMessage: CustomerMessageBadLogin, - }, - }, nil) - - as.(*appstore).ioReader = strings.NewReader("123456") - }) - - It("fails to read 2FA code from stdin", func() { - _, err := as.Login("", testPassword, "") - Expect(err).To(MatchError(ContainSubstring(ErrGetData.Error()))) + It("returns error", func() { + _, err := as.Login(LoginInput{ + Password: testPassword, }) + Expect(err).To(HaveOccurred()) }) }) @@ -281,13 +151,13 @@ var _ = Describe("AppStore (Login)", func() { BeforeEach(func() { mockClient.EXPECT(). Send(gomock.Any()). - Return(http.Result[LoginResult]{ - Data: LoginResult{ + Return(http.Result[loginResult]{ + Data: loginResult{ PasswordToken: testPasswordToken, DirectoryServicesID: testDirectoryServicesID, - Account: LoginAccountResult{ + Account: loginAccountResult{ Email: testEmail, - Address: LoginAddressResult{ + Address: loginAddressResult{ FirstName: testFirstName, LastName: testLastName, }, @@ -314,13 +184,14 @@ var _ = Describe("AppStore (Login)", func() { Expect(err).ToNot(HaveOccurred()) Expect(got).To(Equal(want)) }). - Return(testErr) + Return(errors.New("")) }) It("returns error", func() { - _, err := as.Login("", testPassword, "") - Expect(err).To(MatchError(ContainSubstring(testErr.Error()))) - Expect(err).To(MatchError(ContainSubstring(ErrSetKeychainItem.Error()))) + _, err := as.Login(LoginInput{ + Password: testPassword, + }) + Expect(err).To(HaveOccurred()) }) }) @@ -346,10 +217,12 @@ var _ = Describe("AppStore (Login)", func() { }) It("returns nil", func() { - out, err := as.Login("", testPassword, "") + out, err := as.Login(LoginInput{ + Password: testPassword, + }) Expect(err).ToNot(HaveOccurred()) - Expect(out.Email).To(Equal(testEmail)) - Expect(out.Name).To(Equal(strings.Join([]string{testFirstName, testLastName}, " "))) + Expect(out.Account.Email).To(Equal(testEmail)) + Expect(out.Account.Name).To(Equal(strings.Join([]string{testFirstName, testLastName}, " "))) }) }) }) diff --git a/pkg/appstore/appstore_lookup.go b/pkg/appstore/appstore_lookup.go index 37273a12..94564771 100644 --- a/pkg/appstore/appstore_lookup.go +++ b/pkg/appstore/appstore_lookup.go @@ -1,70 +1,56 @@ package appstore import ( + "errors" "fmt" "github.com/majd/ipatool/pkg/http" - "github.com/pkg/errors" "net/url" ) +type LookupInput struct { + Account Account + BundleID string +} + type LookupOutput struct { App App } -func (a *appstore) Lookup(bundleID string) (LookupOutput, error) { - acc, err := a.account() - if err != nil { - return LookupOutput{}, errors.Wrap(err, ErrGetAccount.Error()) - } - - countryCode, err := a.countryCodeFromStoreFront(acc.StoreFront) - if err != nil { - return LookupOutput{}, errors.Wrap(err, ErrInvalidCountryCode.Error()) - } - - app, err := a.lookup(bundleID, countryCode) +func (t *appstore) Lookup(input LookupInput) (LookupOutput, error) { + countryCode, err := countryCodeFromStoreFront(input.Account.StoreFront) if err != nil { - return LookupOutput{}, err + return LookupOutput{}, fmt.Errorf("failed to reoslve the country code: %w", err) } - return LookupOutput{ - App: app, - }, nil -} - -func (a *appstore) lookup(bundleID, countryCode string) (App, error) { - if StoreFronts[countryCode] == "" { - return App{}, ErrInvalidCountryCode - } - - request := a.lookupRequest(bundleID, countryCode) + request := t.lookupRequest(input.BundleID, countryCode) - res, err := a.searchClient.Send(request) + res, err := t.searchClient.Send(request) if err != nil { - return App{}, errors.Wrap(err, ErrRequest.Error()) + return LookupOutput{}, fmt.Errorf("request failed: %w", err) } if res.StatusCode != 200 { - a.logger.Verbose().Interface("data", res.Data).Int("status", res.StatusCode).Send() - return App{}, ErrRequest + return LookupOutput{}, NewErrorWithMetadata(errors.New("invalid response"), res) } if len(res.Data.Results) == 0 { - return App{}, ErrAppNotFound + return LookupOutput{}, errors.New("app not found") } - return res.Data.Results[0], nil + return LookupOutput{ + App: res.Data.Results[0], + }, nil } -func (a *appstore) lookupRequest(bundleID, countryCode string) http.Request { +func (t *appstore) lookupRequest(bundleID, countryCode string) http.Request { return http.Request{ - URL: a.lookupURL(bundleID, countryCode), + URL: t.lookupURL(bundleID, countryCode), Method: http.MethodGET, ResponseFormat: http.ResponseFormatJSON, } } -func (a *appstore) lookupURL(bundleID, countryCode string) string { +func (t *appstore) lookupURL(bundleID, countryCode string) string { params := url.Values{} params.Add("entity", "software,iPadSoftware") params.Add("limit", "1") diff --git a/pkg/appstore/appstore_lookup_test.go b/pkg/appstore/appstore_lookup_test.go index 2caf7b66..5631fe0e 100644 --- a/pkg/appstore/appstore_lookup_test.go +++ b/pkg/appstore/appstore_lookup_test.go @@ -1,31 +1,25 @@ package appstore import ( + "errors" "github.com/golang/mock/gomock" "github.com/majd/ipatool/pkg/http" - "github.com/majd/ipatool/pkg/log" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/pkg/errors" - "os" ) var _ = Describe("AppStore (Lookup)", func() { var ( ctrl *gomock.Controller - mockClient *http.MockClient[SearchResult] - mockLogger *log.MockLogger - as *appstore + mockClient *http.MockClient[searchResult] + as AppStore ) BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) - mockClient = http.NewMockClient[SearchResult](ctrl) - mockLogger = log.NewMockLogger(ctrl) + mockClient = http.NewMockClient[searchResult](ctrl) as = &appstore{ searchClient: mockClient, - ioReader: os.Stdin, - logger: mockLogger, } }) @@ -33,56 +27,14 @@ var _ = Describe("AppStore (Lookup)", func() { ctrl.Finish() }) - When("country code is invalid", func() { - It("returns error", func() { - _, err := as.lookup("", "XYZ") - Expect(err).To(MatchError(ContainSubstring(ErrInvalidCountryCode.Error()))) - }) - }) - - When("request fails", func() { - var testErr = errors.New("test") - - BeforeEach(func() { - mockClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[SearchResult]{}, testErr) - }) - - It("returns error", func() { - _, err := as.lookup("", "US") - Expect(err).To(MatchError(ContainSubstring(testErr.Error()))) - Expect(err).To(MatchError(ContainSubstring(ErrRequest.Error()))) - }) - }) - - When("request returns bad status code", func() { - BeforeEach(func() { - mockLogger.EXPECT(). - Verbose(). - Return(nil) - - mockClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[SearchResult]{ - StatusCode: 400, - }, nil) - }) - - It("returns error", func() { - _, err := as.lookup("", "US") - Expect(err).To(MatchError(ContainSubstring(ErrRequest.Error()))) - }) - }) - When("request is successful", func() { When("does not find app", func() { BeforeEach(func() { mockClient.EXPECT(). Send(gomock.Any()). - Return(http.Result[SearchResult]{ + Return(http.Result[searchResult]{ StatusCode: 200, - Data: SearchResult{ + Data: searchResult{ Count: 0, Results: []App{}, }, @@ -90,8 +42,12 @@ var _ = Describe("AppStore (Lookup)", func() { }) It("returns error", func() { - _, err := as.lookup("", "US") - Expect(err).To(MatchError(ContainSubstring(ErrAppNotFound.Error()))) + _, err := as.Lookup(LookupInput{ + Account: Account{ + StoreFront: "143441", + }, + }) + Expect(err).To(HaveOccurred()) }) }) @@ -107,9 +63,9 @@ var _ = Describe("AppStore (Lookup)", func() { BeforeEach(func() { mockClient.EXPECT(). Send(gomock.Any()). - Return(http.Result[SearchResult]{ + Return(http.Result[searchResult]{ StatusCode: 200, - Data: SearchResult{ + Data: searchResult{ Count: 1, Results: []App{testApp}, }, @@ -117,10 +73,61 @@ var _ = Describe("AppStore (Lookup)", func() { }) It("returns app", func() { - app, err := as.lookup("", "US") + app, err := as.Lookup(LookupInput{ + Account: Account{ + StoreFront: "143441", + }, + }) Expect(err).ToNot(HaveOccurred()) - Expect(app).To(Equal(testApp)) + Expect(app).To(Equal(LookupOutput{App: testApp})) + }) + }) + }) + + When("store front is invalid", func() { + It("returns error", func() { + _, err := as.Lookup(LookupInput{ + Account: Account{ + StoreFront: "xyz", + }, + }) + Expect(err).To(HaveOccurred()) + }) + }) + + When("request fails", func() { + BeforeEach(func() { + mockClient.EXPECT(). + Send(gomock.Any()). + Return(http.Result[searchResult]{}, errors.New("")) + }) + + It("returns error", func() { + _, err := as.Lookup(LookupInput{ + Account: Account{ + StoreFront: "143441", + }, + }) + Expect(err).To(HaveOccurred()) + }) + }) + + When("request returns bad status code", func() { + BeforeEach(func() { + mockClient.EXPECT(). + Send(gomock.Any()). + Return(http.Result[searchResult]{ + StatusCode: 400, + }, nil) + }) + + It("returns error", func() { + _, err := as.Lookup(LookupInput{ + Account: Account{ + StoreFront: "143441", + }, }) + Expect(err).To(HaveOccurred()) }) }) }) diff --git a/pkg/appstore/appstore_purchase.go b/pkg/appstore/appstore_purchase.go index b221de78..3b9b2a41 100644 --- a/pkg/appstore/appstore_purchase.go +++ b/pkg/appstore/appstore_purchase.go @@ -1,76 +1,64 @@ package appstore import ( + "errors" "fmt" "github.com/majd/ipatool/pkg/http" - "github.com/pkg/errors" "strings" ) -type PurchaseResult struct { - FailureType string `plist:"failureType,omitempty"` - CustomerMessage string `plist:"customerMessage,omitempty"` - JingleDocType string `plist:"jingleDocType,omitempty"` - Status int `plist:"status,omitempty"` -} - -func (a *appstore) Purchase(bundleID string) error { - macAddr, err := a.machine.MacAddress() - if err != nil { - return errors.Wrap(err, ErrGetMAC.Error()) - } - - guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "") - a.logger.Verbose().Str("mac", macAddr).Str("guid", guid).Send() - - err = a.purchase(bundleID, guid, true) - if err != nil { - return errors.Wrap(err, ErrPurchase.Error()) - } +var ( + ErrPasswordTokenExpired = errors.New("password token is expired") + ErrSubscriptionRequired = errors.New("subscription required") + ErrTemporarilyUnavailable = errors.New("item is temporarily unavailable") +) - return nil +type PurchaseInput struct { + Account Account + App App } -func (a *appstore) purchase(bundleID string, guid string, attemptToRenewCredentials bool) error { - acc, err := a.account() +func (t *appstore) Purchase(input PurchaseInput) error { + macAddr, err := t.machine.MacAddress() if err != nil { - return errors.Wrap(err, ErrGetAccount.Error()) + return fmt.Errorf("failed to get mac address: %w", err) } - countryCode, err := a.countryCodeFromStoreFront(acc.StoreFront) - if err != nil { - return errors.Wrap(err, ErrInvalidCountryCode.Error()) - } - - app, err := a.lookup(bundleID, countryCode) - if err != nil { - return errors.Wrap(err, ErrAppLookup.Error()) - } + guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "") - if app.Price > 0 { - return ErrPaidApp + if input.App.Price > 0 { + return errors.New("purchasing paid apps is not supported") } - err = a.purchaseWithParams(acc, app, bundleID, guid, attemptToRenewCredentials, PricingParameterAppStore) + err = t.purchaseWithParams(input.Account, input.App, guid, PricingParameterAppStore) if err != nil { if err == ErrTemporarilyUnavailable { - err = a.purchaseWithParams(acc, app, bundleID, guid, attemptToRenewCredentials, PricingParameterAppleArcade) + err = t.purchaseWithParams(input.Account, input.App, guid, PricingParameterAppleArcade) if err != nil { - return errors.Wrapf(err, "failed to purchase item with param '%s'", PricingParameterAppleArcade) + return fmt.Errorf("failed to purchase item with param '%s': %w", PricingParameterAppleArcade, err) } + + return nil } - return errors.Wrapf(err, "failed to purchase item with param '%s'", PricingParameterAppStore) + return fmt.Errorf("failed to purchase item with param '%s': %w", PricingParameterAppStore, err) } return nil } -func (a *appstore) purchaseWithParams(acc Account, app App, bundleID string, guid string, attemptToRenewCredentials bool, pricingParameters string) error { - req := a.purchaseRequest(acc, app, acc.StoreFront, guid, pricingParameters) - res, err := a.purchaseClient.Send(req) +type purchaseResult struct { + FailureType string `plist:"failureType,omitempty"` + CustomerMessage string `plist:"customerMessage,omitempty"` + JingleDocType string `plist:"jingleDocType,omitempty"` + Status int `plist:"status,omitempty"` +} + +func (t *appstore) purchaseWithParams(acc Account, app App, guid string, pricingParameters string) error { + req := t.purchaseRequest(acc, app, acc.StoreFront, guid, pricingParameters) + res, err := t.purchaseClient.Send(req) if err != nil { - return errors.Wrap(err, ErrRequest.Error()) + return fmt.Errorf("request failed: %w", err) } if res.Data.FailureType == FailureTypeTemporarilyUnavailable { @@ -78,59 +66,33 @@ func (a *appstore) purchaseWithParams(acc Account, app App, bundleID string, gui } if res.Data.CustomerMessage == CustomerMessageSubscriptionRequired { - a.logger.Verbose().Interface("response", res).Send() return ErrSubscriptionRequired } if res.Data.FailureType == FailureTypePasswordTokenExpired { - if attemptToRenewCredentials { - a.logger.Verbose().Msg("retrieving new password token") - acc, err = a.login(acc.Email, acc.Password, "", guid, 0, true) - if err != nil { - return errors.Wrap(err, ErrPasswordTokenExpired.Error()) - } - - return a.purchaseWithParams(acc, app, bundleID, guid, false, pricingParameters) - } - return ErrPasswordTokenExpired } if res.Data.FailureType != "" && res.Data.CustomerMessage != "" { - a.logger.Verbose().Interface("response", res).Send() - return errors.New(res.Data.CustomerMessage) + return NewErrorWithMetadata(errors.New(res.Data.CustomerMessage), res) } if res.Data.FailureType != "" { - a.logger.Verbose().Interface("response", res).Send() - return ErrGeneric + return NewErrorWithMetadata(errors.New("something went wrong"), res) } if res.StatusCode == 500 { - return ErrLicenseExists + return fmt.Errorf("license already exists") } if res.Data.JingleDocType != "purchaseSuccess" || res.Data.Status != 0 { - a.logger.Verbose().Interface("response", res).Send() - return errors.New(ErrPurchase.Error()) + return NewErrorWithMetadata(errors.New("failed to purchase app"), res) } return nil } -func (*appstore) countryCodeFromStoreFront(storeFront string) (string, error) { - for key, val := range StoreFronts { - parts := strings.Split(storeFront, "-") - - if len(parts) >= 1 && parts[0] == val { - return key, nil - } - } - - return "", errors.New(ErrInvalidStoreFront.Error()) -} - -func (a *appstore) purchaseRequest(acc Account, app App, storeFront, guid string, pricingParameters string) http.Request { +func (t *appstore) purchaseRequest(acc Account, app App, storeFront, guid string, pricingParameters string) http.Request { return http.Request{ URL: fmt.Sprintf("https://%s%s", PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathPurchase), Method: http.MethodPOST, diff --git a/pkg/appstore/appstore_purchase_test.go b/pkg/appstore/appstore_purchase_test.go index ed92e35b..8086a95e 100644 --- a/pkg/appstore/appstore_purchase_test.go +++ b/pkg/appstore/appstore_purchase_test.go @@ -1,11 +1,11 @@ package appstore import ( + "errors" "github.com/golang/mock/gomock" "github.com/majd/ipatool/pkg/http" "github.com/majd/ipatool/pkg/keychain" - "github.com/majd/ipatool/pkg/log" - "github.com/majd/ipatool/pkg/util" + "github.com/majd/ipatool/pkg/util/machine" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -14,29 +14,23 @@ var _ = Describe("AppStore (Purchase)", func() { var ( ctrl *gomock.Controller mockKeychain *keychain.MockKeychain - mockMachine *util.MockMachine - mockLogger *log.MockLogger - mockPurchaseClient *http.MockClient[PurchaseResult] - mockSearchClient *http.MockClient[SearchResult] - mockLoginClient *http.MockClient[LoginResult] + mockMachine *machine.MockMachine + mockPurchaseClient *http.MockClient[purchaseResult] + mockLoginClient *http.MockClient[loginResult] as *appstore ) BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) + mockPurchaseClient = http.NewMockClient[purchaseResult](ctrl) + mockLoginClient = http.NewMockClient[loginResult](ctrl) mockKeychain = keychain.NewMockKeychain(ctrl) - mockPurchaseClient = http.NewMockClient[PurchaseResult](ctrl) - mockSearchClient = http.NewMockClient[SearchResult](ctrl) - mockLoginClient = http.NewMockClient[LoginResult](ctrl) - mockMachine = util.NewMockMachine(ctrl) - mockLogger = log.NewMockLogger(ctrl) + mockMachine = machine.NewMockMachine(ctrl) as = &appstore{ keychain: mockKeychain, purchaseClient: mockPurchaseClient, - searchClient: mockSearchClient, loginClient: mockLoginClient, machine: mockMachine, - logger: mockLogger, } }) @@ -48,435 +42,167 @@ var _ = Describe("AppStore (Purchase)", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). - Return("", ErrGetMAC) + Return("", errors.New("")) }) It("returns error", func() { - err := as.Purchase("") - Expect(err).To(MatchError(ContainSubstring(ErrGetMAC.Error()))) - }) - }) - - When("not logged in", func() { - BeforeEach(func() { - mockMachine.EXPECT(). - MacAddress(). - Return("00:00:00:00:00:00", nil) - - mockLogger.EXPECT(). - Verbose(). - Return(nil) - - mockKeychain.EXPECT(). - Get("account"). - Return(nil, ErrGetKeychainItem) - }) - - It("returns error", func() { - err := as.Purchase("") - Expect(err).To(MatchError(ContainSubstring(ErrGetKeychainItem.Error()))) - Expect(err).To(MatchError(ContainSubstring(ErrGetAccount.Error()))) - }) - }) - - When("country code is invalid", func() { - BeforeEach(func() { - mockLogger.EXPECT(). - Verbose(). - Return(nil) - - mockMachine.EXPECT(). - MacAddress(). - Return("00:00:00:00:00:00", nil) - - mockKeychain.EXPECT(). - Get("account"). - Return([]byte("{}"), nil) - }) - - It("returns error", func() { - err := as.Purchase("") - Expect(err).To(MatchError(ContainSubstring(ErrInvalidCountryCode.Error()))) - }) - }) - - When("app lookup fails", func() { - BeforeEach(func() { - mockKeychain.EXPECT(). - Get("account"). - Return([]byte("{\"storeFront\":\"143441\"}"), nil) - - mockSearchClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[SearchResult]{}, ErrRequest) - - mockMachine.EXPECT(). - MacAddress(). - Return("00:00:00:00:00:00", nil) - - mockLogger.EXPECT(). - Verbose(). - Return(nil) - }) - - It("returns error", func() { - err := as.Purchase("") - Expect(err).To(MatchError(ContainSubstring(ErrAppLookup.Error()))) - Expect(err).To(MatchError(ContainSubstring(ErrRequest.Error()))) + err := as.Purchase(PurchaseInput{}) + Expect(err).To(HaveOccurred()) }) }) When("app is paid", func() { BeforeEach(func() { - mockLogger.EXPECT(). - Verbose(). - Return(nil) - mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) - - mockKeychain.EXPECT(). - Get("account"). - Return([]byte("{\"storeFront\":\"143441\"}"), nil) - - mockSearchClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[SearchResult]{ - StatusCode: 200, - Data: SearchResult{ - Count: 1, - Results: []App{ - { - Price: 0.99, - }, - }, - }, - }, nil) }) It("returns error", func() { - err := as.Purchase("") - Expect(err).To(MatchError(ContainSubstring(ErrPaidApp.Error()))) + err := as.Purchase(PurchaseInput{ + Account: Account{ + StoreFront: "143441", + }, + App: App{ + Price: 0.99, + }, + }) + Expect(err).To(HaveOccurred()) }) }) When("purchase request fails", func() { BeforeEach(func() { - mockKeychain.EXPECT(). - Get("account"). - Return([]byte("{\"storeFront\":\"143441\"}"), nil) - - mockSearchClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[SearchResult]{ - StatusCode: 200, - Data: SearchResult{ - Count: 1, - Results: []App{ - { - ID: 0, - BundleID: "", - Name: "", - Version: "", - Price: 0, - }, - }, - }, - }, nil) - mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) mockPurchaseClient.EXPECT(). Send(gomock.Any()). - Return(http.Result[PurchaseResult]{}, ErrRequest) - - mockLogger.EXPECT(). - Verbose(). - Return(nil) + Return(http.Result[purchaseResult]{}, errors.New("")) }) It("returns error", func() { - err := as.Purchase("") - Expect(err).To(MatchError(ContainSubstring(ErrRequest.Error()))) + err := as.Purchase(PurchaseInput{ + Account: Account{ + StoreFront: "143441", + }, + }) + Expect(err).To(HaveOccurred()) }) }) When("password token is expired", func() { BeforeEach(func() { - mockKeychain.EXPECT(). - Get("account"). - Return([]byte("{\"storeFront\":\"143441\"}"), nil) - - mockSearchClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[SearchResult]{ - StatusCode: 200, - Data: SearchResult{ - Count: 1, - Results: []App{ - { - ID: 0, - BundleID: "", - Name: "", - Version: "", - Price: 0, - }, - }, - }, - }, nil) - mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) mockPurchaseClient.EXPECT(). Send(gomock.Any()). - Return(http.Result[PurchaseResult]{ - Data: PurchaseResult{ + Return(http.Result[purchaseResult]{ + Data: purchaseResult{ FailureType: FailureTypePasswordTokenExpired, }, }, nil) - - mockLogger.EXPECT(). - Verbose(). - Return(nil) - }) - - When("renewing credentials fails", func() { - BeforeEach(func() { - mockLoginClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[LoginResult]{ - Data: LoginResult{ - FailureType: "", - CustomerMessage: CustomerMessageBadLogin, - }, - }, nil) - - mockLogger.EXPECT(). - Verbose(). - Return(nil). - Times(2) - }) - - It("returns error", func() { - err := as.Purchase("") - Expect(err).To(MatchError(ContainSubstring(ErrPasswordTokenExpired.Error()))) - }) }) - When("renewing credentials succeeds", func() { - BeforeEach(func() { - mockLoginClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[LoginResult]{ - Data: LoginResult{}, - }, nil) - - mockLogger.EXPECT(). - Verbose(). - Return(nil). - Times(2) - - mockKeychain.EXPECT(). - Set("account", gomock.Any()). - Return(nil) - - mockPurchaseClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[PurchaseResult]{ - Data: PurchaseResult{ - FailureType: FailureTypePasswordTokenExpired, - }, - }, nil) - }) - - It("attempts to purchase app", func() { - err := as.Purchase("") - Expect(err).To(MatchError(ContainSubstring(ErrPasswordTokenExpired.Error()))) + It("returns error", func() { + err := as.Purchase(PurchaseInput{ + Account: Account{ + StoreFront: "143441", + }, }) + Expect(err).To(HaveOccurred()) }) }) When("store API returns customer error message", func() { BeforeEach(func() { - mockKeychain.EXPECT(). - Get("account"). - Return([]byte("{\"storeFront\":\"143441\"}"), nil) - - mockSearchClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[SearchResult]{ - StatusCode: 200, - Data: SearchResult{ - Count: 1, - Results: []App{ - { - ID: 0, - BundleID: "", - Name: "", - Version: "", - Price: 0, - }, - }, - }, - }, nil) - mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) mockPurchaseClient.EXPECT(). Send(gomock.Any()). - Return(http.Result[PurchaseResult]{ - Data: PurchaseResult{ + Return(http.Result[purchaseResult]{ + Data: purchaseResult{ FailureType: "failure", CustomerMessage: CustomerMessageBadLogin, }, }, nil) - - mockLogger.EXPECT(). - Verbose(). - Return(nil). - Times(2) }) It("returns error", func() { - err := as.Purchase("") - Expect(err).To(MatchError(ContainSubstring(CustomerMessageBadLogin))) + err := as.Purchase(PurchaseInput{ + Account: Account{ + StoreFront: "143441", + }, + }) + Expect(err).To(HaveOccurred()) }) }) When("store API returns unknown error", func() { BeforeEach(func() { - mockKeychain.EXPECT(). - Get("account"). - Return([]byte("{\"storeFront\":\"143441\"}"), nil) - - mockSearchClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[SearchResult]{ - StatusCode: 200, - Data: SearchResult{ - Count: 1, - Results: []App{ - { - ID: 0, - BundleID: "", - Name: "", - Version: "", - Price: 0, - }, - }, - }, - }, nil) - mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) mockPurchaseClient.EXPECT(). Send(gomock.Any()). - Return(http.Result[PurchaseResult]{ - Data: PurchaseResult{ + Return(http.Result[purchaseResult]{ + Data: purchaseResult{ FailureType: "failure", }, }, nil) - - mockLogger.EXPECT(). - Verbose(). - Return(nil). - Times(2) }) It("returns error", func() { - err := as.Purchase("") - Expect(err).To(MatchError(ContainSubstring(ErrGeneric.Error()))) + err := as.Purchase(PurchaseInput{ + Account: Account{ + StoreFront: "143441", + }, + }) + Expect(err).To(HaveOccurred()) }) }) When("account already has a license for the app", func() { BeforeEach(func() { - mockKeychain.EXPECT(). - Get("account"). - Return([]byte("{\"storeFront\":\"143441\"}"), nil) - - mockSearchClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[SearchResult]{ - StatusCode: 200, - Data: SearchResult{ - Count: 1, - Results: []App{ - { - ID: 0, - BundleID: "", - Name: "", - Version: "", - Price: 0, - }, - }, - }, - }, nil) - mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) mockPurchaseClient.EXPECT(). Send(gomock.Any()). - Return(http.Result[PurchaseResult]{ + Return(http.Result[purchaseResult]{ StatusCode: 500, - Data: PurchaseResult{}, + Data: purchaseResult{}, }, nil) - - mockLogger.EXPECT(). - Verbose(). - Return(nil) }) It("returns error", func() { - err := as.Purchase("") - Expect(err).To(MatchError(ContainSubstring(ErrLicenseExists.Error()))) + err := as.Purchase(PurchaseInput{ + Account: Account{ + StoreFront: "143441", + }, + }) + Expect(err).To(HaveOccurred()) }) }) When("subscription is required", func() { BeforeEach(func() { - mockKeychain.EXPECT(). - Get("account"). - Return([]byte("{\"storeFront\":\"143441\"}"), nil) - - mockSearchClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[SearchResult]{ - StatusCode: 200, - Data: SearchResult{ - Count: 1, - Results: []App{ - { - ID: 0, - BundleID: "", - Name: "", - Version: "", - Price: 0, - }, - }, - }, - }, nil) - mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) mockPurchaseClient.EXPECT(). Send(pricingParametersMatcher{"STDQ"}). - Return(http.Result[PurchaseResult]{ + Return(http.Result[purchaseResult]{ StatusCode: 200, - Data: PurchaseResult{ + Data: purchaseResult{ CustomerMessage: "This item is temporarily unavailable.", FailureType: FailureTypeTemporarilyUnavailable, }, @@ -484,58 +210,35 @@ var _ = Describe("AppStore (Purchase)", func() { mockPurchaseClient.EXPECT(). Send(pricingParametersMatcher{"GAME"}). - Return(http.Result[PurchaseResult]{ + Return(http.Result[purchaseResult]{ StatusCode: 200, - Data: PurchaseResult{ + Data: purchaseResult{ CustomerMessage: CustomerMessageSubscriptionRequired, }, }, nil) - - mockLogger.EXPECT(). - Verbose(). - Return(nil). - Times(2) }) It("returns error", func() { - err := as.Purchase("") - Expect(err).To(MatchError(ContainSubstring(ErrSubscriptionRequired.Error()))) + err := as.Purchase(PurchaseInput{ + Account: Account{ + StoreFront: "143441", + }, + }) + Expect(err).To(HaveOccurred()) }) }) When("successfully purchases the app", func() { BeforeEach(func() { - mockKeychain.EXPECT(). - Get("account"). - Return([]byte("{\"storeFront\":\"143441\"}"), nil) - - mockSearchClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[SearchResult]{ - StatusCode: 200, - Data: SearchResult{ - Count: 1, - Results: []App{ - { - ID: 0, - BundleID: "", - Name: "", - Version: "", - Price: 0, - }, - }, - }, - }, nil) - mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) mockPurchaseClient.EXPECT(). Send(pricingParametersMatcher{"STDQ"}). - Return(http.Result[PurchaseResult]{ + Return(http.Result[purchaseResult]{ StatusCode: 200, - Data: PurchaseResult{ + Data: purchaseResult{ CustomerMessage: "This item is temporarily unavailable.", FailureType: FailureTypeTemporarilyUnavailable, }, @@ -543,72 +246,49 @@ var _ = Describe("AppStore (Purchase)", func() { mockPurchaseClient.EXPECT(). Send(pricingParametersMatcher{"GAME"}). - Return(http.Result[PurchaseResult]{ + Return(http.Result[purchaseResult]{ StatusCode: 200, - Data: PurchaseResult{ + Data: purchaseResult{ JingleDocType: "purchaseSuccess", Status: 0, }, }, nil) - - mockLogger.EXPECT(). - Verbose(). - Return(nil) }) It("returns nil", func() { - err := as.Purchase("") + err := as.Purchase(PurchaseInput{ + Account: Account{ + StoreFront: "143441", + }, + }) Expect(err).ToNot(HaveOccurred()) }) }) When("purchasing the app fails", func() { BeforeEach(func() { - mockKeychain.EXPECT(). - Get("account"). - Return([]byte("{\"storeFront\":\"143441\"}"), nil) - - mockSearchClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[SearchResult]{ - StatusCode: 200, - Data: SearchResult{ - Count: 1, - Results: []App{ - { - ID: 0, - BundleID: "", - Name: "", - Version: "", - Price: 0, - }, - }, - }, - }, nil) - mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) mockPurchaseClient.EXPECT(). Send(gomock.Any()). - Return(http.Result[PurchaseResult]{ + Return(http.Result[purchaseResult]{ StatusCode: 200, - Data: PurchaseResult{ + Data: purchaseResult{ JingleDocType: "failure", Status: -1, }, }, nil) - - mockLogger.EXPECT(). - Verbose(). - Return(nil). - Times(2) }) It("returns nil", func() { - err := as.Purchase("") - Expect(err).To(MatchError(ContainSubstring(ErrPurchase.Error()))) + err := as.Purchase(PurchaseInput{ + Account: Account{ + StoreFront: "143441", + }, + }) + Expect(err).To(HaveOccurred()) }) }) }) diff --git a/pkg/appstore/appstore_replicate_sinf.go b/pkg/appstore/appstore_replicate_sinf.go new file mode 100644 index 00000000..d28fc102 --- /dev/null +++ b/pkg/appstore/appstore_replicate_sinf.go @@ -0,0 +1,224 @@ +package appstore + +import ( + "archive/zip" + "bytes" + "errors" + "fmt" + "github.com/majd/ipatool/pkg/util" + "howett.net/plist" + "io" + "os" + "path/filepath" + "strings" +) + +type Sinf struct { + ID int64 `plist:"id,omitempty"` + Data []byte `plist:"sinf,omitempty"` +} + +type ReplicateSinfInput struct { + Sinfs []Sinf + PackagePath string +} + +func (t *appstore) ReplicateSinf(input ReplicateSinfInput) error { + zipReader, err := zip.OpenReader(input.PackagePath) + if err != nil { + return errors.New("failed to open zip reader") + } + defer zipReader.Close() + + tmpPath := fmt.Sprintf("%s.tmp", input.PackagePath) + tmpFile, err := t.os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + + zipWriter := zip.NewWriter(tmpFile) + defer zipWriter.Close() + + err = t.replicateZip(zipReader, zipWriter) + if err != nil { + return fmt.Errorf("failed to replicate zip: %w", err) + } + + bundleName, err := t.readBundleName(zipReader) + if err != nil { + return fmt.Errorf("failed to read bundle name: %w", err) + } + + manifest, err := t.readManifestPlist(zipReader) + if err != nil { + return fmt.Errorf("failed to read manifest plist: %w", err) + } + + info, err := t.readInfoPlist(zipReader) + if err != nil { + return fmt.Errorf("failed to read info plist: %w", err) + } + + fmt.Println(manifest != nil) + if manifest != nil { + err = t.replicateSinfFromManifest(*manifest, zipWriter, input.Sinfs, bundleName) + } else { + err = t.replicateSinfFromInfo(*info, zipWriter, input.Sinfs, bundleName) + } + + if err != nil { + return fmt.Errorf("failed to replicate sinf: %w", err) + } + + err = t.os.Remove(input.PackagePath) + if err != nil { + return fmt.Errorf("failed to remove original file: %w", err) + } + + err = t.os.Rename(tmpPath, input.PackagePath) + if err != nil { + return fmt.Errorf("failed to remove original file: %w", err) + } + + return nil +} + +type packageManifest struct { + SinfPaths []string `plist:"SinfPaths,omitempty"` +} + +type packageInfo struct { + BundleExecutable string `plist:"CFBundleExecutable,omitempty"` +} + +func (*appstore) replicateSinfFromManifest(manifest packageManifest, zip *zip.Writer, sinfs []Sinf, bundleName string) error { + zipped, err := util.Zip(sinfs, manifest.SinfPaths) + if err != nil { + return fmt.Errorf("failed to zip sinfs: %w", err) + } + + for _, pair := range zipped { + sp := fmt.Sprintf("Payload/%s.app/%s", bundleName, pair.Second) + + file, err := zip.Create(sp) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + + _, err = file.Write(pair.First.Data) + if err != nil { + return fmt.Errorf("failed to write data: %w", err) + } + } + + return nil +} + +func (t *appstore) replicateSinfFromInfo(info packageInfo, zip *zip.Writer, sinfs []Sinf, bundleName string) error { + sp := fmt.Sprintf("Payload/%s.app/SC_Info/%s.sinf", bundleName, info.BundleExecutable) + + file, err := zip.Create(sp) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + + _, err = file.Write(sinfs[0].Data) + if err != nil { + return fmt.Errorf("failed to write data: %w", err) + } + + return nil +} + +func (t *appstore) replicateZip(src *zip.ReadCloser, dst *zip.Writer) error { + for _, file := range src.File { + srcFile, err := file.OpenRaw() + if err != nil { + return fmt.Errorf("failed to open raw file: %w", err) + } + + header := file.FileHeader + dstFile, err := dst.CreateRaw(&header) + if err != nil { + return fmt.Errorf("failed to create raw file: %w", err) + } + + _, err = io.Copy(dstFile, srcFile) + if err != nil { + return fmt.Errorf("failed to copy file: %w", err) + } + } + + return nil +} + +func (*appstore) readInfoPlist(reader *zip.ReadCloser) (*packageInfo, error) { + for _, file := range reader.File { + if strings.Contains(file.Name, ".app/Info.plist") { + src, err := file.Open() + if err != nil { + return nil, fmt.Errorf("failed to open file: %w", err) + } + + data := new(bytes.Buffer) + _, err = io.Copy(data, src) + if err != nil { + return nil, fmt.Errorf("failed to copy data: %w", err) + } + + var info packageInfo + _, err = plist.Unmarshal(data.Bytes(), &info) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal data: %w", err) + } + + return &info, nil + } + } + + return nil, nil +} + +func (*appstore) readManifestPlist(reader *zip.ReadCloser) (*packageManifest, error) { + for _, file := range reader.File { + if strings.HasSuffix(file.Name, ".app/SC_Info/Manifest.plist") { + src, err := file.Open() + if err != nil { + return nil, fmt.Errorf("failed to open file: %w", err) + } + + data := new(bytes.Buffer) + _, err = io.Copy(data, src) + if err != nil { + return nil, fmt.Errorf("failed to copy data: %w", err) + } + + var manifest packageManifest + _, err = plist.Unmarshal(data.Bytes(), &manifest) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal data: %w", err) + } + + return &manifest, nil + } + } + + return nil, nil +} + +func (*appstore) readBundleName(reader *zip.ReadCloser) (string, error) { + var bundleName string + + for _, file := range reader.File { + if strings.Contains(file.Name, ".app/Info.plist") && !strings.Contains(file.Name, "/Watch/") { + bundleName = filepath.Base(strings.TrimSuffix(file.Name, ".app/Info.plist")) + break + } + } + + if bundleName == "" { + return "", errors.New("could not read bundle name") + } + + return bundleName, nil +} diff --git a/pkg/appstore/appstore_replicate_sinf_test.go b/pkg/appstore/appstore_replicate_sinf_test.go new file mode 100644 index 00000000..21fc0a4a --- /dev/null +++ b/pkg/appstore/appstore_replicate_sinf_test.go @@ -0,0 +1,203 @@ +package appstore + +import ( + "archive/zip" + "errors" + "fmt" + "github.com/golang/mock/gomock" + "github.com/majd/ipatool/pkg/http" + "github.com/majd/ipatool/pkg/keychain" + "github.com/majd/ipatool/pkg/util/machine" + "github.com/majd/ipatool/pkg/util/operatingsystem" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "howett.net/plist" + "os" +) + +var _ = Describe("AppStore (ReplicateSinf)", func() { + var ( + ctrl *gomock.Controller + mockKeychain *keychain.MockKeychain + mockDownloadClient *http.MockClient[downloadResult] + mockPurchaseClient *http.MockClient[purchaseResult] + mockLoginClient *http.MockClient[loginResult] + mockHTTPClient *http.MockClient[interface{}] + mockOS *operatingsystem.MockOperatingSystem + mockMachine *machine.MockMachine + as AppStore + testFile *os.File + testZip *zip.Writer + ) + + BeforeEach(func() { + ctrl = gomock.NewController(GinkgoT()) + mockKeychain = keychain.NewMockKeychain(ctrl) + mockDownloadClient = http.NewMockClient[downloadResult](ctrl) + mockLoginClient = http.NewMockClient[loginResult](ctrl) + mockPurchaseClient = http.NewMockClient[purchaseResult](ctrl) + mockHTTPClient = http.NewMockClient[interface{}](ctrl) + mockOS = operatingsystem.NewMockOperatingSystem(ctrl) + mockMachine = machine.NewMockMachine(ctrl) + as = &appstore{ + keychain: mockKeychain, + loginClient: mockLoginClient, + purchaseClient: mockPurchaseClient, + downloadClient: mockDownloadClient, + httpClient: mockHTTPClient, + machine: mockMachine, + os: mockOS, + } + + var err error + testFile, err = os.CreateTemp("", "test_file") + Expect(err).ToNot(HaveOccurred()) + + testZip = zip.NewWriter(testFile) + }) + + JustBeforeEach(func() { + testZip.Close() + }) + + AfterEach(func() { + err := os.Remove(testFile.Name()) + Expect(err).ToNot(HaveOccurred()) + + ctrl.Finish() + }) + + When("app includes codesign manifest", func() { + BeforeEach(func() { + mockOS.EXPECT(). + OpenFile(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(name string, flag int, perm os.FileMode) (*os.File, error) { + return os.OpenFile(name, flag, perm) + }) + + mockOS.EXPECT(). + Remove(testFile.Name()). + Return(nil) + + mockOS.EXPECT(). + Rename(fmt.Sprintf("%s.tmp", testFile.Name()), testFile.Name()). + Return(nil) + + manifest, err := plist.Marshal(packageManifest{ + SinfPaths: []string{ + "SC_Info/TestApp.sinf", + }, + }, plist.BinaryFormat) + Expect(err).ToNot(HaveOccurred()) + + w, err := testZip.Create("Payload/Test.app/SC_Info/Manifest.plist") + Expect(err).ToNot(HaveOccurred()) + + _, err = w.Write(manifest) + Expect(err).ToNot(HaveOccurred()) + + w, err = testZip.Create("Payload/Test.app/Info.plist") + Expect(err).ToNot(HaveOccurred()) + + info, err := plist.Marshal(map[string]interface{}{ + "CFBundleExecutable": "Test", + }, plist.BinaryFormat) + Expect(err).ToNot(HaveOccurred()) + + _, err = w.Write(info) + Expect(err).ToNot(HaveOccurred()) + + w, err = testZip.Create("Payload/Test.app/Watch/Test.app/Info.plist") + Expect(err).ToNot(HaveOccurred()) + + watchInfo, err := plist.Marshal(map[string]interface{}{ + "WKWatchKitApp": true, + }, plist.BinaryFormat) + Expect(err).ToNot(HaveOccurred()) + + _, err = w.Write(watchInfo) + Expect(err).ToNot(HaveOccurred()) + }) + + It("replicates sinf from manifest plist", func() { + err := as.ReplicateSinf(ReplicateSinfInput{ + PackagePath: testFile.Name(), + Sinfs: []Sinf{ + { + ID: 0, + Data: []byte(""), + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + When("app does not include codesign manifest", func() { + BeforeEach(func() { + mockOS.EXPECT(). + OpenFile(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(name string, flag int, perm os.FileMode) (*os.File, error) { + return os.OpenFile(name, flag, perm) + }) + + mockOS.EXPECT(). + Remove(testFile.Name()). + Return(nil) + + mockOS.EXPECT(). + Rename(fmt.Sprintf("%s.tmp", testFile.Name()), testFile.Name()). + Return(nil) + + w, err := testZip.Create("Payload/Test.app/Info.plist") + Expect(err).ToNot(HaveOccurred()) + + info, err := plist.Marshal(map[string]interface{}{ + "CFBundleExecutable": "Test", + }, plist.BinaryFormat) + Expect(err).ToNot(HaveOccurred()) + + _, err = w.Write(info) + Expect(err).ToNot(HaveOccurred()) + + w, err = testZip.Create("Payload/Test.app/Watch/Test.app/Info.plist") + Expect(err).ToNot(HaveOccurred()) + + watchInfo, err := plist.Marshal(map[string]interface{}{ + "WKWatchKitApp": true, + }, plist.BinaryFormat) + Expect(err).ToNot(HaveOccurred()) + + _, err = w.Write(watchInfo) + Expect(err).ToNot(HaveOccurred()) + }) + + It("replicates sinf", func() { + err := as.ReplicateSinf(ReplicateSinfInput{ + PackagePath: testFile.Name(), + Sinfs: []Sinf{ + { + ID: 0, + Data: []byte(""), + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + When("fails to open file", func() { + BeforeEach(func() { + mockOS.EXPECT(). + OpenFile(gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, errors.New("")) + }) + + It("returns error", func() { + err := as.ReplicateSinf(ReplicateSinfInput{ + PackagePath: testFile.Name(), + }) + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/pkg/appstore/appstore_revoke.go b/pkg/appstore/appstore_revoke.go index 356dfd0e..154d88da 100644 --- a/pkg/appstore/appstore_revoke.go +++ b/pkg/appstore/appstore_revoke.go @@ -1,13 +1,13 @@ package appstore import ( - "github.com/pkg/errors" + "fmt" ) -func (a *appstore) Revoke() error { - err := a.keychain.Remove("account") +func (t *appstore) Revoke() error { + err := t.keychain.Remove("account") if err != nil { - return errors.Wrap(err, ErrRemoveKeychainItem.Error()) + return fmt.Errorf("failed to remove account from keychain: %w", err) } return nil diff --git a/pkg/appstore/appstore_revoke_test.go b/pkg/appstore/appstore_revoke_test.go index 0fe7d092..62c18b2b 100644 --- a/pkg/appstore/appstore_revoke_test.go +++ b/pkg/appstore/appstore_revoke_test.go @@ -1,11 +1,11 @@ package appstore import ( + "errors" "github.com/golang/mock/gomock" "github.com/majd/ipatool/pkg/keychain" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/pkg/errors" ) var _ = Describe("AppStore (Revoke)", func() { @@ -18,7 +18,7 @@ var _ = Describe("AppStore (Revoke)", func() { BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) mockKeychain = keychain.NewMockKeychain(ctrl) - appstore = NewAppStore(AppStoreArgs{ + appstore = NewAppStore(Args{ Keychain: mockKeychain, }) }) @@ -27,32 +27,29 @@ var _ = Describe("AppStore (Revoke)", func() { ctrl.Finish() }) - When("keychain returns error", func() { - var testErr = errors.New("test error") - + When("keychain removes item", func() { BeforeEach(func() { mockKeychain.EXPECT(). Remove("account"). - Return(testErr) + Return(nil) }) - It("returns wrapped error", func() { + It("returns data", func() { err := appstore.Revoke() - Expect(err).To(MatchError(ContainSubstring(testErr.Error()))) - Expect(err).To(MatchError(ContainSubstring(ErrRemoveKeychainItem.Error()))) + Expect(err).ToNot(HaveOccurred()) }) }) - When("keychain removes item", func() { + When("keychain returns error", func() { BeforeEach(func() { mockKeychain.EXPECT(). Remove("account"). - Return(nil) + Return(errors.New("")) }) - It("returns data", func() { + It("returns wrapped error", func() { err := appstore.Revoke() - Expect(err).ToNot(HaveOccurred()) + Expect(err).To(HaveOccurred()) }) }) }) diff --git a/pkg/appstore/appstore_search.go b/pkg/appstore/appstore_search.go index 94147b06..9154b85a 100644 --- a/pkg/appstore/appstore_search.go +++ b/pkg/appstore/appstore_search.go @@ -1,55 +1,61 @@ package appstore import ( + "errors" "fmt" "github.com/majd/ipatool/pkg/http" - "github.com/pkg/errors" "net/url" "strconv" ) -type SearchResult struct { - Count int `json:"resultCount,omitempty"` - Results []App `json:"results,omitempty"` +type SearchInput struct { + Account Account + Term string + Limit int64 } -type SearchOutput = SearchResult - -func (a *appstore) Search(term string, limit int64) (SearchOutput, error) { - acc, err := a.account() - if err != nil { - return SearchOutput{}, errors.Wrap(err, ErrGetAccount.Error()) - } +type SearchOutput struct { + Count int + Results []App +} - countryCode, err := a.countryCodeFromStoreFront(acc.StoreFront) +func (t *appstore) Search(input SearchInput) (SearchOutput, error) { + countryCode, err := countryCodeFromStoreFront(input.Account.StoreFront) if err != nil { - return SearchOutput{}, errors.Wrap(err, ErrInvalidCountryCode.Error()) + return SearchOutput{}, fmt.Errorf("country code is invalid: %w", err) } - request := a.searchRequest(term, countryCode, limit) + request := t.searchRequest(input.Term, countryCode, input.Limit) - res, err := a.searchClient.Send(request) + res, err := t.searchClient.Send(request) if err != nil { - return SearchOutput{}, errors.Wrap(err, ErrRequest.Error()) + return SearchOutput{}, fmt.Errorf("request failed: %w", err) } if res.StatusCode != 200 { - a.logger.Verbose().Interface("data", res.Data).Int("status", res.StatusCode).Send() - return SearchOutput{}, ErrRequest + return SearchOutput{}, NewErrorWithMetadata(errors.New("request failed"), res) } - return res.Data, nil + return SearchOutput{ + Count: res.Data.Count, + Results: res.Data.Results, + }, nil +} + +type searchResult struct { + Count int `json:"resultCount,omitempty"` + Results []App `json:"results,omitempty"` } -func (a *appstore) searchRequest(term, countryCode string, limit int64) http.Request { +func (t *appstore) searchRequest(term, countryCode string, limit int64) http.Request { return http.Request{ - URL: a.searchURL(term, countryCode, limit), + URL: t.searchURL(term, countryCode, limit), Method: http.MethodGET, ResponseFormat: http.ResponseFormatJSON, } } -func (a *appstore) searchURL(term, countryCode string, limit int64) string { +func (t *appstore) searchURL(term, countryCode string, limit int64) string { params := url.Values{} params.Add("entity", "software,iPadSoftware") params.Add("limit", strconv.Itoa(int(limit))) diff --git a/pkg/appstore/appstore_search_test.go b/pkg/appstore/appstore_search_test.go index 28e81228..b2aac09f 100644 --- a/pkg/appstore/appstore_search_test.go +++ b/pkg/appstore/appstore_search_test.go @@ -1,35 +1,25 @@ package appstore import ( + "errors" "github.com/golang/mock/gomock" "github.com/majd/ipatool/pkg/http" - "github.com/majd/ipatool/pkg/keychain" - "github.com/majd/ipatool/pkg/log" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/pkg/errors" - "os" ) var _ = Describe("AppStore (Search)", func() { var ( - ctrl *gomock.Controller - mockClient *http.MockClient[SearchResult] - mockLogger *log.MockLogger - mockKeychain *keychain.MockKeychain - as AppStore + ctrl *gomock.Controller + mockClient *http.MockClient[searchResult] + as AppStore ) BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) - mockClient = http.NewMockClient[SearchResult](ctrl) - mockLogger = log.NewMockLogger(ctrl) - mockKeychain = keychain.NewMockKeychain(ctrl) + mockClient = http.NewMockClient[searchResult](ctrl) as = &appstore{ searchClient: mockClient, - ioReader: os.Stdin, - logger: mockLogger, - keychain: mockKeychain, } }) @@ -37,75 +27,6 @@ var _ = Describe("AppStore (Search)", func() { ctrl.Finish() }) - When("user is not logged in", func() { - BeforeEach(func() { - mockKeychain.EXPECT(). - Get("account"). - Return(nil, ErrGetKeychainItem) - }) - - It("returns error", func() { - _, err := as.Search("", 0) - Expect(err).To(MatchError(ContainSubstring(ErrGetAccount.Error()))) - }) - }) - - When("country code is invalid", func() { - BeforeEach(func() { - mockKeychain.EXPECT(). - Get("account"). - Return([]byte("{}"), nil) - }) - - It("returns error", func() { - _, err := as.Search("", 0) - Expect(err).To(MatchError(ContainSubstring(ErrInvalidCountryCode.Error()))) - }) - }) - - When("request fails", func() { - var testErr = errors.New("test") - - BeforeEach(func() { - mockKeychain.EXPECT(). - Get("account"). - Return([]byte("{\"storeFront\":\"143441\"}"), nil) - - mockClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[SearchResult]{}, testErr) - }) - - It("returns error", func() { - _, err := as.Search("", 0) - Expect(err).To(MatchError(ContainSubstring(testErr.Error()))) - Expect(err).To(MatchError(ContainSubstring(ErrRequest.Error()))) - }) - }) - - When("request returns bad status code", func() { - BeforeEach(func() { - mockLogger.EXPECT(). - Verbose(). - Return(nil) - - mockClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[SearchResult]{ - StatusCode: 400, - }, nil) - - mockKeychain.EXPECT(). - Get("account"). - Return([]byte("{\"storeFront\":\"143441\"}"), nil) - }) - - It("returns error", func() { - _, err := as.Search("", 0) - Expect(err).To(MatchError(ContainSubstring(ErrRequest.Error()))) - }) - }) - When("request is successful", func() { const ( testID = 0 @@ -118,9 +39,9 @@ var _ = Describe("AppStore (Search)", func() { BeforeEach(func() { mockClient.EXPECT(). Send(gomock.Any()). - Return(http.Result[SearchResult]{ + Return(http.Result[searchResult]{ StatusCode: 200, - Data: SearchResult{ + Data: searchResult{ Count: 1, Results: []App{ { @@ -133,14 +54,14 @@ var _ = Describe("AppStore (Search)", func() { }, }, }, nil) - - mockKeychain.EXPECT(). - Get("account"). - Return([]byte("{\"storeFront\":\"143441\"}"), nil) }) It("returns output", func() { - out, err := as.Search("", 0) + out, err := as.Search(SearchInput{ + Account: Account{ + StoreFront: "143441", + }, + }) Expect(err).ToNot(HaveOccurred()) Expect(out.Count).To(Equal(1)) Expect(len(out.Results)).To(Equal(1)) @@ -153,4 +74,51 @@ var _ = Describe("AppStore (Search)", func() { })) }) }) + + When("store front is invalid", func() { + It("returns error", func() { + _, err := as.Search(SearchInput{ + Account: Account{ + StoreFront: "xyz", + }, + }) + Expect(err).To(HaveOccurred()) + }) + }) + + When("request fails", func() { + BeforeEach(func() { + mockClient.EXPECT(). + Send(gomock.Any()). + Return(http.Result[searchResult]{}, errors.New("")) + }) + + It("returns error", func() { + _, err := as.Search(SearchInput{ + Account: Account{ + StoreFront: "143441", + }, + }) + Expect(err).To(HaveOccurred()) + }) + }) + + When("request returns bad status code", func() { + BeforeEach(func() { + mockClient.EXPECT(). + Send(gomock.Any()). + Return(http.Result[searchResult]{ + StatusCode: 400, + }, nil) + }) + + It("returns error", func() { + _, err := as.Search(SearchInput{ + Account: Account{ + StoreFront: "143441", + }, + }) + Expect(err).To(HaveOccurred()) + }) + }) }) diff --git a/pkg/appstore/constants.go b/pkg/appstore/constants.go index 829fc642..40d0607e 100644 --- a/pkg/appstore/constants.go +++ b/pkg/appstore/constants.go @@ -13,8 +13,8 @@ const ( iTunesAPIPathSearch = "/search" iTunesAPIPathLookup = "/lookup" - PriavteAppStoreAPIDomainPrefixWithoutAuthCode = "p25" - PriavteAppStoreAPIDomainPrefixWithAuthCode = "p71" + PrivateAppStoreAPIDomainPrefixWithoutAuthCode = "p25" + PrivateAppStoreAPIDomainPrefixWithAuthCode = "p71" PrivateAppStoreAPIDomain = "buy." + iTunesAPIDomain PrivateAppStoreAPIPathAuthenticate = "/WebObjects/MZFinance.woa/wa/authenticate" PrivateAppStoreAPIPathPurchase = "/WebObjects/MZBuy.woa/wa/buyProduct" diff --git a/pkg/appstore/error.go b/pkg/appstore/error.go new file mode 100644 index 00000000..f35542af --- /dev/null +++ b/pkg/appstore/error.go @@ -0,0 +1,17 @@ +package appstore + +type Error struct { + Metadata interface{} + underlyingError error +} + +func (t Error) Error() string { + return t.underlyingError.Error() +} + +func NewErrorWithMetadata(err error, metadata interface{}) *Error { + return &Error{ + underlyingError: err, + Metadata: metadata, + } +} diff --git a/pkg/appstore/errors.go b/pkg/appstore/errors.go deleted file mode 100644 index d6a01917..00000000 --- a/pkg/appstore/errors.go +++ /dev/null @@ -1,58 +0,0 @@ -package appstore - -import "errors" - -var ( - ErrAppLookup = errors.New("failed to find app") - ErrAppNotFound = errors.New("failed to find app on the App Store") - ErrApplyLegacyPatches = errors.New("failed to apply legacy patches") - ErrApplyPatches = errors.New("failed to apply patches") - ErrAuthCodeRequired = errors.New("auth code is required") - ErrCheckDirectory = errors.New("failed to determine if the supplied path is a directory") - ErrCreateDestinationFile = errors.New("failed to create destination file") - ErrCreateMetadataFile = errors.New("failed to create metadata file") - ErrCreateRequest = errors.New("failed to create HTTP request") - ErrCreateSinfFile = errors.New("failed to create sinf file") - ErrDecompressInfoFile = errors.New("failed to open decompressed info file") - ErrDecompressManifestFile = errors.New("failed to open decompressed manifest file") - ErrDownloadFile = errors.New("failed to download file") - ErrEncodeMetadataFile = errors.New("failed to encode metadata") - ErrFileWrite = errors.New("failed to write file") - ErrGeneric = errors.New("failed due to an unknown error") - ErrGetAccount = errors.New("failed to get account") - ErrGetBundleName = errors.New("failed to determine name of app bundle") - ErrGetCurrentDirectory = errors.New("failed to get current directory path") - ErrGetData = errors.New("failed to get data") - ErrGetExecutablePath = errors.New("failed to get executable path") - ErrGetFileMetadata = errors.New("failed to get file metadata") - ErrGetInfoFile = errors.New("failed to read info file") - ErrGetKeychainItem = errors.New("failed to read item from keychain") - ErrGetMAC = errors.New("failed to get MAC address") - ErrGetManifestFile = errors.New("failed to read manifest file") - ErrInvalidCountryCode = errors.New("invalid country code") - ErrInvalidResponse = errors.New("received 0 items from the App Store; expected 1 or more") - ErrInvalidStoreFront = errors.New("could not infer country code from store front") - ErrLicenseExists = errors.New("account already has a license for this app") - ErrLicenseRequired = errors.New("license is required") - ErrLogin = errors.New("failed to log in") - ErrMarshal = errors.New("failed to marshal data") - ErrOpenFile = errors.New("failed to open file") - ErrOpenZipFile = errors.New("failed to open zip file") - ErrPaidApp = errors.New("paid apps cannot be purchased") - ErrPasswordRequired = errors.New("password is required when not running in interactive mode; use the \"--password\" flag") - ErrPasswordTokenExpired = errors.New("password token is expired") - ErrPatchApp = errors.New("failed to patch app package") - ErrPurchase = errors.New("failed to purchase app") - ErrRemoveKeychainItem = errors.New("failed to remove item from keychain") - ErrRemoveTempFile = errors.New("failed to remove temporary file") - ErrReplicateZip = errors.New("failed to replicate zip") - ErrRequest = errors.New("failed to send request") - ErrResolveDestinationPath = errors.New("failed to resolve destination path") - ErrSetKeychainItem = errors.New("failed to save item in keychain") - ErrSubscriptionRequired = errors.New("subscription required") - ErrTemporarilyUnavailable = errors.New("item is temporarily unavailable") - ErrUnmarshal = errors.New("failed to unmarshal data") - ErrWriteMetadataFile = errors.New("failed to write metadata") - ErrWriteSinfData = errors.New("failed to write sinf data") - ErrZipSinfs = errors.New("failed to zip sinfs and sinf paths") -) diff --git a/pkg/appstore/store_front.go b/pkg/appstore/storefront.go similarity index 85% rename from pkg/appstore/store_front.go rename to pkg/appstore/storefront.go index 541cfa86..0e188bb3 100644 --- a/pkg/appstore/store_front.go +++ b/pkg/appstore/storefront.go @@ -1,6 +1,23 @@ package appstore -var StoreFronts = map[string]string{ +import ( + "fmt" + "strings" +) + +func countryCodeFromStoreFront(storeFront string) (string, error) { + for key, val := range storeFronts { + parts := strings.Split(storeFront, "-") + + if len(parts) >= 1 && parts[0] == val { + return key, nil + } + } + + return "", fmt.Errorf("country code mapping for store front (%s) was not found", storeFront) +} + +var storeFronts = map[string]string{ "AE": "143481", "AG": "143540", "AI": "143538", diff --git a/pkg/http/client.go b/pkg/http/client.go index 72030581..4913c53d 100644 --- a/pkg/http/client.go +++ b/pkg/http/client.go @@ -3,14 +3,14 @@ package http import ( "bytes" "encoding/json" - "github.com/pkg/errors" + "fmt" "howett.net/plist" "io" "net/http" "strings" ) -//go:generate mockgen -source=client.go -destination=client_mock.go -package=http +//go:generate go run github.com/golang/mock/mockgen -source=client.go -destination=client_mock.go -package=http type Client[R interface{}] interface { Send(request Request) (Result[R], error) Do(req *http.Request) (*http.Response, error) @@ -22,7 +22,7 @@ type client[R interface{}] struct { cookieJar CookieJar } -type ClientArgs struct { +type Args struct { CookieJar CookieJar } @@ -37,7 +37,7 @@ func (adt *AddHeaderTransport) RoundTrip(req *http.Request) (*http.Response, err return adt.T.RoundTrip(req) } -func NewClient[R interface{}](args ClientArgs) Client[R] { +func NewClient[R interface{}](args Args) Client[R] { return &client[R]{ internalClient: http.Client{ Timeout: 0, @@ -55,13 +55,13 @@ func (c *client[R]) Send(req Request) (Result[R], error) { if req.Payload != nil { data, err = req.Payload.data() if err != nil { - return Result[R]{}, errors.Wrap(err, ErrGetPayloadData.Error()) + return Result[R]{}, fmt.Errorf("failed to get payload data: %w", err) } } request, err := http.NewRequest(req.Method, req.URL, bytes.NewReader(data)) if err != nil { - return Result[R]{}, errors.Wrap(err, ErrCreateRequest.Error()) + return Result[R]{}, fmt.Errorf("failed to create request: %w", err) } for key, val := range req.Headers { @@ -70,12 +70,12 @@ func (c *client[R]) Send(req Request) (Result[R], error) { res, err := c.internalClient.Do(request) if err != nil { - return Result[R]{}, errors.Wrap(err, ErrRequest.Error()) + return Result[R]{}, fmt.Errorf("request failed: %w", err) } err = c.cookieJar.Save() if err != nil { - return Result[R]{}, errors.Wrap(err, ErrSaveCookie.Error()) + return Result[R]{}, fmt.Errorf("failed to save cookies: %w", err) } if req.ResponseFormat == ResponseFormatJSON { @@ -85,7 +85,7 @@ func (c *client[R]) Send(req Request) (Result[R], error) { return c.handleXMLResponse(res) } - return Result[R]{}, errors.Errorf("%s: %s", ErrUnsupportedContentType.Error(), req.ResponseFormat) + return Result[R]{}, fmt.Errorf("content type is not supported (%s)", req.ResponseFormat) } func (c *client[R]) Do(req *http.Request) (*http.Response, error) { @@ -99,13 +99,13 @@ func (*client[R]) NewRequest(method, url string, body io.Reader) (*http.Request, func (c *client[R]) handleJSONResponse(res *http.Response) (Result[R], error) { body, err := io.ReadAll(res.Body) if err != nil { - return Result[R]{}, errors.Wrap(err, ErrGetResponseBody.Error()) + return Result[R]{}, fmt.Errorf("failed to read response body: %w", err) } var data R err = json.Unmarshal(body, &data) if err != nil { - return Result[R]{}, errors.Wrap(err, ErrUnmarshalJSON.Error()) + return Result[R]{}, fmt.Errorf("failed to unmarshal json: %w", err) } return Result[R]{ @@ -117,13 +117,13 @@ func (c *client[R]) handleJSONResponse(res *http.Response) (Result[R], error) { func (c *client[R]) handleXMLResponse(res *http.Response) (Result[R], error) { body, err := io.ReadAll(res.Body) if err != nil { - return Result[R]{}, errors.Wrap(err, ErrGetResponseBody.Error()) + return Result[R]{}, fmt.Errorf("failed to read response body: %w", err) } var data R _, err = plist.Unmarshal(body, &data) if err != nil { - return Result[R]{}, errors.Wrap(err, ErrUnmarshalXML.Error()) + return Result[R]{}, fmt.Errorf("failed to unmarshal xml: %w", err) } headers := map[string]string{} diff --git a/pkg/http/client_mock.go b/pkg/http/client_mock.go deleted file mode 100644 index ebcf1356..00000000 --- a/pkg/http/client_mock.go +++ /dev/null @@ -1,81 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: client.go - -// Package http is a generated GoMock package. -package http - -import ( - io "io" - http "net/http" - reflect "reflect" - - gomock "github.com/golang/mock/gomock" -) - -// MockClient is a mock of Client interface. -type MockClient[R interface{}] struct { - ctrl *gomock.Controller - recorder *MockClientMockRecorder[R] -} - -// MockClientMockRecorder is the mock recorder for MockClient. -type MockClientMockRecorder[R interface{}] struct { - mock *MockClient[R] -} - -// NewMockClient creates a new mock instance. -func NewMockClient[R interface{}](ctrl *gomock.Controller) *MockClient[R] { - mock := &MockClient[R]{ctrl: ctrl} - mock.recorder = &MockClientMockRecorder[R]{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockClient[R]) EXPECT() *MockClientMockRecorder[R] { - return m.recorder -} - -// Do mocks base method. -func (m *MockClient[R]) Do(req *http.Request) (*http.Response, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Do", req) - ret0, _ := ret[0].(*http.Response) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Do indicates an expected call of Do. -func (mr *MockClientMockRecorder[R]) Do(req interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Do", reflect.TypeOf((*MockClient[R])(nil).Do), req) -} - -// NewRequest mocks base method. -func (m *MockClient[R]) NewRequest(method, url string, body io.Reader) (*http.Request, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "NewRequest", method, url, body) - ret0, _ := ret[0].(*http.Request) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// NewRequest indicates an expected call of NewRequest. -func (mr *MockClientMockRecorder[R]) NewRequest(method, url, body interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewRequest", reflect.TypeOf((*MockClient[R])(nil).NewRequest), method, url, body) -} - -// Send mocks base method. -func (m *MockClient[R]) Send(request Request) (Result[R], error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Send", request) - ret0, _ := ret[0].(Result[R]) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Send indicates an expected call of Send. -func (mr *MockClientMockRecorder[R]) Send(request interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Send", reflect.TypeOf((*MockClient[R])(nil).Send), request) -} diff --git a/pkg/http/client_test.go b/pkg/http/client_test.go index 874718d9..105ba957 100644 --- a/pkg/http/client_test.go +++ b/pkg/http/client_test.go @@ -77,7 +77,7 @@ var _ = Describe("Client", Ordered, func() { }) It("returns request", func() { - sut := NewClient[XMLResult](ClientArgs{}) + sut := NewClient[XMLResult](Args{}) req, err := sut.NewRequest("GET", fmt.Sprintf("http://localhost:%d/json", port), nil) Expect(err).ToNot(HaveOccurred()) @@ -85,7 +85,7 @@ var _ = Describe("Client", Ordered, func() { }) It("returns response", func() { - sut := NewClient[XMLResult](ClientArgs{}) + sut := NewClient[XMLResult](Args{}) req, err := sut.NewRequest("GET", fmt.Sprintf("http://localhost:%d/json", port), nil) Expect(err).ToNot(HaveOccurred()) @@ -107,7 +107,7 @@ var _ = Describe("Client", Ordered, func() { }) It("returns error", func() { - sut := NewClient[JSONResult](ClientArgs{ + sut := NewClient[JSONResult](Args{ CookieJar: mockCookieJar, }) _, err := sut.Send(Request{ @@ -115,8 +115,7 @@ var _ = Describe("Client", Ordered, func() { Method: MethodGET, }) - Expect(err).To(MatchError(ContainSubstring(testErr.Error()))) - Expect(err).To(MatchError(ContainSubstring("failed to save cookies"))) + Expect(err).To(HaveOccurred()) }) }) @@ -128,7 +127,7 @@ var _ = Describe("Client", Ordered, func() { }) It("decodes JSON response", func() { - sut := NewClient[JSONResult](ClientArgs{ + sut := NewClient[JSONResult](Args{ CookieJar: mockCookieJar, }) res, err := sut.Send(Request{ @@ -150,7 +149,7 @@ var _ = Describe("Client", Ordered, func() { }) It("decodes XML response", func() { - sut := NewClient[XMLResult](ClientArgs{ + sut := NewClient[XMLResult](Args{ CookieJar: mockCookieJar, }) res, err := sut.Send(Request{ @@ -164,7 +163,7 @@ var _ = Describe("Client", Ordered, func() { }) It("returns error when content type is not supported", func() { - sut := NewClient[XMLResult](ClientArgs{ + sut := NewClient[XMLResult](Args{ CookieJar: mockCookieJar, }) _, err := sut.Send(Request{ @@ -173,14 +172,14 @@ var _ = Describe("Client", Ordered, func() { ResponseFormat: "random", }) - Expect(err).To(MatchError(ContainSubstring("unsupported response body content type: random"))) + Expect(err).To(HaveOccurred()) }) }) }) When("payload fails to decode", func() { It("returns error", func() { - sut := NewClient[XMLResult](ClientArgs{ + sut := NewClient[XMLResult](Args{ CookieJar: mockCookieJar, }) _, err := sut.Send(Request{ @@ -194,12 +193,12 @@ var _ = Describe("Client", Ordered, func() { }, }) - Expect(err).To(MatchError(ContainSubstring(ErrGetPayloadData.Error()))) + Expect(err).To(HaveOccurred()) }) }) It("uses default headers", func() { - sut := NewClient[XMLResult](ClientArgs{}) + sut := NewClient[XMLResult](Args{}) req, err := sut.NewRequest("GET", fmt.Sprintf("http://localhost:%d/headers", port), nil) Expect(err).ToNot(HaveOccurred()) diff --git a/pkg/http/constants.go b/pkg/http/constants.go index 15052cb2..a8a9b73c 100644 --- a/pkg/http/constants.go +++ b/pkg/http/constants.go @@ -5,5 +5,5 @@ type ResponseFormat string const ( ResponseFormatJSON ResponseFormat = "json" ResponseFormatXML ResponseFormat = "xml" - DefaultUserAgent = "Configurator/2.15 (Macintosh; OperatingSystem X 11.0.0; 16G29) AppleWebKit/2603.3.8" + DefaultUserAgent = "Configurator/2.15 (Macintosh; OS X 11.0.0; 16G29) AppleWebKit/2603.3.8" ) diff --git a/pkg/http/cookiejar.go b/pkg/http/cookiejar.go index d1d8f7c4..47c73101 100644 --- a/pkg/http/cookiejar.go +++ b/pkg/http/cookiejar.go @@ -2,7 +2,7 @@ package http import "net/http" -//go:generate mockgen -source=cookiejar.go -destination=cookiejar_mock.go -package=http +//go:generate go run github.com/golang/mock/mockgen -source=cookiejar.go -destination=cookiejar_mock.go -package=http type CookieJar interface { http.CookieJar diff --git a/pkg/http/cookiejar_mock.go b/pkg/http/cookiejar_mock.go deleted file mode 100644 index 7f018944..00000000 --- a/pkg/http/cookiejar_mock.go +++ /dev/null @@ -1,76 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: cookiejar.go - -// Package http is a generated GoMock package. -package http - -import ( - http "net/http" - url "net/url" - reflect "reflect" - - gomock "github.com/golang/mock/gomock" -) - -// MockCookieJar is a mock of CookieJar interface. -type MockCookieJar struct { - ctrl *gomock.Controller - recorder *MockCookieJarMockRecorder -} - -// MockCookieJarMockRecorder is the mock recorder for MockCookieJar. -type MockCookieJarMockRecorder struct { - mock *MockCookieJar -} - -// NewMockCookieJar creates a new mock instance. -func NewMockCookieJar(ctrl *gomock.Controller) *MockCookieJar { - mock := &MockCookieJar{ctrl: ctrl} - mock.recorder = &MockCookieJarMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockCookieJar) EXPECT() *MockCookieJarMockRecorder { - return m.recorder -} - -// Cookies mocks base method. -func (m *MockCookieJar) Cookies(u *url.URL) []*http.Cookie { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Cookies", u) - ret0, _ := ret[0].([]*http.Cookie) - return ret0 -} - -// Cookies indicates an expected call of Cookies. -func (mr *MockCookieJarMockRecorder) Cookies(u interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Cookies", reflect.TypeOf((*MockCookieJar)(nil).Cookies), u) -} - -// Save mocks base method. -func (m *MockCookieJar) Save() error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Save") - ret0, _ := ret[0].(error) - return ret0 -} - -// Save indicates an expected call of Save. -func (mr *MockCookieJarMockRecorder) Save() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockCookieJar)(nil).Save)) -} - -// SetCookies mocks base method. -func (m *MockCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) { - m.ctrl.T.Helper() - m.ctrl.Call(m, "SetCookies", u, cookies) -} - -// SetCookies indicates an expected call of SetCookies. -func (mr *MockCookieJarMockRecorder) SetCookies(u, cookies interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetCookies", reflect.TypeOf((*MockCookieJar)(nil).SetCookies), u, cookies) -} diff --git a/pkg/http/errors.go b/pkg/http/errors.go deleted file mode 100644 index 36028a87..00000000 --- a/pkg/http/errors.go +++ /dev/null @@ -1,16 +0,0 @@ -package http - -import "errors" - -var ( - ErrCreateRequest = errors.New("failed to create HTTP request") - ErrEncodePayloadXML = errors.New("failed to encode XML object") - ErrGetPayloadData = errors.New("failed to get payload data") - ErrGetResponseBody = errors.New("failed to get response body") - ErrRequest = errors.New("failed to send request") - ErrSaveCookie = errors.New("failed to save cookies") - ErrUnmarshalJSON = errors.New("failed to unmarshal JSON data") - ErrUnmarshalXML = errors.New("failed to unmarshal XML data") - ErrUnsupportedContentType = errors.New("unsupported response body content type") - ErrUnsupportedValueType = errors.New("unsupported value type") -) diff --git a/pkg/http/payload.go b/pkg/http/payload.go index 04fa4d84..efaf9048 100644 --- a/pkg/http/payload.go +++ b/pkg/http/payload.go @@ -2,7 +2,7 @@ package http import ( "bytes" - "github.com/pkg/errors" + "fmt" "howett.net/plist" "net/url" "strconv" @@ -25,7 +25,7 @@ func (p *XMLPayload) data() ([]byte, error) { err := plist.NewEncoder(buffer).Encode(p.Content) if err != nil { - return nil, errors.Wrap(err, ErrEncodePayloadXML.Error()) + return nil, fmt.Errorf("failed to encode plist: %w", err) } return buffer.Bytes(), nil @@ -41,7 +41,7 @@ func (p *URLPayload) data() ([]byte, error) { case int: params.Add(key, strconv.Itoa(val.(int))) default: - return nil, errors.Errorf("%s: %s", ErrUnsupportedValueType.Error(), t) + return nil, fmt.Errorf("value type is not supported (%s)", t) } } diff --git a/pkg/http/payload_test.go b/pkg/http/payload_test.go index 10a2c2ef..0875eaf2 100644 --- a/pkg/http/payload_test.go +++ b/pkg/http/payload_test.go @@ -30,7 +30,7 @@ var _ = Describe("Payload", func() { } data, err := sut.data() - Expect(err).To(MatchError(ContainSubstring(ErrUnsupportedValueType.Error()))) + Expect(err).To(HaveOccurred()) Expect(data).To(BeNil()) }) }) @@ -57,7 +57,7 @@ var _ = Describe("Payload", func() { } data, err := sut.data() - Expect(err).To(MatchError(ContainSubstring("failed to encode XML object"))) + Expect(err).To(HaveOccurred()) Expect(data).To(BeNil()) }) }) diff --git a/pkg/keychain/errors.go b/pkg/keychain/errors.go deleted file mode 100644 index d701b879..00000000 --- a/pkg/keychain/errors.go +++ /dev/null @@ -1,9 +0,0 @@ -package keychain - -import "github.com/pkg/errors" - -var ( - ErrGetKeychainItem = errors.New("failed to get item from keyring") - ErrRemoveKeychainItem = errors.New("failed to remove item from keyring") - ErrSetKeychainItem = errors.New("failed to set item in keyring") -) diff --git a/pkg/keychain/keychain.go b/pkg/keychain/keychain.go index 9e578261..5cb54706 100644 --- a/pkg/keychain/keychain.go +++ b/pkg/keychain/keychain.go @@ -1,6 +1,6 @@ package keychain -//go:generate mockgen -source=keychain.go -destination=keychain_mock.go -package keychain +//go:generate go run github.com/golang/mock/mockgen -source=keychain.go -destination=keychain_mock.go -package keychain type Keychain interface { Get(key string) ([]byte, error) Set(key string, data []byte) error @@ -11,11 +11,11 @@ type keychain struct { keyring Keyring } -type KeychainArgs struct { +type Args struct { Keyring Keyring } -func NewKeychain(args KeychainArgs) Keychain { +func New(args Args) Keychain { return &keychain{ keyring: args.Keyring, } diff --git a/pkg/keychain/keychain_get.go b/pkg/keychain/keychain_get.go index 7a06c940..6f2aad4c 100644 --- a/pkg/keychain/keychain_get.go +++ b/pkg/keychain/keychain_get.go @@ -1,13 +1,13 @@ package keychain import ( - "github.com/pkg/errors" + "fmt" ) func (k *keychain) Get(key string) ([]byte, error) { item, err := k.keyring.Get(key) if err != nil { - return nil, errors.Wrap(err, ErrGetKeychainItem.Error()) + return nil, fmt.Errorf("failed to get item: %w", err) } return item.Data, nil diff --git a/pkg/keychain/keychain_get_test.go b/pkg/keychain/keychain_get_test.go index 69584a3e..bbbc23d9 100644 --- a/pkg/keychain/keychain_get_test.go +++ b/pkg/keychain/keychain_get_test.go @@ -1,11 +1,11 @@ package keychain import ( + "errors" "github.com/99designs/keyring" "github.com/golang/mock/gomock" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/pkg/errors" ) var _ = Describe("Keychain (Get)", func() { @@ -18,7 +18,7 @@ var _ = Describe("Keychain (Get)", func() { BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) mockKeyring = NewMockKeyring(ctrl) - keychain = NewKeychain(KeychainArgs{ + keychain = New(Args{ Keyring: mockKeyring, }) }) @@ -29,18 +29,16 @@ var _ = Describe("Keychain (Get)", func() { When("keyring returns error", func() { const testKey = "test-key" - var testErr = errors.New("test error") BeforeEach(func() { mockKeyring.EXPECT(). Get(testKey). - Return(keyring.Item{}, testErr) + Return(keyring.Item{}, errors.New("")) }) It("returns wrapped error", func() { data, err := keychain.Get(testKey) - Expect(err).To(MatchError(ContainSubstring(testErr.Error()))) - Expect(err).To(MatchError(ContainSubstring("failed to get item from keyring"))) + Expect(err).To(HaveOccurred()) Expect(data).To(BeNil()) }) }) diff --git a/pkg/keychain/keychain_mock.go b/pkg/keychain/keychain_mock.go deleted file mode 100644 index 1158989c..00000000 --- a/pkg/keychain/keychain_mock.go +++ /dev/null @@ -1,77 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: keychain.go - -// Package keychain is a generated GoMock package. -package keychain - -import ( - reflect "reflect" - - gomock "github.com/golang/mock/gomock" -) - -// MockKeychain is a mock of Keychain interface. -type MockKeychain struct { - ctrl *gomock.Controller - recorder *MockKeychainMockRecorder -} - -// MockKeychainMockRecorder is the mock recorder for MockKeychain. -type MockKeychainMockRecorder struct { - mock *MockKeychain -} - -// NewMockKeychain creates a new mock instance. -func NewMockKeychain(ctrl *gomock.Controller) *MockKeychain { - mock := &MockKeychain{ctrl: ctrl} - mock.recorder = &MockKeychainMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockKeychain) EXPECT() *MockKeychainMockRecorder { - return m.recorder -} - -// Get mocks base method. -func (m *MockKeychain) Get(key string) ([]byte, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Get", key) - ret0, _ := ret[0].([]byte) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Get indicates an expected call of Get. -func (mr *MockKeychainMockRecorder) Get(key interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockKeychain)(nil).Get), key) -} - -// Remove mocks base method. -func (m *MockKeychain) Remove(key string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Remove", key) - ret0, _ := ret[0].(error) - return ret0 -} - -// Remove indicates an expected call of Remove. -func (mr *MockKeychainMockRecorder) Remove(key interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Remove", reflect.TypeOf((*MockKeychain)(nil).Remove), key) -} - -// Set mocks base method. -func (m *MockKeychain) Set(key string, data []byte) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Set", key, data) - ret0, _ := ret[0].(error) - return ret0 -} - -// Set indicates an expected call of Set. -func (mr *MockKeychainMockRecorder) Set(key, data interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Set", reflect.TypeOf((*MockKeychain)(nil).Set), key, data) -} diff --git a/pkg/keychain/keychain_remove.go b/pkg/keychain/keychain_remove.go index 3867ba24..d78f17bc 100644 --- a/pkg/keychain/keychain_remove.go +++ b/pkg/keychain/keychain_remove.go @@ -1,11 +1,13 @@ package keychain -import "github.com/pkg/errors" +import ( + "fmt" +) func (k *keychain) Remove(key string) error { err := k.keyring.Remove(key) if err != nil { - return errors.Wrap(err, ErrRemoveKeychainItem.Error()) + return fmt.Errorf("failed to remove item: %w", err) } return nil diff --git a/pkg/keychain/keychain_remove_test.go b/pkg/keychain/keychain_remove_test.go index 7da339e1..21f7f780 100644 --- a/pkg/keychain/keychain_remove_test.go +++ b/pkg/keychain/keychain_remove_test.go @@ -1,10 +1,10 @@ package keychain import ( + "errors" "github.com/golang/mock/gomock" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/pkg/errors" ) var _ = Describe("Keychain (Remove)", func() { @@ -17,7 +17,7 @@ var _ = Describe("Keychain (Remove)", func() { BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) mockKeyring = NewMockKeyring(ctrl) - keychain = NewKeychain(KeychainArgs{ + keychain = New(Args{ Keyring: mockKeyring, }) }) @@ -28,18 +28,16 @@ var _ = Describe("Keychain (Remove)", func() { When("keyring returns error", func() { const testKey = "test-key" - var testErr = errors.New("test error") BeforeEach(func() { mockKeyring.EXPECT(). Remove(testKey). - Return(testErr) + Return(errors.New("")) }) It("returns wrapped error", func() { err := keychain.Remove(testKey) - Expect(err).To(MatchError(ContainSubstring(testErr.Error()))) - Expect(err).To(MatchError(ContainSubstring("failed to remove item from keyring"))) + Expect(err).To(HaveOccurred()) }) }) diff --git a/pkg/keychain/keychain_set.go b/pkg/keychain/keychain_set.go index 0c006f66..5e5b07b9 100644 --- a/pkg/keychain/keychain_set.go +++ b/pkg/keychain/keychain_set.go @@ -1,8 +1,8 @@ package keychain import ( + "fmt" "github.com/99designs/keyring" - "github.com/pkg/errors" ) func (k *keychain) Set(key string, data []byte) error { @@ -11,7 +11,7 @@ func (k *keychain) Set(key string, data []byte) error { Data: data, }) if err != nil { - return errors.Wrap(err, ErrSetKeychainItem.Error()) + return fmt.Errorf("failed to set item: %w", err) } return nil diff --git a/pkg/keychain/keychain_set_test.go b/pkg/keychain/keychain_set_test.go index 902c077f..9c004569 100644 --- a/pkg/keychain/keychain_set_test.go +++ b/pkg/keychain/keychain_set_test.go @@ -1,11 +1,11 @@ package keychain import ( + "errors" "github.com/99designs/keyring" "github.com/golang/mock/gomock" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/pkg/errors" ) var _ = Describe("Keychain (Set)", func() { @@ -18,7 +18,7 @@ var _ = Describe("Keychain (Set)", func() { BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) mockKeyring = NewMockKeyring(ctrl) - keychain = NewKeychain(KeychainArgs{ + keychain = New(Args{ Keyring: mockKeyring, }) }) @@ -29,10 +29,7 @@ var _ = Describe("Keychain (Set)", func() { When("keyring returns error", func() { const testKey = "test-key" - var ( - testData = []byte("test") - testErr = errors.New("test error") - ) + var testData = []byte("test") BeforeEach(func() { mockKeyring.EXPECT(). @@ -40,13 +37,12 @@ var _ = Describe("Keychain (Set)", func() { Key: testKey, Data: testData, }). - Return(testErr) + Return(errors.New("")) }) It("returns wrapped error", func() { err := keychain.Set(testKey, testData) - Expect(err).To(MatchError(ContainSubstring(testErr.Error()))) - Expect(err).To(MatchError(ContainSubstring("failed to set item in keyring"))) + Expect(err).To(HaveOccurred()) }) }) diff --git a/pkg/keychain/keyring.go b/pkg/keychain/keyring.go index 6bf870d4..b1ea653d 100644 --- a/pkg/keychain/keyring.go +++ b/pkg/keychain/keyring.go @@ -2,7 +2,7 @@ package keychain import "github.com/99designs/keyring" -//go:generate mockgen -source=keyring.go -destination=keyring_mock.go -package keychain +//go:generate go run github.com/golang/mock/mockgen -source=keyring.go -destination=keyring_mock.go -package keychain type Keyring interface { Get(key string) (keyring.Item, error) Set(item keyring.Item) error diff --git a/pkg/keychain/keyring_mock.go b/pkg/keychain/keyring_mock.go deleted file mode 100644 index 5f35563e..00000000 --- a/pkg/keychain/keyring_mock.go +++ /dev/null @@ -1,78 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: keyring.go - -// Package keychain is a generated GoMock package. -package keychain - -import ( - reflect "reflect" - - keyring "github.com/99designs/keyring" - gomock "github.com/golang/mock/gomock" -) - -// MockKeyring is a mock of Keyring interface. -type MockKeyring struct { - ctrl *gomock.Controller - recorder *MockKeyringMockRecorder -} - -// MockKeyringMockRecorder is the mock recorder for MockKeyring. -type MockKeyringMockRecorder struct { - mock *MockKeyring -} - -// NewMockKeyring creates a new mock instance. -func NewMockKeyring(ctrl *gomock.Controller) *MockKeyring { - mock := &MockKeyring{ctrl: ctrl} - mock.recorder = &MockKeyringMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockKeyring) EXPECT() *MockKeyringMockRecorder { - return m.recorder -} - -// Get mocks base method. -func (m *MockKeyring) Get(key string) (keyring.Item, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Get", key) - ret0, _ := ret[0].(keyring.Item) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Get indicates an expected call of Get. -func (mr *MockKeyringMockRecorder) Get(key interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockKeyring)(nil).Get), key) -} - -// Remove mocks base method. -func (m *MockKeyring) Remove(key string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Remove", key) - ret0, _ := ret[0].(error) - return ret0 -} - -// Remove indicates an expected call of Remove. -func (mr *MockKeyringMockRecorder) Remove(key interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Remove", reflect.TypeOf((*MockKeyring)(nil).Remove), key) -} - -// Set mocks base method. -func (m *MockKeyring) Set(item keyring.Item) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Set", item) - ret0, _ := ret[0].(error) - return ret0 -} - -// Set indicates an expected call of Set. -func (mr *MockKeyringMockRecorder) Set(item interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Set", reflect.TypeOf((*MockKeyring)(nil).Set), item) -} diff --git a/pkg/log/logger.go b/pkg/log/logger.go index 96d86f22..dd8e7558 100644 --- a/pkg/log/logger.go +++ b/pkg/log/logger.go @@ -7,7 +7,7 @@ import ( "io" ) -//go:generate mockgen -source=logger.go -destination=logger_mock.go -package log +//go:generate go run github.com/golang/mock/mockgen -source=logger.go -destination=logger_mock.go -package log type Logger interface { Verbose() *zerolog.Event Log() *zerolog.Event @@ -19,12 +19,12 @@ type logger struct { verbose bool } -type LoggerArgs struct { +type Args struct { Verbose bool Writer io.Writer } -func NewLogger(args LoggerArgs) Logger { +func NewLogger(args Args) Logger { internalLogger := log.Logger level := zerolog.InfoLevel diff --git a/pkg/log/logger_mock.go b/pkg/log/logger_mock.go deleted file mode 100644 index 9d449238..00000000 --- a/pkg/log/logger_mock.go +++ /dev/null @@ -1,77 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: logger.go - -// Package log is a generated GoMock package. -package log - -import ( - reflect "reflect" - - gomock "github.com/golang/mock/gomock" - zerolog "github.com/rs/zerolog" -) - -// MockLogger is a mock of Logger interface. -type MockLogger struct { - ctrl *gomock.Controller - recorder *MockLoggerMockRecorder -} - -// MockLoggerMockRecorder is the mock recorder for MockLogger. -type MockLoggerMockRecorder struct { - mock *MockLogger -} - -// NewMockLogger creates a new mock instance. -func NewMockLogger(ctrl *gomock.Controller) *MockLogger { - mock := &MockLogger{ctrl: ctrl} - mock.recorder = &MockLoggerMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockLogger) EXPECT() *MockLoggerMockRecorder { - return m.recorder -} - -// Error mocks base method. -func (m *MockLogger) Error() *zerolog.Event { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Error") - ret0, _ := ret[0].(*zerolog.Event) - return ret0 -} - -// Error indicates an expected call of Error. -func (mr *MockLoggerMockRecorder) Error() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Error", reflect.TypeOf((*MockLogger)(nil).Error)) -} - -// Log mocks base method. -func (m *MockLogger) Log() *zerolog.Event { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Log") - ret0, _ := ret[0].(*zerolog.Event) - return ret0 -} - -// Log indicates an expected call of Log. -func (mr *MockLoggerMockRecorder) Log() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Log", reflect.TypeOf((*MockLogger)(nil).Log)) -} - -// Verbose mocks base method. -func (m *MockLogger) Verbose() *zerolog.Event { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Verbose") - ret0, _ := ret[0].(*zerolog.Event) - return ret0 -} - -// Verbose indicates an expected call of Verbose. -func (mr *MockLoggerMockRecorder) Verbose() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Verbose", reflect.TypeOf((*MockLogger)(nil).Verbose)) -} diff --git a/pkg/log/logger_test.go b/pkg/log/logger_test.go index 9828c3a9..2d4e8fa7 100644 --- a/pkg/log/logger_test.go +++ b/pkg/log/logger_test.go @@ -22,7 +22,7 @@ var _ = Describe("Logger", func() { Context("Verbose logger", func() { BeforeEach(func() { - logger = NewLogger(LoggerArgs{ + logger = NewLogger(Args{ Verbose: true, Writer: mockWriter, }) @@ -44,7 +44,7 @@ var _ = Describe("Logger", func() { Context("Non-verbose logger", func() { BeforeEach(func() { - logger = NewLogger(LoggerArgs{ + logger = NewLogger(Args{ Verbose: false, Writer: mockWriter, }) diff --git a/pkg/log/writer.go b/pkg/log/writer.go index 203c1c46..936f56d3 100644 --- a/pkg/log/writer.go +++ b/pkg/log/writer.go @@ -6,7 +6,7 @@ import ( "os" ) -//go:generate mockgen -source=writer.go -destination=writer_mock.go -package log +//go:generate go run github.com/golang/mock/mockgen -source=writer.go -destination=writer_mock.go -package log type Writer interface { Write(p []byte) (n int, err error) WriteLevel(level zerolog.Level, p []byte) (n int, err error) diff --git a/pkg/log/writer_mock.go b/pkg/log/writer_mock.go deleted file mode 100644 index ecb3c8e2..00000000 --- a/pkg/log/writer_mock.go +++ /dev/null @@ -1,65 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: writer.go - -// Package log is a generated GoMock package. -package log - -import ( - reflect "reflect" - - gomock "github.com/golang/mock/gomock" - zerolog "github.com/rs/zerolog" -) - -// MockWriter is a mock of Writer interface. -type MockWriter struct { - ctrl *gomock.Controller - recorder *MockWriterMockRecorder -} - -// MockWriterMockRecorder is the mock recorder for MockWriter. -type MockWriterMockRecorder struct { - mock *MockWriter -} - -// NewMockWriter creates a new mock instance. -func NewMockWriter(ctrl *gomock.Controller) *MockWriter { - mock := &MockWriter{ctrl: ctrl} - mock.recorder = &MockWriterMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockWriter) EXPECT() *MockWriterMockRecorder { - return m.recorder -} - -// Write mocks base method. -func (m *MockWriter) Write(p []byte) (int, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Write", p) - ret0, _ := ret[0].(int) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Write indicates an expected call of Write. -func (mr *MockWriterMockRecorder) Write(p interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Write", reflect.TypeOf((*MockWriter)(nil).Write), p) -} - -// WriteLevel mocks base method. -func (m *MockWriter) WriteLevel(level zerolog.Level, p []byte) (int, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "WriteLevel", level, p) - ret0, _ := ret[0].(int) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// WriteLevel indicates an expected call of WriteLevel. -func (mr *MockWriterMockRecorder) WriteLevel(level, p interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WriteLevel", reflect.TypeOf((*MockWriter)(nil).WriteLevel), level, p) -} diff --git a/pkg/util/errors.go b/pkg/util/errors.go deleted file mode 100644 index 8baaa79c..00000000 --- a/pkg/util/errors.go +++ /dev/null @@ -1,10 +0,0 @@ -package util - -import "github.com/pkg/errors" - -var ( - ErrEmptyNetworkInterfaces = errors.New("could not find network interfaces") - ErrGetNetworkInterfaces = errors.New("failed to get network interfaces") - ErrInvalidNetworkInterface = errors.New("could not find network interfaces with a valid MAC address") - ErrSlicesLengthMismatch = errors.New("slices have different lengths") -) diff --git a/pkg/util/machine.go b/pkg/util/machine.go deleted file mode 100644 index 09a02fc8..00000000 --- a/pkg/util/machine.go +++ /dev/null @@ -1,62 +0,0 @@ -package util - -import ( - "github.com/pkg/errors" - "golang.org/x/term" - "net" - "path/filepath" - "runtime" -) - -//go:generate mockgen -source=machine.go -destination=machine_mock.go -package util -type Machine interface { - MacAddress() (string, error) - HomeDirectory() string - ReadPassword(fd int) ([]byte, error) -} - -type machine struct { - os OperatingSystem -} - -type MachineArgs struct { - OperatingSystem OperatingSystem -} - -func NewMachine(args MachineArgs) Machine { - return &machine{ - os: args.OperatingSystem, - } -} - -func (*machine) MacAddress() (string, error) { - ifaces, err := net.Interfaces() - if err != nil { - return "", errors.Wrap(err, ErrGetNetworkInterfaces.Error()) - } - - if len(ifaces) == 0 { - return "", errors.New(ErrEmptyNetworkInterfaces.Error()) - } - - for _, iface := range ifaces { - addr := iface.HardwareAddr.String() - if addr != "" { - return addr, nil - } - } - - return "", errors.New(ErrInvalidNetworkInterface.Error()) -} - -func (m *machine) HomeDirectory() string { - if runtime.GOOS == "windows" { - return filepath.Join(m.os.Getenv("HOMEDRIVE"), m.os.Getenv("HOMEPATH")) - } - - return m.os.Getenv("HOME") -} - -func (*machine) ReadPassword(fd int) ([]byte, error) { - return term.ReadPassword(fd) -} diff --git a/pkg/util/machine/machine.go b/pkg/util/machine/machine.go new file mode 100644 index 00000000..723b8f67 --- /dev/null +++ b/pkg/util/machine/machine.go @@ -0,0 +1,63 @@ +package machine + +import ( + "fmt" + "github.com/majd/ipatool/pkg/util/operatingsystem" + "golang.org/x/term" + "net" + "path/filepath" + "runtime" +) + +//go:generate go run github.com/golang/mock/mockgen -source=machine.go -destination=machine_mock.go -package machine +type Machine interface { + MacAddress() (string, error) + HomeDirectory() string + ReadPassword(fd int) ([]byte, error) +} + +type machine struct { + os operatingsystem.OperatingSystem +} + +type Args struct { + OS operatingsystem.OperatingSystem +} + +func New(args Args) Machine { + return &machine{ + os: args.OS, + } +} + +func (*machine) MacAddress() (string, error) { + interfaces, err := net.Interfaces() + if err != nil { + return "", fmt.Errorf("failed to get network interfaces: %w", err) + } + + if len(interfaces) == 0 { + return "", fmt.Errorf("could not find network interfaces: %w", err) + } + + for _, netInterface := range interfaces { + addr := netInterface.HardwareAddr.String() + if addr != "" { + return addr, nil + } + } + + return "", fmt.Errorf("could not find network interfaces with a valid mac address: %w", err) +} + +func (m *machine) HomeDirectory() string { + if runtime.GOOS == "windows" { + return filepath.Join(m.os.Getenv("HOMEDRIVE"), m.os.Getenv("HOMEPATH")) + } + + return m.os.Getenv("HOME") +} + +func (*machine) ReadPassword(fd int) ([]byte, error) { + return term.ReadPassword(fd) +} diff --git a/pkg/util/machine_test.go b/pkg/util/machine/machine_test.go similarity index 72% rename from pkg/util/machine_test.go rename to pkg/util/machine/machine_test.go index 657e36a4..7785be77 100644 --- a/pkg/util/machine_test.go +++ b/pkg/util/machine/machine_test.go @@ -1,24 +1,31 @@ -package util +package machine import ( "github.com/golang/mock/gomock" + "github.com/majd/ipatool/pkg/util/operatingsystem" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "syscall" + "testing" ) +func TestMachine(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Machine Suite") +} + var _ = Describe("Machine", func() { var ( ctrl *gomock.Controller machine Machine - mockOS *MockOperatingSystem + mockOS *operatingsystem.MockOperatingSystem ) BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) - mockOS = NewMockOperatingSystem(ctrl) - machine = NewMachine(MachineArgs{ - OperatingSystem: mockOS, + mockOS = operatingsystem.NewMockOperatingSystem(ctrl) + machine = New(Args{ + OS: mockOS, }) }) @@ -46,7 +53,7 @@ var _ = Describe("Machine", func() { When("reading password from stdout", func() { It("returns error", func() { _, err := machine.ReadPassword(syscall.Stdout) - Expect(err).To(MatchError(ContainSubstring("inappropriate ioctl for device"))) + Expect(err).To(HaveOccurred()) }) }) }) diff --git a/pkg/util/machine_mock.go b/pkg/util/machine_mock.go deleted file mode 100644 index 31d5b72b..00000000 --- a/pkg/util/machine_mock.go +++ /dev/null @@ -1,78 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: machine.go - -// Package util is a generated GoMock package. -package util - -import ( - reflect "reflect" - - gomock "github.com/golang/mock/gomock" -) - -// MockMachine is a mock of Machine interface. -type MockMachine struct { - ctrl *gomock.Controller - recorder *MockMachineMockRecorder -} - -// MockMachineMockRecorder is the mock recorder for MockMachine. -type MockMachineMockRecorder struct { - mock *MockMachine -} - -// NewMockMachine creates a new mock instance. -func NewMockMachine(ctrl *gomock.Controller) *MockMachine { - mock := &MockMachine{ctrl: ctrl} - mock.recorder = &MockMachineMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockMachine) EXPECT() *MockMachineMockRecorder { - return m.recorder -} - -// HomeDirectory mocks base method. -func (m *MockMachine) HomeDirectory() string { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HomeDirectory") - ret0, _ := ret[0].(string) - return ret0 -} - -// HomeDirectory indicates an expected call of HomeDirectory. -func (mr *MockMachineMockRecorder) HomeDirectory() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HomeDirectory", reflect.TypeOf((*MockMachine)(nil).HomeDirectory)) -} - -// MacAddress mocks base method. -func (m *MockMachine) MacAddress() (string, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "MacAddress") - ret0, _ := ret[0].(string) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// MacAddress indicates an expected call of MacAddress. -func (mr *MockMachineMockRecorder) MacAddress() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MacAddress", reflect.TypeOf((*MockMachine)(nil).MacAddress)) -} - -// ReadPassword mocks base method. -func (m *MockMachine) ReadPassword(fd int) ([]byte, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ReadPassword", fd) - ret0, _ := ret[0].([]byte) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ReadPassword indicates an expected call of ReadPassword. -func (mr *MockMachineMockRecorder) ReadPassword(fd interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadPassword", reflect.TypeOf((*MockMachine)(nil).ReadPassword), fd) -} diff --git a/pkg/util/must.go b/pkg/util/must.go new file mode 100644 index 00000000..6df9691c --- /dev/null +++ b/pkg/util/must.go @@ -0,0 +1,10 @@ +package util + +// Must is a helper that wraps a call to a function returning (T, error) and panics if the error is non-nil. +func Must[T any](val T, err error) T { + if err != nil { + panic(err.Error()) + } + + return val +} diff --git a/pkg/util/must_test.go b/pkg/util/must_test.go new file mode 100644 index 00000000..ffc3015e --- /dev/null +++ b/pkg/util/must_test.go @@ -0,0 +1,23 @@ +package util + +import ( + "errors" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Must", func() { + It("returns current value", func() { + res := Must("value", nil) + Expect(res).To(Equal("value")) + }) + + It("panics", func() { + defer func() { + r := recover() + Expect(r).To(Equal("test")) + }() + + _ = Must("value", errors.New("test")) + }) +}) diff --git a/pkg/util/operating_system.go b/pkg/util/operating_system.go deleted file mode 100644 index b0c69092..00000000 --- a/pkg/util/operating_system.go +++ /dev/null @@ -1,48 +0,0 @@ -package util - -import "os" - -//go:generate mockgen -source=operating_system.go -destination=operating_system_mock.go -package util -type OperatingSystem interface { - Getenv(key string) string - Stat(name string) (os.FileInfo, error) - Getwd() (string, error) - OpenFile(name string, flag int, perm os.FileMode) (*os.File, error) - Remove(name string) error - IsNotExist(err error) bool - MkdirAll(path string, perm os.FileMode) error -} - -type operatingSystem struct{} - -func NewOperatingSystem() OperatingSystem { - return &operatingSystem{} -} - -func (*operatingSystem) Getenv(key string) string { - return os.Getenv(key) -} - -func (*operatingSystem) Stat(name string) (os.FileInfo, error) { - return os.Stat(name) -} - -func (*operatingSystem) Getwd() (string, error) { - return os.Getwd() -} - -func (*operatingSystem) OpenFile(name string, flag int, perm os.FileMode) (*os.File, error) { - return os.OpenFile(name, flag, perm) -} - -func (*operatingSystem) Remove(name string) error { - return os.Remove(name) -} - -func (*operatingSystem) IsNotExist(err error) bool { - return os.IsNotExist(err) -} - -func (*operatingSystem) MkdirAll(path string, perm os.FileMode) error { - return os.MkdirAll(path, perm) -} diff --git a/pkg/util/operating_system_mock.go b/pkg/util/operating_system_mock.go deleted file mode 100644 index 058fa8a8..00000000 --- a/pkg/util/operating_system_mock.go +++ /dev/null @@ -1,136 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: operating_system.go - -// Package util is a generated GoMock package. -package util - -import ( - os "os" - reflect "reflect" - - gomock "github.com/golang/mock/gomock" -) - -// MockOperatingSystem is a mock of OperatingSystem interface. -type MockOperatingSystem struct { - ctrl *gomock.Controller - recorder *MockOperatingSystemMockRecorder -} - -// MockOperatingSystemMockRecorder is the mock recorder for MockOperatingSystem. -type MockOperatingSystemMockRecorder struct { - mock *MockOperatingSystem -} - -// NewMockOperatingSystem creates a new mock instance. -func NewMockOperatingSystem(ctrl *gomock.Controller) *MockOperatingSystem { - mock := &MockOperatingSystem{ctrl: ctrl} - mock.recorder = &MockOperatingSystemMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockOperatingSystem) EXPECT() *MockOperatingSystemMockRecorder { - return m.recorder -} - -// Getenv mocks base method. -func (m *MockOperatingSystem) Getenv(key string) string { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Getenv", key) - ret0, _ := ret[0].(string) - return ret0 -} - -// Getenv indicates an expected call of Getenv. -func (mr *MockOperatingSystemMockRecorder) Getenv(key interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Getenv", reflect.TypeOf((*MockOperatingSystem)(nil).Getenv), key) -} - -// Getwd mocks base method. -func (m *MockOperatingSystem) Getwd() (string, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Getwd") - ret0, _ := ret[0].(string) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Getwd indicates an expected call of Getwd. -func (mr *MockOperatingSystemMockRecorder) Getwd() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Getwd", reflect.TypeOf((*MockOperatingSystem)(nil).Getwd)) -} - -// IsNotExist mocks base method. -func (m *MockOperatingSystem) IsNotExist(err error) bool { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "IsNotExist", err) - ret0, _ := ret[0].(bool) - return ret0 -} - -// IsNotExist indicates an expected call of IsNotExist. -func (mr *MockOperatingSystemMockRecorder) IsNotExist(err interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsNotExist", reflect.TypeOf((*MockOperatingSystem)(nil).IsNotExist), err) -} - -// MkdirAll mocks base method. -func (m *MockOperatingSystem) MkdirAll(path string, perm os.FileMode) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "MkdirAll", path, perm) - ret0, _ := ret[0].(error) - return ret0 -} - -// MkdirAll indicates an expected call of MkdirAll. -func (mr *MockOperatingSystemMockRecorder) MkdirAll(path, perm interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MkdirAll", reflect.TypeOf((*MockOperatingSystem)(nil).MkdirAll), path, perm) -} - -// OpenFile mocks base method. -func (m *MockOperatingSystem) OpenFile(name string, flag int, perm os.FileMode) (*os.File, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "OpenFile", name, flag, perm) - ret0, _ := ret[0].(*os.File) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// OpenFile indicates an expected call of OpenFile. -func (mr *MockOperatingSystemMockRecorder) OpenFile(name, flag, perm interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OpenFile", reflect.TypeOf((*MockOperatingSystem)(nil).OpenFile), name, flag, perm) -} - -// Remove mocks base method. -func (m *MockOperatingSystem) Remove(name string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Remove", name) - ret0, _ := ret[0].(error) - return ret0 -} - -// Remove indicates an expected call of Remove. -func (mr *MockOperatingSystemMockRecorder) Remove(name interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Remove", reflect.TypeOf((*MockOperatingSystem)(nil).Remove), name) -} - -// Stat mocks base method. -func (m *MockOperatingSystem) Stat(name string) (os.FileInfo, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Stat", name) - ret0, _ := ret[0].(os.FileInfo) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Stat indicates an expected call of Stat. -func (mr *MockOperatingSystemMockRecorder) Stat(name interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stat", reflect.TypeOf((*MockOperatingSystem)(nil).Stat), name) -} diff --git a/pkg/util/operatingsystem/operatingsystem.go b/pkg/util/operatingsystem/operatingsystem.go new file mode 100644 index 00000000..b3ff5ee3 --- /dev/null +++ b/pkg/util/operatingsystem/operatingsystem.go @@ -0,0 +1,53 @@ +package operatingsystem + +import "os" + +//go:generate go run github.com/golang/mock/mockgen -source=operatingsystem.go -destination=operatingsystem_mock.go -package operatingsystem +type OperatingSystem interface { + Getenv(key string) string + Stat(name string) (os.FileInfo, error) + Getwd() (string, error) + OpenFile(name string, flag int, perm os.FileMode) (*os.File, error) + Remove(name string) error + IsNotExist(err error) bool + MkdirAll(path string, perm os.FileMode) error + Rename(old, new string) error +} + +type operatingSystem struct{} + +func New() OperatingSystem { + return &operatingSystem{} +} + +func (operatingSystem) Getenv(key string) string { + return os.Getenv(key) +} + +func (operatingSystem) Stat(name string) (os.FileInfo, error) { + return os.Stat(name) +} + +func (operatingSystem) Getwd() (string, error) { + return os.Getwd() +} + +func (operatingSystem) OpenFile(name string, flag int, perm os.FileMode) (*os.File, error) { + return os.OpenFile(name, flag, perm) +} + +func (operatingSystem) Remove(name string) error { + return os.Remove(name) +} + +func (operatingSystem) IsNotExist(err error) bool { + return os.IsNotExist(err) +} + +func (operatingSystem) MkdirAll(path string, perm os.FileMode) error { + return os.MkdirAll(path, perm) +} + +func (operatingSystem) Rename(old, new string) error { + return os.Rename(old, new) +} diff --git a/pkg/util/operating_system_test.go b/pkg/util/operatingsystem/operatingsystem_test.go similarity index 55% rename from pkg/util/operating_system_test.go rename to pkg/util/operatingsystem/operatingsystem_test.go index d44937b0..9acdb27f 100644 --- a/pkg/util/operating_system_test.go +++ b/pkg/util/operatingsystem/operatingsystem_test.go @@ -1,41 +1,48 @@ -package util +package operatingsystem import ( + "fmt" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "io/fs" - goos "os" + "math/rand" + "os" "path" + "testing" + "time" ) -var _ = Describe("Operating System", func() { - var ( - os OperatingSystem - ) +func TestOS(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "OperatingSystem Suite") +} + +var _ = Describe("OperatingSystem", func() { + var sut OperatingSystem BeforeEach(func() { - os = NewOperatingSystem() + sut = New() }) When("env var is set", func() { BeforeEach(func() { - err := goos.Setenv("TEST", "true") + err := os.Setenv("TEST", "true") Expect(err).ToNot(HaveOccurred()) }) It("returns env var", func() { - res := os.Getenv("TEST") + res := sut.Getenv("TEST") Expect(res).To(Equal("true")) }) }) When("file exists", func() { - var file *goos.File + var file *os.File BeforeEach(func() { var err error - file, err = goos.CreateTemp("", "test_file") + file, err = os.CreateTemp("", "test_file") Expect(err).ToNot(HaveOccurred()) }) @@ -45,29 +52,39 @@ var _ = Describe("Operating System", func() { }) It("returns file info", func() { - res, err := os.Stat(file.Name()) + res, err := sut.Stat(file.Name()) Expect(err).ToNot(HaveOccurred()) Expect(res.Name()).To(Equal(path.Base(file.Name()))) }) It("opens file", func() { - res, err := os.OpenFile(file.Name(), goos.O_WRONLY, 0644) + res, err := sut.OpenFile(file.Name(), os.O_WRONLY, 0644) Expect(err).ToNot(HaveOccurred()) Expect(res.Name()).To(Equal(file.Name())) }) It("removes file", func() { - err := os.Remove(file.Name()) + err := sut.Remove(file.Name()) Expect(err).ToNot(HaveOccurred()) - _, err = os.Stat(file.Name()) - Expect(goos.IsNotExist(err)).To(BeTrue()) + _, err = sut.Stat(file.Name()) + Expect(os.IsNotExist(err)).To(BeTrue()) + }) + + It("renames file", func() { + r := rand.New(rand.NewSource(time.Now().UnixNano())) + newPath := fmt.Sprintf("%s/%d", os.TempDir(), r.Intn(100)) + + err := sut.Rename(file.Name(), newPath) + defer sut.Remove(newPath) + + Expect(err).ToNot(HaveOccurred()) }) }) When("running", func() { It("returns current working directory", func() { - res, err := os.Getwd() + res, err := sut.Getwd() Expect(err).ToNot(HaveOccurred()) Expect(res).ToNot(BeNil()) }) @@ -75,14 +92,14 @@ var _ = Describe("Operating System", func() { When("error is 'ErrNotExist'", func() { It("returns true", func() { - res := os.IsNotExist(fs.ErrNotExist) + res := sut.IsNotExist(fs.ErrNotExist) Expect(res).To(BeTrue()) }) }) When("directory does not exist", func() { It("creates directory", func() { - err := os.MkdirAll(goos.TempDir(), 0664) + err := sut.MkdirAll(os.TempDir(), 0664) Expect(err).ToNot(HaveOccurred()) }) }) diff --git a/pkg/util/zip.go b/pkg/util/zip.go index e8cb8dca..f7c75255 100644 --- a/pkg/util/zip.go +++ b/pkg/util/zip.go @@ -1,6 +1,6 @@ package util -import "github.com/pkg/errors" +import "errors" type Pair[T, U any] struct { First T @@ -9,7 +9,7 @@ type Pair[T, U any] struct { func Zip[T, U any](ts []T, us []U) ([]Pair[T, U], error) { if len(ts) != len(us) { - return nil, errors.New(ErrSlicesLengthMismatch.Error()) + return nil, errors.New("slices have different lengths") } pairs := make([]Pair[T, U], len(ts)) diff --git a/pkg/util/zip_test.go b/pkg/util/zip_test.go index 323265fe..89a09518 100644 --- a/pkg/util/zip_test.go +++ b/pkg/util/zip_test.go @@ -9,7 +9,7 @@ var _ = Describe("Zip", func() { When("slices have different lengths", func() { It("returns error", func() { _, err := Zip([]string{}, []string{"test"}) - Expect(err).To(MatchError(ContainSubstring("slices have different lengths"))) + Expect(err).To(HaveOccurred()) }) })