Repository: klothoplatform/klotho (go)

Issue:
can't downgrade based on pinning
I have Klotho v0.6.1:

```
$ klotho --version
Version: v0.6.1-darwin-amd64
```

I pin it to v0.6.0, and it tells me I'm already up to date:

```
$ klotho --set-option update.stream=open:v0.6.0 --update
already up to date on stream "open:v0.6.0".

$ klotho --version
Version: v0.6.1-darwin-amd64
```

Workaround:

```
# switch from open to pro
$ klotho --set-option update.stream=pro:latest --update
updated to the latest version on stream "pro:latest"

# switch from pro to open, with a pinned version
$ klotho --set-option update.stream=open:v0.6.0 --update
updated to the latest version on stream "open:v0.6.0"

$ klotho --version
Version: v0.6.0-darwin-amd64

# version is pinned correctly
$ klotho --update
already up to date on stream "open:v0.6.0".
```

Pull request description:
fix pin-and-downgrade
Previously, if you tried to pin to a lower version, we would treat it as a no-op (and keep you at your current version). Now, we'll perform the update ("downdate").

This resolves #187.

We've had a few back-and-forths in this code, most recently #137. I added unit tests to try and put this to bed for good, or at least prevent further regressions.

### Standard checks

- **Unit tests**: I added some!
- **Docs**: none needed
- **Backwards compatibility**: no issues


