Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -544,4 +544,4 @@ jobs:
session-build-macos-13
session-build-macos-latest
session-build-windows-2025
session-build-ubuntu-24.04-arm
session-build-ubuntu-24.04-arm
3 changes: 3 additions & 0 deletions internal/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,9 @@ const SecurityPromptConfig = "security.prompt.enabled"
// SecurityPromptLevelConfig is the config key used to determine the level of security prompts
const SecurityPromptLevelConfig = "security.prompt.level"

// UpdateEndpointConfig is the config key used to determine the update endpoint to use
const UpdateEndpointConfig = "update.endpoint"

// APIHostConfig is the config key used to determine the api host
const APIHostConfig = "api.host"

Expand Down
2 changes: 2 additions & 0 deletions internal/testhelpers/e2e/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ type Session struct {
ExecutorExe string
spawned []*SpawnedCmd
ignoreLogErrors bool
cfg *config.Instance
cache keyCache
cfg *config.Instance
}
Expand Down Expand Up @@ -200,6 +201,7 @@ func new(t *testing.T, retainDirs, updatePath bool, extraEnv ...string) *Session

cfg, err := config.NewCustom(dirs.Config, singlethread.New(), true)
require.NoError(session.T, err)
session.cfg = cfg

if err := cfg.Set(constants.SecurityPromptConfig, false); err != nil {
require.NoError(session.T, err)
Expand Down
45 changes: 31 additions & 14 deletions internal/updater/checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import (
"github.com/ActiveState/cli/internal/logging"
"github.com/ActiveState/cli/internal/retryhttp"
"github.com/ActiveState/cli/internal/rtutils/ptr"

configMediator "github.com/ActiveState/cli/internal/mediators/config"
)

type Configurable interface {
Expand All @@ -32,30 +34,28 @@ var (
InvocationSourceUpdate InvocationSource = "update"
)

func init() {
configMediator.RegisterOption(constants.UpdateEndpointConfig, configMediator.String, "")
}

type Checker struct {
cfg Configurable
an analytics.Dispatcher
apiInfoURL string
retryhttp *retryhttp.Client
cache *AvailableUpdate
done chan struct{}
cfg Configurable
an analytics.Dispatcher
retryhttp *retryhttp.Client
cache *AvailableUpdate
done chan struct{}

InvocationSource InvocationSource
}

func NewDefaultChecker(cfg Configurable, an analytics.Dispatcher) *Checker {
infoURL := constants.APIUpdateInfoURL
if url, ok := os.LookupEnv("_TEST_UPDATE_INFO_URL"); ok {
infoURL = url
}
return NewChecker(cfg, an, infoURL, retryhttp.DefaultClient)
return NewChecker(cfg, an, retryhttp.DefaultClient)
}

func NewChecker(cfg Configurable, an analytics.Dispatcher, infoURL string, httpget *retryhttp.Client) *Checker {
func NewChecker(cfg Configurable, an analytics.Dispatcher, httpget *retryhttp.Client) *Checker {
return &Checker{
cfg,
an,
infoURL,
httpget,
nil,
make(chan struct{}),
Expand Down Expand Up @@ -83,11 +83,26 @@ func (u *Checker) infoURL(tag, desiredVersion, branchName, platform, arch string
v.Set("target-version", desiredVersion)
}

var (
infoURL string

envUrl = os.Getenv("_TEST_UPDATE_INFO_URL")
cfgUrl = u.cfg.GetString(constants.UpdateEndpointConfig)
)
switch {
case envUrl != "":
infoURL = envUrl
case cfgUrl != "":
infoURL = cfgUrl
default:
infoURL = constants.APIUpdateInfoURL
}

if tag != "" {
v.Set("tag", tag)
}

return u.apiInfoURL + "/info?" + v.Encode()
return infoURL + "/info?" + v.Encode()
}

func (u *Checker) getUpdateInfo(desiredChannel, desiredVersion string) (*AvailableUpdate, error) {
Expand Down Expand Up @@ -118,13 +133,15 @@ func (u *Checker) getUpdateInfo(desiredChannel, desiredVersion string) (*Availab
logging.Debug("Update info 404s: %v", errs.JoinMessage(err))
label = anaConst.UpdateLabelUnavailable
msg = anaConst.UpdateErrorNotFound
info = &AvailableUpdate{}

// The request could not be satisfied or service is unavailable. This happens when Cloudflare
// blocks access, or the service is unavailable in a particular geographic location.
case resp.StatusCode == 403 || resp.StatusCode == 503:
logging.Warning("Update info request blocked or service unavailable: %v", err)
label = anaConst.UpdateLabelUnavailable
msg = anaConst.UpdateErrorBlocked
info = &AvailableUpdate{}

// If all went well.
default:
Expand Down
55 changes: 55 additions & 0 deletions test/integration/update_int_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,61 @@ func (suite *UpdateIntegrationTestSuite) TestUpdateTags() {
}
}

func (suite *UpdateIntegrationTestSuite) TestUpdateHost_SetBeforeInvocation() {
suite.OnlyRunForTags(tagsuite.Update)

ts := e2e.New(suite.T(), false)
defer ts.Close()

ts.SetConfig(constants.UpdateEndpointConfig, "https://test.example.com/update")
suite.Assert().Equal(ts.GetConfig(constants.UpdateEndpointConfig), "https://test.example.com/update")

cp := ts.SpawnWithOpts(
e2e.OptArgs("--version"),
)
cp.ExpectExitCode(0)

correctHostCount := 0
incorrectHostCount := 0
for _, path := range ts.LogFiles() {
contents := string(fileutils.ReadFileUnsafe(path))
if strings.Contains(contents, "https://test.example.com/update") {
correctHostCount++
}
if strings.Contains(contents, "https://platform.activestate.com/update") {
incorrectHostCount++
}
}
suite.Assert().Greater(correctHostCount, 0, "Log file should contain the configured API host 'test.example.com'")
suite.Assert().Equal(incorrectHostCount, 0, "Log file should not contain the default API host 'platform.activestate.com'")

// Clean up - remove the config setting
cp = ts.Spawn("config", "set", constants.UpdateEndpointConfig, "")
cp.Expect("Successfully")
cp.ExpectExitCode(0)
}

func (suite *UpdateIntegrationTestSuite) TestUpdateHost() {
suite.OnlyRunForTags(tagsuite.Update)

ts := e2e.New(suite.T(), false)
defer ts.Close()

cp := ts.Spawn("config", "set", constants.UpdateEndpointConfig, "https://example.com/update")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I prefer to use something that's not an actual domain like test.tld. I can only imagine the traffic example.com sees :p

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

example.com is not a valid domain. This is so you can use it in these contexts.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

example.com is not a valid domain. This is so you can use it in these contexts.

I'm going to pretend that I knew that when I wrote this test 🙂

cp.Expect("Successfully set config key")
cp.ExpectExitCode(0)

cp = ts.SpawnWithOpts(
e2e.OptArgs("update"),
e2e.OptAppendEnv(suite.env(false, false)...),
e2e.OptAppendEnv("VERBOSE=true"),
)
cp.ExpectExitCode(0)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This really gives a zero exit code? I would imagine this will fail.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The checker handles some 4xx codes without throwing an error. I think that's what's happening here.


output := cp.Snapshot()
suite.Assert().Contains(output, "Getting update info: https://example.com/update/")
}

func TestUpdateIntegrationTestSuite(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode.")
Expand Down
Loading