Repository: replicatedhq/kots
Pull request #283 (2020-01-28 21:14:58)

changing license channels


Patch:
diff --git a/ffi/archive.go b/ffi/archive.go
index ca7e56c4c..4cd88f99d 100644
--- a/ffi/archive.go
+++ b/ffi/archive.go
@@ -7,6 +7,7 @@ import (
 	"github.com/mholt/archiver"
 	"github.com/pkg/errors"
 	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
+	"github.com/replicatedhq/kots/pkg/upstream"
 	"k8s.io/client-go/kubernetes/scheme"
 )
 
@@ -24,15 +25,18 @@ func extractArchive(rootPath, fromArchivePath string) (*archiver.TarGz, error) {
 	return tarGz, nil
 }
 
-func readCursorFromPath(installationFilePath string) (string, error) {
+func readCursorFromPath(installationFilePath string) (upstream.ReplicatedCursor, error) {
 	installation, err := loadInstallationFromPath(installationFilePath)
 	if err != nil {
 		if os.IsNotExist(err) {
-			return "", nil
+			return upstream.ReplicatedCursor{}, nil
 		}
-		return "", errors.Wrap(err, "failed to read installation file")
+		return upstream.ReplicatedCursor{}, errors.Wrap(err, "failed to read installation file")
 	}
-	return installation.Spec.UpdateCursor, nil
+	return upstream.ReplicatedCursor{
+		ChannelName: installation.Spec.ChannelName,
+		Cursor:      installation.Spec.UpdateCursor,
+	}, nil
 }
 
 func loadInstallationFromPath(installationFilePath string) (*kotsv1beta1.Installation, error) {
diff --git a/ffi/main.go b/ffi/main.go
index 9b72ff801..95a01927d 100644
--- a/ffi/main.go
+++ b/ffi/main.go
@@ -88,7 +88,7 @@ func UpdateCheck(socket, fromArchivePath, namespace string) {
 
 		fmt.Printf("Result of checking for updates for %s: Before: %s, After %s\n", license.Spec.AppSlug, beforeCursor, afterCursor)
 
-		isUpdateAvailable := string(beforeCursor) != string(afterCursor)
+		isUpdateAvailable := !beforeCursor.Equal(afterCursor)
 		if !isUpdateAvailable {
 			ffiResult = NewFFIResult(0)
 			return
diff --git a/ffi/output.go b/ffi/output.go
index c0a9cb3ab..49f5b379a 100644
--- a/ffi/output.go
+++ b/ffi/output.go
@@ -9,6 +9,7 @@ import (
 	"net"
 
 	"github.com/pkg/errors"
+	"github.com/replicatedhq/kots/pkg/util"
 )
 
 type statusMessage struct {
@@ -105,7 +106,12 @@ func NewFFIResult(exitCode int) *FFIResult {
 }
 
 func (f *FFIResult) WithError(err error) *FFIResult {
-	f.Err = err
+	cause := errors.Cause(err)
+	if _, ok := cause.(util.ActionableError); ok {
+		f.Err = cause
+	} else {
+		f.Err = err
+	}
 	return f
 }
 
diff --git a/ffi/updatedownload.go b/ffi/updatedownload.go
index 3b3b36d14..8c5d977de 100644
--- a/ffi/updatedownload.go
+++ b/ffi/updatedownload.go
@@ -109,7 +109,7 @@ func UpdateDownload(socket, fromArchivePath, namespace, registryJson, cursor str
 
 		fmt.Printf("Result of checking for updates for %s: Before: %s, After %s\n", license.Spec.AppSlug, beforeCursor, afterCursor)
 
-		isUpdateAvailable := string(beforeCursor) != string(afterCursor)
+		isUpdateAvailable := !beforeCursor.Equal(afterCursor)
 		if !isUpdateAvailable {
 			ffiResult = NewFFIResult(0)
 			return
@@ -216,7 +216,7 @@ func UpdateDownloadFromAirgap(socket, fromArchivePath, namespace, registryJson,
 			ConfigFile:          filepath.Join(tmpRoot, "upstream", "userdata", "config.yaml"),
 			AirgapRoot:          airgapRoot,
 			InstallationFile:    installationFilePath,
-			UpdateCursor:        beforeCursor,
+			UpdateCursor:        beforeCursor.Cursor,
 			RootDir:             tmpRoot,
 			ExcludeKotsKinds:    true,
 			ExcludeAdminConsole: true,
@@ -246,14 +246,14 @@ func UpdateDownloadFromAirgap(socket, fromArchivePath, namespace, registryJson,
 
 		fmt.Printf("Result of checking for updates for %s: Before: %s, After %s\n", license.Spec.AppSlug, beforeCursor, afterCursor)
 
-		bc, err := cursor.NewCursor(string(beforeCursor))
+		bc, err := cursor.NewCursor(string(beforeCursor.Cursor))
 		if err != nil {
 			fmt.Printf("failed to parse current cursor: %s\n", err.Error())
 			ffiResult = NewFFIResult(-1).WithError(err)
 			return
 		}
 
-		ac, err := cursor.NewCursor(string(afterCursor))
+		ac, err := cursor.NewCursor(string(afterCursor.Cursor))
 		if err != nil {
 			fmt.Printf("failed to parse update cursor: %s\n", err.Error())
 			ffiResult = NewFFIResult(-1).WithError(err)
diff --git a/ffi/updateslist.go b/ffi/updateslist.go
index 39cd364ce..4f2181814 100644
--- a/ffi/updateslist.go
+++ b/ffi/updateslist.go
@@ -12,7 +12,7 @@ import (
 )
 
 //export ListUpdates
-func ListUpdates(socket, licenseData, currentCursor string) {
+func ListUpdates(socket, licenseData, currentCursor, currentChannel string) {
 	go func() {
 		var ffiResult *FFIResult
 
@@ -41,9 +41,10 @@ func ListUpdates(socket, licenseData, currentCursor string) {
 		defer os.Remove(licenseFile)
 
 		getUpdatesOptions := pull.GetUpdatesOptions{
-			LicenseFile:   licenseFile,
-			CurrentCursor: currentCursor,
-			Silent:        true,
+			LicenseFile:    licenseFile,
+			CurrentCursor:  currentCursor,
+			CurrentChannel: currentChannel,
+			Silent:         true,
 		}
 
 		updates, err := pull.GetUpdates(fmt.Sprintf("replicated://%s", license.Spec.AppSlug), getUpdatesOptions)
diff --git a/kotskinds/apis/kots/v1beta1/airgap_types.go b/kotskinds/apis/kots/v1beta1/airgap_types.go
index 4f037af47..da6652697 100644
--- a/kotskinds/apis/kots/v1beta1/airgap_types.go
+++ b/kotskinds/apis/kots/v1beta1/airgap_types.go
@@ -25,6 +25,7 @@ type AirgapSpec struct {
 	VersionLabel string `json:"versionLabel,omitempty"`
 	ReleaseNotes string `json:"releaseNotes,omitempty"`
 	UpdateCursor string `json:"updateCursor,omitempty"`
+	ChannelName  string `json:"channelName,omitempty"`
 	Signature    []byte `json:"signature,omitempty"`
 }
 
diff --git a/kotskinds/apis/kots/v1beta1/installation_types.go b/kotskinds/apis/kots/v1beta1/installation_types.go
index 00dbceaba..6accac912 100644
--- a/kotskinds/apis/kots/v1beta1/installation_types.go
+++ b/kotskinds/apis/kots/v1beta1/installation_types.go
@@ -23,6 +23,7 @@ import (
 // InstallationSpec defines the desired state of InstallationSpec
 type InstallationSpec struct {
 	UpdateCursor  string `json:"updateCursor,omitempty"`
+	ChannelName   string `json:"channelName,omitempty"`
 	VersionLabel  string `json:"versionLabel,omitempty"`
 	ReleaseNotes  string `json:"releaseNotes,omitempty"`
 	EncryptionKey string `json:"encryptionKey,omitempty"`
diff --git a/pkg/pull/peek.go b/pkg/pull/peek.go
index 61ea709f1..748026a8e 100644
--- a/pkg/pull/peek.go
+++ b/pkg/pull/peek.go
@@ -7,12 +7,13 @@ import (
 )
 
 type GetUpdatesOptions struct {
-	HelmRepoURI   string
-	Namespace     string
-	LocalPath     string
-	LicenseFile   string
-	CurrentCursor string
-	Silent        bool
+	HelmRepoURI    string
+	Namespace      string
+	LocalPath      string
+	LicenseFile    string
+	CurrentCursor  string
+	CurrentChannel string
+	Silent         bool
 }
 
 // GetUpdates will retrieve all later versions of the application specified in upstreamURI
@@ -30,6 +31,7 @@ func GetUpdates(upstreamURI string, getUpdatesOptions GetUpdatesOptions) ([]upst
 	fetchOptions.HelmRepoURI = getUpdatesOptions.HelmRepoURI
 	fetchOptions.LocalPath = getUpdatesOptions.LocalPath
 	fetchOptions.CurrentCursor = getUpdatesOptions.CurrentCursor
+	fetchOptions.CurrentChannel = getUpdatesOptions.CurrentChannel
 
 	if getUpdatesOptions.LicenseFile != "" {
 		license, err := parseLicenseFromFile(getUpdatesOptions.LicenseFile)
diff --git a/pkg/upstream/fetch.go b/pkg/upstream/fetch.go
index 63e73da16..a90c3b375 100644
--- a/pkg/upstream/fetch.go
+++ b/pkg/upstream/fetch.go
@@ -22,6 +22,7 @@ type FetchOptions struct {
 	Airgap              *kotsv1beta1.Airgap
 	EncryptionKey       string
 	CurrentCursor       string
+	CurrentChannel      string
 	CurrentVersionLabel string
 }
 
@@ -75,9 +76,15 @@ func pickVersionLabel(fetchOptions *FetchOptions) string {
 	return fetchOptions.CurrentVersionLabel
 }
 
-func pickCursor(fetchOptions *FetchOptions) string {
+func pickCursor(fetchOptions *FetchOptions) ReplicatedCursor {
 	if fetchOptions.Airgap != nil && fetchOptions.Airgap.Spec.UpdateCursor != "" {
-		return fetchOptions.Airgap.Spec.UpdateCursor
+		return ReplicatedCursor{
+			ChannelName: fetchOptions.Airgap.Spec.ChannelName,
+			Cursor:      fetchOptions.Airgap.Spec.UpdateCursor,
+		}
+	}
+	return ReplicatedCursor{
+		ChannelName: fetchOptions.CurrentChannel,
+		Cursor:      fetchOptions.CurrentCursor,
 	}
-	return fetchOptions.CurrentCursor
 }
diff --git a/pkg/upstream/peek.go b/pkg/upstream/peek.go
index b7f27a745..702778755 100644
--- a/pkg/upstream/peek.go
+++ b/pkg/upstream/peek.go
@@ -34,7 +34,11 @@ func getUpdatesUpstream(upstreamURI string, fetchOptions *FetchOptions) ([]Updat
 		return getUpdatesHelm(u, fetchOptions.HelmRepoURI)
 	}
 	if u.Scheme == "replicated" {
-		return getUpdatesReplicated(u, fetchOptions.LocalPath, fetchOptions.CurrentCursor, fetchOptions.CurrentVersionLabel, fetchOptions.License, fetchOptions.CurrentCursor)
+		cursor := ReplicatedCursor{
+			ChannelName: fetchOptions.CurrentChannel,
+			Cursor:      fetchOptions.CurrentCursor,
+		}
+		return getUpdatesReplicated(u, fetchOptions.LocalPath, cursor, fetchOptions.CurrentVersionLabel, fetchOptions.License)
 	}
 	if u.Scheme == "git" {
 		// return getUpdatesGit(upstreamURI)
diff --git a/pkg/upstream/replicated.go b/pkg/upstream/replicated.go
index 79290fb09..0798abe0e 100644
--- a/pkg/upstream/replicated.go
+++ b/pkg/upstream/replicated.go
@@ -47,12 +47,17 @@ type ReplicatedUpstream struct {
 	Sequence     *int
 }
 
+type ReplicatedCursor struct {
+	ChannelName string
+	Cursor      string
+}
+
 type App struct {
 	Name string
 }
 
 type Release struct {
-	UpdateCursor string
+	UpdateCursor ReplicatedCursor
 	VersionLabel string
 	ReleaseNotes string
 	Manifests    map[string][]byte
@@ -65,14 +70,18 @@ type ChannelRelease struct {
 	CreatedAt       string `json:"createdAt"`
 }
 
-func getUpdatesReplicated(u *url.URL, localPath string, currentCursor, versionLabel string, license *kotsv1beta1.License, channelSequence string) ([]Update, error) {
+func (this ReplicatedCursor) Equal(other ReplicatedCursor) bool {
+	return this.ChannelName == other.ChannelName && this.Cursor == other.Cursor
+}
+
+func getUpdatesReplicated(u *url.URL, localPath string, currentCursor ReplicatedCursor, versionLabel string, license *kotsv1beta1.License) ([]Update, error) {
 	if localPath != "" {
 		parsedLocalRelease, err := readReplicatedAppFromLocalPath(localPath, currentCursor, versionLabel)
 		if err != nil {
 			return nil, errors.Wrap(err, "failed to read replicated app from local path")
 		}
 
-		return []Update{{Cursor: parsedLocalRelease.UpdateCursor, VersionLabel: versionLabel}}, nil
+		return []Update{{Cursor: parsedLocalRelease.UpdateCursor.Cursor, VersionLabel: versionLabel}}, nil
 	}
 
 	// A license file is required to be set for this to succeed
@@ -90,7 +99,7 @@ func getUpdatesReplicated(u *url.URL, localPath string, currentCursor, versionLa
 		return nil, errors.Wrap(err, "failed to get successful head response")
 	}
 
-	pendingReleases, err := listPendingChannelReleases(replicatedUpstream, remoteLicense, channelSequence)
+	pendingReleases, err := listPendingChannelReleases(replicatedUpstream, remoteLicense, currentCursor)
 	if err != nil {
 		return nil, errors.Wrap(err, "failed to list replicated app releases")
 	}
@@ -105,7 +114,7 @@ func getUpdatesReplicated(u *url.URL, localPath string, currentCursor, versionLa
 	return updates, nil
 }
 
-func downloadReplicated(u *url.URL, localPath string, rootDir string, useAppDir bool, license *kotsv1beta1.License, existingConfigValues *kotsv1beta1.ConfigValues, updateCursor, versionLabel string, cipher *crypto.AESCipher) (*types.Upstream, error) {
+func downloadReplicated(u *url.URL, localPath string, rootDir string, useAppDir bool, license *kotsv1beta1.License, existingConfigValues *kotsv1beta1.ConfigValues, updateCursor ReplicatedCursor, versionLabel string, cipher *crypto.AESCipher) (*types.Upstream, error) {
 	var release *Release
 
 	if localPath != "" {
@@ -115,9 +124,6 @@ func downloadReplicated(u *url.URL, localPath string, rootDir string, useAppDir
 		}
 
 		release = parsedLocalRelease
-		if updateCursor != "" && release.UpdateCursor != updateCursor {
-			return nil, errors.Wrap(err, "release in local path does not match update cursor")
-		}
 	} else {
 		// A license file is required to be set for this to succeed
 		if license == nil {
@@ -199,7 +205,8 @@ func downloadReplicated(u *url.URL, localPath string, rootDir string, useAppDir
 		Name:          application.Name,
 		Files:         files,
 		Type:          "replicated",
-		UpdateCursor:  release.UpdateCursor,
+		UpdateCursor:  release.UpdateCursor.Cursor,
+		ChannelName:   license.Spec.ChannelName,
 		VersionLabel:  release.VersionLabel,
 		ReleaseNotes:  release.ReleaseNotes,
 		EncryptionKey: cipher.ToString(),
@@ -208,7 +215,7 @@ func downloadReplicated(u *url.URL, localPath string, rootDir string, useAppDir
 	return upstream, nil
 }
 
-func (r *ReplicatedUpstream) getRequest(method string, license *kotsv1beta1.License, channelSequence string) (*http.Request, error) {
+func (r *ReplicatedUpstream) getRequest(method string, license *kotsv1beta1.License, cursor ReplicatedCursor) (*http.Request, error) {
 	u, err := url.Parse(license.Spec.Endpoint)
 	if err != nil {
 		return nil, errors.Wrap(err, "failed to parse endpoint from license")
@@ -223,7 +230,11 @@ func (r *ReplicatedUpstream) getRequest(method string, license *kotsv1beta1.Lice
 	if r.Channel != nil {
 		urlPath = path.Join(urlPath, *r.Channel)
 	}
-	url := fmt.Sprintf("%s://%s?channelSequence=%s", u.Scheme, urlPath, channelSequence)
+
+	urlValues := url.Values{}
+	urlValues.Set("channelSequence", cursor.Cursor)
+	urlValues.Add("licenseSequence", fmt.Sprintf("%d", license.Spec.LicenseSequence))
+	url := fmt.Sprintf("%s://%s?%s", u.Scheme, urlPath, urlValues.Encode())
 
 	req, err := http.NewRequest(method, url, nil)
 	if err != nil {
@@ -259,7 +270,7 @@ func parseReplicatedURL(u *url.URL) (*ReplicatedUpstream, error) {
 }
 
 func getSuccessfulHeadResponse(replicatedUpstream *ReplicatedUpstream, license *kotsv1beta1.License) (*kotsv1beta1.License, error) {
-	headReq, err := replicatedUpstream.getRequest("HEAD", license, "")
+	headReq, err := replicatedUpstream.getRequest("HEAD", license, ReplicatedCursor{})
 	if err != nil {
 		return nil, errors.Wrap(err, "failed to create http request")
 	}
@@ -280,7 +291,7 @@ func getSuccessfulHeadResponse(replicatedUpstream *ReplicatedUpstream, license *
 	return license, nil
 }
 
-func readReplicatedAppFromLocalPath(localPath, localCursor, versionLabel string) (*Release, error) {
+func readReplicatedAppFromLocalPath(localPath string, localCursor ReplicatedCursor, versionLabel string) (*Release, error) {
 	release := Release{
 		Manifests:    make(map[string][]byte),
 		UpdateCursor: localCursor,
@@ -317,8 +328,8 @@ func readReplicatedAppFromLocalPath(localPath, localCursor, versionLabel string)
 	return &release, nil
 }
 
-func downloadReplicatedApp(replicatedUpstream *ReplicatedUpstream, license *kotsv1beta1.License, channelSequence string) (*Release, error) {
-	getReq, err := replicatedUpstream.getRequest("GET", license, channelSequence)
+func downloadReplicatedApp(replicatedUpstream *ReplicatedUpstream, license *kotsv1beta1.License, cursor ReplicatedCursor) (*Release, error) {
+	getReq, err := replicatedUpstream.getRequest("GET", license, cursor)
 	if err != nil {
 		return nil, errors.Wrap(err, "failed to create http request")
 	}
@@ -328,11 +339,16 @@ func downloadReplicatedApp(replicatedUpstream *ReplicatedUpstream, license *kots
 	}
 	defer getResp.Body.Close()
 
-	if getResp.StatusCode >= 400 {
+	if getResp.StatusCode >= 300 {
+		body, _ := ioutil.ReadAll(getResp.Body)
+		if len(body) > 0 {
+			return nil, util.ActionableError{Message: string(body)}
+		}
 		return nil, errors.Errorf("unexpected result from get request: %d", getResp.StatusCode)
 	}
 
-	updateCursor := getResp.Header.Get("X-Replicated-ChannelSequence")
+	updateSequence := getResp.Header.Get("X-Replicated-ChannelSequence")
+	updateChannel := getResp.Header.Get("X-Replicated-ChannelName")
 	versionLabel := getResp.Header.Get("X-Replicated-VersionLabel")
 
 	gzf, err := gzip.NewReader(getResp.Body)
@@ -341,8 +357,11 @@ func downloadReplicatedApp(replicatedUpstream *ReplicatedUpstream, license *kots
 	}
 
 	release := Release{
-		Manifests:    make(map[string][]byte),
-		UpdateCursor: updateCursor,
+		Manifests: make(map[string][]byte),
+		UpdateCursor: ReplicatedCursor{
+			ChannelName: updateChannel,
+			Cursor:      updateSequence,
+		},
 		VersionLabel: versionLabel,
 		// NOTE: release notes come from Application spec
 	}
@@ -378,7 +397,7 @@ func downloadReplicatedApp(replicatedUpstream *ReplicatedUpstream, license *kots
 	return &release, nil
 }
 
-func listPendingChannelReleases(replicatedUpstream *ReplicatedUpstream, license *kotsv1beta1.License, channelSequence string) ([]ChannelRelease, error) {
+func listPendingChannelReleases(replicatedUpstream *ReplicatedUpstream, license *kotsv1beta1.License, cursor ReplicatedCursor) ([]ChannelRelease, error) {
 	u, err := url.Parse(license.Spec.Endpoint)
 	if err != nil {
 		return nil, errors.Wrap(err, "failed to parse endpoint from license")
@@ -389,7 +408,15 @@ func listPendingChannelReleases(replicatedUpstream *ReplicatedUpstream, license
 		hostname = fmt.Sprintf("%s:%s", u.Hostname(), u.Port())
 	}
 
-	url := fmt.Sprintf("%s://%s/release/%s/pending?channelSequence=%s", u.Scheme, hostname, license.Spec.AppSlug, channelSequence)
+	sequence := cursor.Cursor
+	if license.Spec.ChannelName != cursor.ChannelName {
+		sequence = ""
+	}
+
+	urlValues := url.Values{}
+	urlValues.Set("channelSequence", sequence)
+	urlValues.Add("licenseSequence", fmt.Sprintf("%d", license.Spec.LicenseSequence))
+	url := fmt.Sprintf("%s://%s/release/%s/pending?%s", u.Scheme, hostname, license.Spec.AppSlug, urlValues.Encode())
 
 	req, err := http.NewRequest("GET", url, nil)
 	if err != nil {
@@ -405,15 +432,18 @@ func listPendingChannelReleases(replicatedUpstream *ReplicatedUpstream, license
 	}
 	defer resp.Body.Close()
 
-	if resp.StatusCode >= 400 {
-		return nil, errors.Errorf("unexpected result from get request: %d", resp.StatusCode)
-	}
-
 	body, err := ioutil.ReadAll(resp.Body)
 	if err != nil {
 		return nil, errors.Wrap(err, "failed to read response body")
 	}
 
+	if resp.StatusCode >= 400 {
+		if len(body) > 0 {
+			return nil, util.ActionableError{Message: string(body)}
+		}
+		return nil, errors.Errorf("unexpected result from get request: %d", resp.StatusCode)
+	}
+
 	var channelReleases struct {
 		ChannelReleases []ChannelRelease `json:"channelReleases"`
 	}
diff --git a/pkg/upstream/types/types.go b/pkg/upstream/types/types.go
index 0ded5ce8f..e06ef2892 100644
--- a/pkg/upstream/types/types.go
+++ b/pkg/upstream/types/types.go
@@ -22,6 +22,7 @@ type Upstream struct {
 	Type          string
 	Files         []UpstreamFile
 	UpdateCursor  string
+	ChannelName   string
 	VersionLabel  string
 	ReleaseNotes  string
 	EncryptionKey string
diff --git a/pkg/upstream/write.go b/pkg/upstream/write.go
index cd05399b3..900c76deb 100644
--- a/pkg/upstream/write.go
+++ b/pkg/upstream/write.go
@@ -115,6 +115,7 @@ func WriteUpstream(u *types.Upstream, options types.WriteOptions) error {
 		},
 		Spec: kotsv1beta1.InstallationSpec{
 			UpdateCursor:  u.UpdateCursor,
+			ChannelName:   u.ChannelName,
 			VersionLabel:  u.VersionLabel,
 			ReleaseNotes:  u.ReleaseNotes,
 			EncryptionKey: encryptionKey,
diff --git a/pkg/util/util.go b/pkg/util/util.go
index c926365b9..123d6f7c0 100644
--- a/pkg/util/util.go
+++ b/pkg/util/util.go
@@ -2,6 +2,7 @@ package util
 
 import (
 	"bytes"
+	"fmt"
 	"net/url"
 )
 
@@ -60,3 +61,11 @@ func IntPointer(x int) *int64 {
 	xout = int64(x)
 	return &xout
 }
+
+type ActionableError struct {
+	Message string
+}
+
+func (e ActionableError) Error() string {
+	return fmt.Sprintf("%s", e.Message)
+}


Test patch:
diff --git a/pkg/upstream/replicated_test.go b/pkg/upstream/replicated_test.go
index efa4a8785..7f5b432de 100644
--- a/pkg/upstream/replicated_test.go
+++ b/pkg/upstream/replicated_test.go
@@ -325,7 +325,13 @@ func Test_getRequest(t *testing.T) {
 		r := &ReplicatedUpstream{
 			Channel: test.channel,
 		}
-		request, err := r.getRequest("GET", license, test.channelSequence)
+		cursor := ReplicatedCursor{
+			Cursor: test.channelSequence,
+		}
+		if test.channel != nil {
+			cursor.ChannelName = *test.channel
+		}
+		request, err := r.getRequest("GET", license, cursor)
 		req.NoError(err)
 		assert.Equal(t, test.expectedURL, request.URL.String())
 	}