Patch:
diff --git a/pkg/cli/klothomain.go b/pkg/cli/klothomain.go
index d52357b3..dfc23451 100644
--- a/pkg/cli/klothomain.go
+++ b/pkg/cli/klothomain.go
@@ -2,28 +2,29 @@ package cli
 
 import (
 	"fmt"
-	"github.com/klothoplatform/klotho/pkg/closenicely"
-	"github.com/spf13/pflag"
 	"os"
 	"regexp"
-
-	"github.com/klothoplatform/klotho/pkg/auth"
-	"github.com/klothoplatform/klotho/pkg/cli_config"
-
-	"github.com/klothoplatform/klotho/pkg/updater"
-
-	"github.com/klothoplatform/klotho/pkg/input"
+	"time"
 
 	"github.com/fatih/color"
-	"github.com/klothoplatform/klotho/pkg/analytics"
-	"github.com/klothoplatform/klotho/pkg/config"
-	"github.com/klothoplatform/klotho/pkg/core"
-	"github.com/klothoplatform/klotho/pkg/logging"
+	"github.com/gojek/heimdall/v7"
+	"github.com/gojek/heimdall/v7/httpclient"
 	"github.com/pkg/errors"
 	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
 	"go.uber.org/atomic"
 	"go.uber.org/zap"
 	"go.uber.org/zap/zapcore"
+
+	"github.com/klothoplatform/klotho/pkg/analytics"
+	"github.com/klothoplatform/klotho/pkg/auth"
+	"github.com/klothoplatform/klotho/pkg/cli_config"
+	"github.com/klothoplatform/klotho/pkg/closenicely"
+	"github.com/klothoplatform/klotho/pkg/config"
+	"github.com/klothoplatform/klotho/pkg/core"
+	"github.com/klothoplatform/klotho/pkg/input"
+	"github.com/klothoplatform/klotho/pkg/logging"
+	"github.com/klothoplatform/klotho/pkg/updater"
 )
 
 type KlothoMain struct {
@@ -271,6 +272,11 @@ func (km KlothoMain) run(cmd *cobra.Command, args []string) (err error) {
 		ServerURL:     updater.DefaultServer,
 		Stream:        updateStream,
 		CurrentStream: km.DefaultUpdateStream,
+		Client: httpclient.NewClient(
+			httpclient.WithHTTPTimeout(5*time.Minute),
+			httpclient.WithRetryCount(3),
+			httpclient.WithRetrier(heimdall.NewRetrier(heimdall.NewExponentialBackoff(10*time.Millisecond, time.Second, 1.5, 100*time.Millisecond))),
+		),
 	}
 	if cfg.update {
 		if err := klothoUpdater.Update(km.Version); err != nil {
@@ -289,7 +295,7 @@ func (km KlothoMain) run(cmd *cobra.Command, args []string) (err error) {
 			zap.S().Warnf("failed to check for updates: %v", err)
 		}
 		if needsUpdate {
-			analyticsClient.Info(klothoName + "update is available")
+			analyticsClient.Info(klothoName + " update is available")
 			zap.L().Info("new update is available, please run klotho --update to get the latest version")
 		}
 	} else {
diff --git a/pkg/input/readdir.go b/pkg/input/readdir.go
index 4954b3b0..5a95ded0 100644
--- a/pkg/input/readdir.go
+++ b/pkg/input/readdir.go
@@ -3,9 +3,11 @@ package input
 import (
 	"encoding/json"
 	"fmt"
+	"github.com/klothoplatform/klotho/pkg/filter/predicate"
 	"io"
 	"io/fs"
 	"os"
+	"path"
 	"path/filepath"
 	"strings"
 
@@ -33,12 +35,29 @@ func Upcast[F core.File](o fileOpener[F]) fileOpener[core.File] {
 type languageFiles struct {
 	name             languageName
 	foundSources     bool
-	foundPackageFile bool
-	packageFileName  string
-	// packageFileOpener reads a package file (as opposed to a source file).
+	foundProjectFile bool
+	// projectFileOpener reads a package file (as opposed to a source file).
 	// It is specialized to `core.File` so that the `languageFiles` struct doesn't need to be generic.
-	// If you have a func that returns a more specfic type, use Upcast to convert it to `fileOpener[core.File]`.
-	packageFileOpener fileOpener[core.File]
+	// If you have a func that returns a more specific type, use Upcast to convert it to `fileOpener[core.File]`.
+	projectFileOpener      fileOpener[core.File]
+	projectFilePredicate   predicate.Predicate[string]
+	projectFileDescription string
+}
+
+func (l languageFiles) isProjectFile(filepath string) bool {
+	return l.projectFilePredicate != nil && l.projectFilePredicate(filepath)
+}
+
+func hasName(expected string) predicate.Predicate[string] {
+	return func(s string) bool {
+		return expected == s
+	}
+}
+
+func hasExtension(extension string) predicate.Predicate[string] {
+	return func(name string) bool {
+		return filepath.Ext(name) == extension
+	}
 }
 
 type languageName string
@@ -110,19 +129,32 @@ func ReadDir(fsys fs.FS, cfg config.Application, cfgFilePath string) (*core.Inpu
 		zap.S().Debugf("Read TS config (%s): %+v", tsConfigPath, tsConfig)
 	}
 
-	jsLang := &languageFiles{name: JavaScript, packageFileName: "package.json", packageFileOpener: Upcast(javascript.NewPackageFile)}
-	pyLang := &languageFiles{name: Python, packageFileName: "requirements.txt", packageFileOpener: Upcast(python.NewRequirementsTxt)}
-	goLang := &languageFiles{name: Go, packageFileName: "go.mod", packageFileOpener: Upcast(golang.NewGoMod)}
+	jsLang := &languageFiles{
+		name:                   JavaScript,
+		projectFilePredicate:   hasName("package.json"),
+		projectFileDescription: "package.json",
+		projectFileOpener:      Upcast(javascript.NewPackageFile)}
+	pyLang := &languageFiles{
+		name:                   Python,
+		projectFilePredicate:   hasName("requirements.txt"),
+		projectFileDescription: "requirements.txt",
+		projectFileOpener:      Upcast(python.NewRequirementsTxt)}
+	goLang := &languageFiles{
+		name:                   Go,
+		projectFilePredicate:   hasName("go.mod"),
+		projectFileDescription: "go.mod",
+		projectFileOpener:      Upcast(golang.NewGoMod)}
 	csLang := &languageFiles{
-		name: CSharp,
-		// TODO: Since C# projects don't have a specific named project file, we'll need to update how looking for the project file works.
-		packageFileName: fmt.Sprintf("%s.csproj", cfg.AppName),
-		// TODO: package files in C# are currently unused, so no need to open & parse them.
-		packageFileOpener: func(path string, content io.Reader) (f core.File, err error) { return &core.FileRef{FPath: path}, nil },
+		name:                   CSharp,
+		projectFilePredicate:   hasExtension(".csproj"),
+		projectFileDescription: "MSBuild Project File (.csproj)",
+		// TODO: project files in C# are currently unused, so no need to open & parse them.
+		projectFileOpener: func(path string, content io.Reader) (f core.File, err error) { return &core.FileRef{FPath: path}, nil },
 	}
 	yamlLang := &languageFiles{name: Yaml}
 	dockerfileLang := &languageFiles{name: DockerFile}
-	allLangs := []*languageFiles{jsLang, pyLang, goLang, yamlLang}
+	allLangs := []*languageFiles{jsLang, pyLang, goLang, yamlLang, csLang}
+
 	err := fs.WalkDir(fsys, cfg.Path, func(path string, info fs.DirEntry, err error) error {
 		if err != nil {
 			return err
@@ -143,13 +175,25 @@ func ReadDir(fsys fs.FS, cfg config.Application, cfgFilePath string) (*core.Inpu
 		}
 
 		if info.IsDir() {
+
 			switch info.Name() {
 			case "node_modules", "vendor":
 				// Skip modules/vendor folder for performance.
 				// If we ever support Klotho annotations from dependencies,
 				// we'll need to remove this skip and check those.
 				return fs.SkipDir
-
+			case "bin", "obj":
+				dir := filepath.Dir(info.Name())
+				if dir == "." {
+					dir = cfg.Path
+				}
+				projectsInDir := getProjectFilesInDir(fsys, dir, csLang)
+				if _, ok := projectsInDir[CSharp]; ok {
+					zap.L().With(logging.FileField(f)).Debug("detected C# project output, skipping directory")
+					return fs.SkipDir
+				}
+			case ".idea", ".vscode":
+				fallthrough
 			case ".git", ".svn":
 				return fs.SkipDir
 
@@ -166,16 +210,16 @@ func ReadDir(fsys fs.FS, cfg config.Application, cfgFilePath string) (*core.Inpu
 			}
 			return nil
 		}
-		isPackageFile := false
+		isProjectFile := false
 		for _, lang := range allLangs {
-			if info.Name() == lang.packageFileName {
-				f, err = addFile(fsys, path, relPath, lang.packageFileOpener)
-				lang.foundPackageFile = true
-				isPackageFile = true
+			if lang.isProjectFile(info.Name()) {
+				f, err = addFile(fsys, path, relPath, lang.projectFileOpener)
+				isProjectFile = true
+				lang.foundProjectFile = true
 				break
 			}
 		}
-		if !isPackageFile {
+		if !isProjectFile {
 			ext := filepath.Ext(info.Name())
 			switch ext {
 			case ".js":
@@ -227,39 +271,73 @@ func ReadDir(fsys fs.FS, cfg config.Application, cfgFilePath string) (*core.Inpu
 	if err != nil {
 		return nil, err
 	}
-	for _, lang := range allLangs {
-		if lang.foundSources && !lang.foundPackageFile && lang.packageFileName != "" {
 
-			pkg, err := openFindUpward(lang.packageFileName, cfg.Path, fsys, lang.packageFileOpener)
+	for _, lang := range allLangs {
+		if lang.foundSources && !lang.foundProjectFile && lang.projectFilePredicate != nil {
+			projectFile, err := openFindUpward(lang, cfg.Path, fsys)
 			if err != nil {
 				return nil, err
 			}
-			input.Add(pkg)
-			zap.L().With(logging.FileField(pkg)).Sugar().Debugf("Read package file for %s", lang.name)
+			input.Add(projectFile)
+			zap.L().With(logging.FileField(projectFile)).Sugar().Debugf("Read project file for %s", lang.name)
 		}
 	}
 
 	return input, nil
 }
 
+func getProjectFilesInDir(fsys fs.FS, dir string, langs ...*languageFiles) map[languageName][]string {
+	projectFiles := make(map[languageName][]string)
+	entries, _ := fs.ReadDir(fsys, dir)
+	for _, lang := range langs {
+		for _, e := range entries {
+			if lang.isProjectFile(e.Name()) {
+				projectFiles[lang.name] = append(projectFiles[lang.name], e.Name())
+			}
+		}
+	}
+	return projectFiles
+}
+
 // openFindUpward tries to open the `basename` file in `rootPath`, or any of its parent dirs up to `fsys`'s root.
-func openFindUpward[F core.File](basename string, rootPath string, fsys fs.FS, opener fileOpener[F]) (core.File, error) {
-	for pkgDir := rootPath; ; pkgDir = filepath.Dir(pkgDir) {
-		pkgPath := filepath.Join(pkgDir, basename)
-		f, err := fsys.Open(pkgPath)
-		if errors.Is(err, fs.ErrNotExist) {
-			if pkgDir == "/" || pkgDir == "." {
-				break
+func openFindUpward(lang *languageFiles, rootPath string, fsys fs.FS) (core.File, error) {
+	for prjDir := rootPath; ; prjDir = filepath.Dir(prjDir) {
+		entries, err := fs.ReadDir(fsys, prjDir)
+		var projectFile core.File
+		for _, entry := range entries {
+			if entry.IsDir() {
+				continue
+			}
+
+			if lang.isProjectFile(entry.Name()) {
+				if projectFile != nil {
+					return nil, fmt.Errorf("multiple '%s' files found in directory: %s", lang.projectFileDescription, prjDir)
+				}
+
+				prjFilePath := path.Join(prjDir, entry.Name())
+				var f fs.File
+				f, err = fsys.Open(prjFilePath)
+				if err != nil {
+					break
+				}
+				projectFile, err = lang.projectFileOpener(prjFilePath, f)
+				f.Close()
+				if err != nil {
+					break
+				}
 			}
-			continue
 		}
 		if err != nil {
-			return nil, errors.Wrapf(err, "error looking upward for package file named '%s'", basename)
+			return nil, errors.Wrapf(err, "error looking upward for project file")
+		}
+		if projectFile != nil {
+			return projectFile, nil
+		}
+		if prjDir == "/" || prjDir == "." {
+			break
 		}
-		defer f.Close()
-		return opener(basename, f)
 	}
-	return nil, errors.Errorf("No %s found", basename)
+	return nil, errors.Errorf("No %s file found", lang.projectFileDescription)
 }
 
 func addFile[F core.File](fsys fs.FS, path string, relPath string, opener fileOpener[F]) (core.File, error) {
diff --git a/pkg/updater/updater.go b/pkg/updater/updater.go
index 1271cfeb..1b59a317 100644
--- a/pkg/updater/updater.go
+++ b/pkg/updater/updater.go
@@ -7,7 +7,6 @@ import (
 	"net/http"
 	"runtime"
 	"strings"
-	"time"
 
 	"github.com/coreos/go-semver/semver"
 	"github.com/gojek/heimdall/v7/httpclient"
@@ -21,7 +20,7 @@ var (
 	Arch string = runtime.GOARCH
 )
 
-var (
+const (
 	DefaultServer string = "http://srv.klo.dev"
 )
 
@@ -31,6 +30,8 @@ type Updater struct {
 	Stream string
 	// CurrentStream is the stream this binary came from
 	CurrentStream string
+
+	Client *httpclient.Client
 }
 
 func selfUpdate(data io.ReadCloser) error {
@@ -42,11 +43,8 @@ func selfUpdate(data io.ReadCloser) error {
 // against the latest github release, returns true
 // if the latest release is newer
 func (u *Updater) CheckUpdate(currentVersion string) (bool, error) {
-	timeout := 10 * time.Second
-	cli := httpclient.NewClient(httpclient.WithHTTPTimeout(timeout))
-
 	endpoint := fmt.Sprintf("%s/update/check-latest-version?stream=%s", u.ServerURL, u.Stream)
-	res, err := cli.Get(endpoint, nil)
+	res, err := u.Client.Get(endpoint, nil)
 	if err != nil {
 		return false, fmt.Errorf("failed to query for latest version: %v", err)
 	}
@@ -77,7 +75,33 @@ func (u *Updater) CheckUpdate(currentVersion string) (bool, error) {
 		return false, fmt.Errorf("invalid version %s: %v", currentVersion, err)
 	}
 
-	return currVersion.LessThan(*latestVersion) || strings.Split(u.CurrentStream, ":")[0] != strings.Split(u.Stream, ":")[0], nil
+	// Given a stream "xxx:yyyy", the qualifier is the "xxx" and the tag is the "yyyy".
+	//
+	// (1) If the qualifiers are different, always update (this is to handle open <--> pro)
+	// Otherwise, check the cli's version against latest. This is a bit trickier:
+	//
+	// (2a) If the tags are the same, then either it's a specific version or it's a monotonic tag like "latest".
+	//   • If it's a monotonic tag, we only want to perform upgrades. A downgrade would be a situation like if we gave
+	//     someone a pre-release, in which case we don't want to downgrade them.
+	//   • If it's a specific version, we can assume that the version will never change.
+	//   • So in either case, we want to only perform upgrades.
+	// (2b) If the tags are different, then someone is either pinning to a specific version, or going from a pinned
+	//     version to a monotonic version. In either case, we should allow downgrades. (Going from pinned to monotonic
+	//     *may* be an incorrect downgrade, with a similar pre-release reason. But if someone has a pre-release, they
+	//     shouldn't be worrying about any upgrade stuff, including not changing their update stream from pinned to
+	//     monotonic.)
+
+	// case (1): different qualifiers always update
+	if strings.Split(u.CurrentStream, ":")[0] != strings.Split(u.Stream, ":")[0] {
+		return true, nil
+	}
+
+	// the qualifiers are the same, so the tags are the same iff the full stream strings are the same
+	if u.CurrentStream == u.Stream {
+		return currVersion.LessThan(*latestVersion), nil // case (2a): only upgrades
+	} else {
+		return !currVersion.Equal(*latestVersion), nil // case (2b): upgrades or downgrades
+	}
 }
 
 // Update performs an update if a newer version is
@@ -94,7 +118,7 @@ func (u *Updater) Update(currentVersion string) error {
 		return nil
 	}
 
-	body, err := getLatest(u.ServerURL, u.Stream)
+	body, err := u.getLatest()
 	if err != nil {
 		return errors.Wrapf(err, "failed to get latest")
 	}
@@ -110,12 +134,9 @@ func (u *Updater) Update(currentVersion string) error {
 }
 
 // getLatest Grabs latest release from klotho server
-func getLatest(baseUrl string, stream string) (io.ReadCloser, error) {
-	timeout := 10 * time.Second
-	cli := httpclient.NewClient(httpclient.WithHTTPTimeout(timeout))
-
-	endpoint := fmt.Sprintf("%s/update/latest/%s/%s?stream=%s", baseUrl, OS, Arch, stream)
-	res, err := cli.Get(endpoint, nil)
+func (u *Updater) getLatest() (io.ReadCloser, error) {
+	endpoint := fmt.Sprintf("%s/update/latest/%s/%s?stream=%s", u.ServerURL, OS, Arch, u.Stream)
+	res, err := u.Client.Get(endpoint, nil)
 	if err != nil {
 		return nil, fmt.Errorf("failed to query for latest version: %v", err)
 	}


Test patch:
diff --git a/pkg/input/readdir_test.go b/pkg/input/readdir_test.go
index 2c3f3f9a..102594d1 100644
--- a/pkg/input/readdir_test.go
+++ b/pkg/input/readdir_test.go
@@ -109,6 +109,53 @@ func TestReadDir(t *testing.T) {
 				"requirements.txt",
 			},
 		},
+		{
+			name: "csharp: simple",
+			files: map[string]string{
+				"fizz/one.cs":           "",
+				"fizz/myproject.csproj": "<Project></Project>",
+			},
+			rootPath: "fizz",
+			want: []string{
+				"one.cs",
+				"myproject.csproj",
+			},
+		},
+		{
+			name: "csharp: obj and bin directories excluded",
+			files: map[string]string{
+				"fizz/one.cs":           "",
+				"fizz/aproject.csproj":  "<Project></Project>",
+				"fizz/obj/two.cs":       "",
+				"fizz/bin/aproject.dll": "",
+			},
+			rootPath: "fizz",
+			want: []string{
+				"one.cs",
+				"aproject.csproj",
+			},
+		},
+		{
+			name: "csharp: simple csproj in parent",
+			files: map[string]string{
+				"parent/src/one.cs":       "",                    // will be within ./new_cwd/src
+				"parent/myproject.csproj": "<Project></Project>", // will be wi
+			},
+			rootPath: "parent",
+			want: []string{
+				"src/one.cs",
+				"myproject.csproj",
+			},
+		},
+		{
+			name: "csharp: multiple csproj in parent returns error",
+			files: map[string]string{
+				"parent/src/one.cs":        "",
+				"parent/myproject.csproj":  "<Project></Project>",
+				"parent/myproject2.csproj": "<Project></Project>",
+			},
+			rootPath: "parent/src",
+		},
 		{
 			name: "multi-language",
 			files: map[string]string{
diff --git a/pkg/updater/updater_test.go b/pkg/updater/updater_test.go
new file mode 100644
index 00000000..4f9b6abc
--- /dev/null
+++ b/pkg/updater/updater_test.go
@@ -0,0 +1,237 @@
+package updater
+
+import (
+	"encoding/json"
+	"github.com/stretchr/testify/assert"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+)
+
+func TestCheckUpdate(t *testing.T) {
+	type updateInputs struct {
+		buildStream    string
+		currentVersion string
+		checkStream    string
+	}
+	cases := []struct {
+		name string
+		cli  updateInputs
+		// serverResponse is the server's answer for the latest version, or empty string to represent an error
+		serverResponse string
+		expectUpdate   bool
+		expectError    bool
+	}{
+		{
+			name: `same stream, server has same version`,
+			cli: updateInputs{
+				buildStream:    `open:latest`,
+				currentVersion: `0.5.0`,
+				checkStream:    `open:latest`,
+			},
+			serverResponse: `0.5.0`,
+			expectUpdate:   false,
+		},
+		{
+			name: `same stream, server has newer version`,
+			cli: updateInputs{
+				buildStream:    `open:latest`,
+				currentVersion: `0.5.0`,
+				checkStream:    `open:latest`,
+			},
+			serverResponse: `0.5.1`,
+			expectUpdate:   true,
+		},
+		{
+			name: `same stream, server has older version`,
+			cli: updateInputs{
+				buildStream:    `open:latest`,
+				currentVersion: `0.5.1`,
+				checkStream:    `open:latest`,
+			},
+			serverResponse: `0.5.0`,
+			// This is typically unexpected, but can happen in development, or if we give someone a pre-release.
+			// We should not downgrade in either of those cases.
+			expectUpdate: false,
+		},
+		{
+			name: `different stream, server has newer version`,
+			cli: updateInputs{
+				buildStream:    `open:latest`,
+				currentVersion: `0.5.0`,
+				checkStream:    `pro:latest`,
+			},
+			serverResponse: `0.5.1`,
+			expectUpdate:   true,
+		},
+		{
+			name: `different stream, server has same version`,
+			cli: updateInputs{
+				buildStream:    `open:latest`,
+				currentVersion: `0.5.0`,
+				checkStream:    `pro:latest`,
+			},
+			serverResponse: `0.5.0`,
+			expectUpdate:   true, // both are 0.5.0, but we want to update to switch editions
+		},
+		{
+			name: `different stream, server has older version`,
+			cli: updateInputs{
+				buildStream:    `open:latest`,
+				currentVersion: `0.5.1`,
+				checkStream:    `pro:latest`,
+			},
+			serverResponse: `0.5.1`,
+			expectUpdate:   true,
+		},
+		{
+			name: `pin to same stream, same version`,
+			cli: updateInputs{
+				buildStream:    `open:latest`,
+				currentVersion: `0.5.0`,
+				checkStream:    `open:v0.5.0`,
+			},
+			serverResponse: `0.5.0`,
+			// it's important that this is false, or else the user will keep getting messages to the effect of
+			// "you're on v0.1.2, but there's a new version, v0.1.2".
+			expectUpdate: false,
+		},
+		{
+			name: `pin to same stream, newer version`,
+			cli: updateInputs{
+				buildStream:    `open:latest`,
+				currentVersion: `0.5.0`,
+				checkStream:    `open:v0.5.1`,
+			},
+			serverResponse: `0.5.1`,
+			expectUpdate:   true,
+		},
+		{
+			name: `pin to same stream, older version`,
+			cli: updateInputs{
+				buildStream:    `open:latest`,
+				currentVersion: `0.5.1`,
+				checkStream:    `open:v0.5.0`,
+			},
+			serverResponse: `0.5.0`,
+			expectUpdate:   true,
+		},
+		{
+			name: `pin to different stream, same version`,
+			cli: updateInputs{
+				buildStream:    `open:latest`,
+				currentVersion: `0.5.0`,
+				checkStream:    `pro:v0.5.0`,
+			},
+			serverResponse: `0.5.0`,
+			expectUpdate:   true,
+		},
+		{
+			name: `pin to different stream, newer version`,
+			cli: updateInputs{
+				buildStream:    `open:latest`,
+				currentVersion: `0.5.0`,
+				checkStream:    `pro:v0.5.1`,
+			},
+			serverResponse: `0.5.1`,
+			expectUpdate:   true,
+		},
+		{
+			name: `pin to different stream, older version`,
+			cli: updateInputs{
+				buildStream:    `open:latest`,
+				currentVersion: `0.5.1`,
+				checkStream:    `pro:v0.5.0`,
+			},
+			serverResponse: `0.5.0`,
+			expectUpdate:   true,
+		},
+		{
+			name: `pin to non-existent version`,
+			cli: updateInputs{
+				buildStream:    `open:latest`,
+				currentVersion: `0.5.1`,
+				checkStream:    `open:v0.5.0`,
+			},
+			expectError: true,
+		},
+	}
+	for _, tt := range cases {
+		t.Run(tt.name, func(t *testing.T) {
+			assert := assert.New(t)
+
+			handler := interactions{assert: assert}
+			rr := requestResponse{
+				inUri:     `/update/check-latest-version?stream=` + tt.cli.checkStream,
+				inMethod:  http.MethodGet,
+				outStatus: http.StatusInternalServerError, // will be overwritten if serverResponse != ""
+			}
+			if tt.serverResponse != "" {
+				rr.outStatus = http.StatusOK
+				rr.outBody = map[string]string{`latest_version`: tt.serverResponse}
+			}
+			handler.interactions = append(handler.interactions, rr)
+			server := httptest.NewServer(&handler)
+			defer server.Close()
+
+			updater := Updater{
+				ServerURL:     server.URL,
+				Stream:        tt.cli.checkStream,
+				CurrentStream: tt.cli.buildStream,
+			}
+			needsUpdate, e := updater.CheckUpdate(tt.cli.currentVersion)
+
+			if t.Failed() {
+				return // this means the mocked server failed (ie ran out of request-responses)
+			}
+			if !assert.Empty(handler.interactions, "didn't see all expected interactions") {
+				return
+			}
+			if tt.expectError {
+				assert.Error(e)
+			} else {
+				if assert.NoError(e) {
+					assert.Equal(needsUpdate, tt.expectUpdate)
+				}
+			}
+		})
+	}
+}
+
+type (
+	requestResponse struct {
+		inMethod  string
+		inUri     string
+		outStatus int
+		outBody   any
+	}
+
+	interactions struct {
+		assert       *assert.Assertions
+		interactions []requestResponse
+	}
+)
+
+func (s *interactions) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	if len(s.interactions) == 0 {
+		s.assert.Fail("no interactions left")
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+	curr := s.interactions[0]
+	s.interactions = s.interactions[1:]
+
+	if s.assert.Equal(curr.inMethod, r.Method) && s.assert.Equal(curr.inUri, r.URL.RequestURI()) {
+		body, err := json.Marshal(curr.outBody)
+		if !s.assert.NoError(err) {
+			w.WriteHeader(http.StatusInternalServerError)
+			return
+		}
+		w.WriteHeader(curr.outStatus)
+		_, err = w.Write(body)
+		s.assert.NoError(err)
+	} else {
+		s.assert.Fail("no interactions left")
+		w.WriteHeader(http.StatusInternalServerError)
+	}
+}