src/cli/updatecheck_test.go
Rama main · réplica de lectura del repositorio original.
package cli
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"sync/atomic"
"testing"
"time"
internalcli "github.com/schollz/croc/v11/internal/cli"
"github.com/schollz/croc/v11/src/croc"
)
func TestParseReleaseVersion(t *testing.T) {
for _, test := range []struct {
value string
want string
ok bool
}{
{value: "11.2.3", want: "11.2.3", ok: true},
{value: "v11.2.3", want: "11.2.3", ok: true},
{value: "0.0.0", want: "0.0.0", ok: true},
{value: "11.2", ok: false},
{value: "11.02.3", ok: false},
{value: "v11.2.3-rc.1", ok: false},
{value: " 11.2.3", ok: false},
{value: "11.2.3 ", ok: false},
{value: "18446744073709551616.0.0", ok: false},
} {
t.Run(test.value, func(t *testing.T) {
got, ok := parseReleaseVersion(test.value)
if ok != test.ok {
t.Fatalf("parseReleaseVersion(%q) ok = %v, want %v", test.value, ok, test.ok)
}
if ok && got.String() != test.want {
t.Fatalf("parseReleaseVersion(%q) = %q, want %q", test.value, got.String(), test.want)
}
})
}
}
func TestNewerRelease(t *testing.T) {
for _, test := range []struct {
name string
latest string
current string
want bool
}{
{name: "major", latest: "12.0.0", current: "11.9.9", want: true},
{name: "minor", latest: "11.3.0", current: "11.2.9", want: true},
{name: "patch", latest: "v11.2.4", current: "11.2.3", want: true},
{name: "equal", latest: "11.2.3", current: "11.2.3"},
{name: "older", latest: "10.9.9", current: "11.0.0"},
{name: "invalid latest", latest: "latest", current: "11.2.3"},
{name: "invalid current", latest: "11.2.3", current: "development"},
} {
t.Run(test.name, func(t *testing.T) {
if got := newerRelease(test.latest, test.current); got != test.want {
t.Fatalf("newerRelease(%q, %q) = %v, want %v", test.latest, test.current, got, test.want)
}
})
}
}
func TestFetchLatestRelease(t *testing.T) {
for _, test := range []struct {
name string
statusCode int
body string
want string
wantErr bool
}{
{name: "valid", statusCode: http.StatusOK, body: `{"tag_name":"v11.2.3"}`, want: "11.2.3"},
{name: "invalid json", statusCode: http.StatusOK, body: `{`, wantErr: true},
{name: "invalid tag", statusCode: http.StatusOK, body: `{"tag_name":"v11.2.3-rc.1"}`, wantErr: true},
{name: "missing tag", statusCode: http.StatusOK, body: `{}`, wantErr: true},
{name: "http failure", statusCode: http.StatusTooManyRequests, body: `{}`, wantErr: true},
} {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if got := request.Header.Get("Accept"); got != "application/vnd.github+json" {
t.Errorf("Accept header = %q", got)
}
if got := request.Header.Get("User-Agent"); got != "croc/11.2.2" {
t.Errorf("User-Agent header = %q", got)
}
response.WriteHeader(test.statusCode)
_, _ = io.WriteString(response, test.body)
}))
defer server.Close()
checker := transferVersionChecker{
currentVersion: "11.2.2",
endpoint: server.URL,
client: server.Client(),
}
got, err := checker.fetchLatest(context.Background())
if test.wantErr {
if err == nil {
t.Fatalf("fetchLatest() = %q, want error", got)
}
return
}
if err != nil {
t.Fatal(err)
}
if got != test.want {
t.Fatalf("fetchLatest() = %q, want %q", got, test.want)
}
})
}
}
func TestFetchLatestReleaseHonorsCancellation(t *testing.T) {
started := make(chan struct{})
stopped := make(chan struct{})
client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
close(started)
<-request.Context().Done()
close(stopped)
return nil, request.Context().Err()
})}
checker := transferVersionChecker{
currentVersion: "11.2.2",
endpoint: "https://example.test/latest",
client: client,
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
_, err := checker.fetchLatest(ctx)
done <- err
}()
<-started
cancel()
select {
case err := <-done:
if err == nil {
t.Fatal("fetchLatest succeeded after cancellation")
}
case <-time.After(time.Second):
t.Fatal("fetchLatest did not stop after cancellation")
}
<-stopped
}
func TestVersionCheckUsesFreshCache(t *testing.T) {
now := time.Date(2026, time.August, 20, 12, 0, 0, 0, time.UTC)
cachePath := filepath.Join(t.TempDir(), versionCheckCacheName)
if err := writeVersionCheckCache(cachePath, versionCheckCache{
CheckedAt: now.Add(-time.Hour),
LatestVersion: "11.2.3",
}); err != nil {
t.Fatal(err)
}
var requests atomic.Int32
checker, closeServer := newTestVersionChecker(t, cachePath, now, &requests, http.StatusOK, `{"tag_name":"v99.0.0"}`)
defer closeServer()
handle := checker.start(context.Background(), false)
result, ready := handle.finish()
if !ready || result.latestVersion != "11.2.3" {
t.Fatalf("fresh result = (%q, %v), want (11.2.3, true)", result.latestVersion, ready)
}
if got := requests.Load(); got != 0 {
t.Fatalf("fresh cache made %d requests, want 0", got)
}
}
func TestVersionCheckRefreshesStaleAndCorruptCaches(t *testing.T) {
now := time.Date(2026, time.August, 20, 12, 0, 0, 0, time.UTC)
for _, test := range []struct {
name string
contents []byte
}{
{name: "missing"},
{name: "stale", contents: mustMarshalCache(t, versionCheckCache{CheckedAt: now.Add(-25 * time.Hour), LatestVersion: "11.2.2"})},
{name: "future", contents: mustMarshalCache(t, versionCheckCache{CheckedAt: now.Add(time.Hour), LatestVersion: "11.2.2"})},
{name: "corrupt", contents: []byte(`not-json`)},
} {
t.Run(test.name, func(t *testing.T) {
cachePath := filepath.Join(t.TempDir(), versionCheckCacheName)
if test.contents != nil {
if err := os.WriteFile(cachePath, test.contents, 0o600); err != nil {
t.Fatal(err)
}
}
var requests atomic.Int32
checker, closeServer := newTestVersionChecker(t, cachePath, now, &requests, http.StatusOK, `{"tag_name":"v11.2.3"}`)
defer closeServer()
result := awaitVersionCheck(t, checker.start(context.Background(), false))
if result.latestVersion != "11.2.3" {
t.Fatalf("latest version = %q", result.latestVersion)
}
if got := requests.Load(); got != 1 {
t.Fatalf("requests = %d, want 1", got)
}
cached, err := readVersionCheckCache(cachePath)
if err != nil {
t.Fatal(err)
}
if cached.CheckedAt != now || cached.LatestVersion != "11.2.3" {
t.Fatalf("updated cache = %#v", cached)
}
})
}
}
func TestForcedVersionCheckBypassesFreshCache(t *testing.T) {
now := time.Date(2026, time.August, 20, 12, 0, 0, 0, time.UTC)
cachePath := filepath.Join(t.TempDir(), versionCheckCacheName)
if err := writeVersionCheckCache(cachePath, versionCheckCache{
CheckedAt: now,
LatestVersion: "11.2.2",
}); err != nil {
t.Fatal(err)
}
var requests atomic.Int32
checker, closeServer := newTestVersionChecker(t, cachePath, now, &requests, http.StatusOK, `{"tag_name":"v11.2.3"}`)
defer closeServer()
result := awaitVersionCheck(t, checker.start(context.Background(), true))
if result.latestVersion != "11.2.3" || requests.Load() != 1 {
t.Fatalf("forced result = %q with %d requests", result.latestVersion, requests.Load())
}
}
func TestFailedVersionCheckIsCachedSilently(t *testing.T) {
now := time.Date(2026, time.August, 20, 12, 0, 0, 0, time.UTC)
cachePath := filepath.Join(t.TempDir(), versionCheckCacheName)
var requests atomic.Int32
checker, closeServer := newTestVersionChecker(t, cachePath, now, &requests, http.StatusServiceUnavailable, `{}`)
defer closeServer()
result := awaitVersionCheck(t, checker.start(context.Background(), true))
if result.latestVersion != "" {
t.Fatalf("failed check returned version %q", result.latestVersion)
}
cached, err := readVersionCheckCache(cachePath)
if err != nil {
t.Fatal(err)
}
if cached.CheckedAt != now || cached.LatestVersion != "" {
t.Fatalf("failed check cache = %#v", cached)
}
if runtime.GOOS != "windows" {
info, err := os.Stat(cachePath)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("cache permissions = %o, want 600", got)
}
}
}
func TestVersionCheckCacheWriteFailureDoesNotHideResult(t *testing.T) {
now := time.Date(2026, time.August, 20, 12, 0, 0, 0, time.UTC)
cachePath := t.TempDir()
var requests atomic.Int32
checker, closeServer := newTestVersionChecker(t, cachePath, now, &requests, http.StatusOK, `{"tag_name":"v11.2.3"}`)
defer closeServer()
result := awaitVersionCheck(t, checker.start(context.Background(), true))
if result.latestVersion != "11.2.3" {
t.Fatalf("result after cache write failure = %q", result.latestVersion)
}
}
func TestFinishingVersionCheckNeverWaitsForNetwork(t *testing.T) {
started := make(chan struct{})
stopped := make(chan struct{})
client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
close(started)
<-request.Context().Done()
close(stopped)
return nil, request.Context().Err()
})}
checker := transferVersionChecker{
cachePath: filepath.Join(t.TempDir(), versionCheckCacheName),
currentVersion: "11.2.2",
endpoint: "https://example.test/latest",
client: client,
now: time.Now,
timeout: time.Minute,
}
handle := checker.start(context.Background(), true)
<-started
start := time.Now()
if _, ready := handle.finish(); ready {
t.Fatal("unfinished check unexpectedly had a result")
}
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
t.Fatalf("finish blocked for %s", elapsed)
}
select {
case <-stopped:
case <-time.After(time.Second):
t.Fatal("unfinished request was not canceled")
}
}
func TestCROCDoCheckRequiresExactValue(t *testing.T) {
for _, test := range []struct {
value string
want bool
}{
{value: ""},
{value: "true"},
{value: "01"},
{value: "1", want: true},
} {
t.Run(test.value, func(t *testing.T) {
t.Setenv("CROC_DO_CHECK", test.value)
if got := forceTransferVersionCheck(); got != test.want {
t.Fatalf("forceTransferVersionCheck() = %v, want %v", got, test.want)
}
})
}
}
func TestCROCDoCheckZeroSkipsTransferVersionCheck(t *testing.T) {
var requests atomic.Int32
oldTransport := http.DefaultTransport
t.Cleanup(func() { http.DefaultTransport = oldTransport })
http.DefaultTransport = roundTripFunc(func(*http.Request) (*http.Response, error) {
requests.Add(1)
return nil, io.ErrUnexpectedEOF
})
for _, test := range []struct {
name string
cached bool
}{
{name: "missing cache"},
{name: "fresh cache", cached: true},
} {
t.Run(test.name, func(t *testing.T) {
requests.Store(0)
configDir := t.TempDir()
cachePath := filepath.Join(configDir, versionCheckCacheName)
t.Setenv("CROC_CONFIG_DIR", configDir)
t.Setenv("CROC_DO_CHECK", "0")
if test.cached {
if err := writeVersionCheckCache(cachePath, versionCheckCache{
CheckedAt: time.Now(),
LatestVersion: "999.0.0",
}); err != nil {
t.Fatal(err)
}
}
set := flag.NewFlagSet("test", flag.ContinueOnError)
set.Bool("quiet", false, "")
app := internalcli.NewApp()
var output bytes.Buffer
app.ErrWriter = &output
finish := startTransferVersionCheck(internalcli.NewContext(app, set, nil), "")
// A started check runs in a goroutine, so give it time to do its work.
time.Sleep(100 * time.Millisecond)
finish()
if got := requests.Load(); got != 0 {
t.Errorf("requests = %d, want 0", got)
}
if _, err := os.Stat(cachePath); !test.cached && !os.IsNotExist(err) {
t.Errorf("version check cache was written: stat error = %v", err)
}
if output.Len() != 0 {
t.Errorf("notice = %q, want none", output.String())
}
})
}
}
func TestTransferVersionNoticeIsDeferredAndHonorsQuiet(t *testing.T) {
current, ok := parseReleaseVersion(Version)
if !ok {
t.Fatalf("test requires a valid current version, got %q", Version)
}
latest := fmt.Sprintf("%d.0.0", current.major+1)
want := fmt.Sprintf(
"A newer croc version is available: v%s (current: v%s).\nRun: croc update\n",
latest,
Version,
)
for _, test := range []struct {
name string
quiet bool
want string
}{
{name: "notice", want: want},
{name: "quiet", quiet: true},
} {
t.Run(test.name, func(t *testing.T) {
configDir := t.TempDir()
t.Setenv("CROC_CONFIG_DIR", configDir)
t.Setenv("CROC_DO_CHECK", "")
if err := writeVersionCheckCache(filepath.Join(configDir, versionCheckCacheName), versionCheckCache{
CheckedAt: time.Now(),
LatestVersion: latest,
}); err != nil {
t.Fatal(err)
}
set := flag.NewFlagSet("test", flag.ContinueOnError)
set.Bool("quiet", test.quiet, "")
app := internalcli.NewApp()
var output bytes.Buffer
app.ErrWriter = &output
ctx := internalcli.NewContext(app, set, nil)
finish := startTransferVersionCheck(ctx, "")
if output.Len() != 0 {
t.Fatalf("notice was written before finish: %q", output.String())
}
finish()
if got := output.String(); got != test.want {
t.Fatalf("notice = %q, want %q", got, test.want)
}
})
}
}
func TestForcedTransferVersionCheckReportsBeforeTransfer(t *testing.T) {
for _, test := range []struct {
name string
statusCode int
body string
quiet bool
want string
}{
{
name: "current",
statusCode: http.StatusOK,
body: `{"tag_name":"v11.2.3"}`,
want: "No newer croc version is available (current: v11.2.3, latest: v11.2.3).\n",
},
{
name: "newer",
statusCode: http.StatusOK,
body: `{"tag_name":"v11.2.4"}`,
want: "A newer croc version is available: v11.2.4 (current: v11.2.3).\nRun: croc update\n",
},
{
name: "unavailable",
statusCode: http.StatusServiceUnavailable,
body: `{}`,
want: "Could not check for a newer croc version (current: v11.2.3).\n",
},
{
name: "quiet",
statusCode: http.StatusOK,
body: `{"tag_name":"v11.2.3"}`,
quiet: true,
},
} {
t.Run(test.name, func(t *testing.T) {
var requests atomic.Int32
checker, closeServer := newTestVersionChecker(
t,
filepath.Join(t.TempDir(), versionCheckCacheName),
time.Now(),
&requests,
test.statusCode,
test.body,
)
defer closeServer()
checker.currentVersion = "11.2.3"
set := flag.NewFlagSet("test", flag.ContinueOnError)
set.Bool("quiet", test.quiet, "")
app := internalcli.NewApp()
var output bytes.Buffer
app.ErrWriter = &output
ctx := internalcli.NewContext(app, set, nil)
finish := startTransferVersionCheckWithChecker(ctx, checker, true)
if got := output.String(); got != test.want {
t.Fatalf("notice before transfer = %q, want %q", got, test.want)
}
if got := requests.Load(); got != 1 {
t.Fatalf("requests = %d, want 1", got)
}
finish()
if got := output.String(); got != test.want {
t.Fatalf("notice after finish = %q, want no duplicate", got)
}
})
}
}
func TestFailedSendAndReceiveRunDeferredVersionNotice(t *testing.T) {
current, ok := parseReleaseVersion(Version)
if !ok {
t.Fatalf("test requires a valid current version, got %q", Version)
}
latest := fmt.Sprintf("%d.0.0", current.major+1)
for _, test := range []struct {
name string
args []string
storedToken string
}{
{name: "send", args: []string{"croc", "--ignore-stdin", "send"}},
{name: "stored send", args: []string{"croc", "--ignore-stdin", "send", "--store"}},
{name: "stored receive", args: []string{"croc", "--ignore-stdin"}, storedToken: "invalid-stored-token"},
} {
t.Run(test.name, func(t *testing.T) {
configDir := t.TempDir()
t.Setenv("CROC_CONFIG_DIR", configDir)
t.Setenv("CROC_DO_CHECK", "")
t.Setenv("CROC_NO_UPDATE_CHECK", "")
t.Setenv("CROC_STORE_TOKEN", test.storedToken)
if err := writeVersionCheckCache(filepath.Join(configDir, versionCheckCacheName), versionCheckCache{
CheckedAt: time.Now(),
LatestVersion: latest,
}); err != nil {
t.Fatal(err)
}
app := newApp()
app.Writer = io.Discard
var errors bytes.Buffer
app.ErrWriter = &errors
if err := app.Run(test.args); err == nil {
t.Fatalf("%v unexpectedly succeeded", test.args)
}
if errors.Len() == 0 {
t.Fatalf("%v did not run the deferred notice", test.args)
}
})
}
}
func TestNoUpdateCheck(t *testing.T) {
oldTransport := http.DefaultTransport
t.Cleanup(func() { http.DefaultTransport = oldTransport })
http.DefaultTransport = roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, io.ErrUnexpectedEOF
})
current, ok := parseReleaseVersion(Version)
if !ok {
t.Fatalf("test requires a valid current version, got %q", Version)
}
latest := fmt.Sprintf("%d.0.0", current.major+1)
send := []string{"croc", "--ignore-stdin", "send"}
for _, test := range []struct {
name string
args []string
env string
doCheck string
remembered string
storedToken string
wantCheck bool
}{
{name: "default", args: send, wantCheck: true},
{name: "flag", args: []string{"croc", "--no-update-check", "--ignore-stdin", "send"}},
{name: "env", args: send, env: "1"},
{name: "remembered send", args: send, remembered: "send.json"},
{
name: "remembered stored receive",
args: []string{"croc", "--ignore-stdin"},
remembered: "receive.json",
storedToken: "invalid-stored-token",
},
{
name: "explicit false overrides remembered",
args: []string{"croc", "--no-update-check=false", "--ignore-stdin", "send"},
remembered: "send.json",
wantCheck: true,
},
{name: "env false overrides remembered", args: send, env: "0", remembered: "send.json", wantCheck: true},
{
name: "remember ignores the stored value",
args: []string{"croc", "--remember", "--ignore-stdin", "send"},
remembered: "send.json",
wantCheck: true,
},
{name: "forced check beats remembered", args: send, doCheck: "1", remembered: "send.json", wantCheck: true},
} {
t.Run(test.name, func(t *testing.T) {
configDir := t.TempDir()
t.Setenv("CROC_CONFIG_DIR", configDir)
t.Setenv("CROC_DO_CHECK", test.doCheck)
t.Setenv("CROC_NO_UPDATE_CHECK", test.env)
t.Setenv("CROC_STORE_TOKEN", test.storedToken)
cachePath := filepath.Join(configDir, versionCheckCacheName)
// A skipped check must not touch the cache, so only checks get a seed.
if test.wantCheck {
if err := writeVersionCheckCache(cachePath, versionCheckCache{
CheckedAt: time.Now(),
LatestVersion: latest,
}); err != nil {
t.Fatal(err)
}
}
if test.remembered != "" {
config := []byte(`{"NoUpdateCheck":true}`)
if err := os.WriteFile(filepath.Join(configDir, test.remembered), config, 0o600); err != nil {
t.Fatal(err)
}
}
app := newApp()
app.Writer = io.Discard
var output bytes.Buffer
app.ErrWriter = &output
if err := app.Run(test.args); err == nil {
t.Fatalf("%v unexpectedly succeeded", test.args)
}
if got := output.Len() != 0; got != test.wantCheck {
t.Fatalf("checked = %v, want %v (output %q)", got, test.wantCheck, output.String())
}
if !test.wantCheck {
// A started check runs in a goroutine, so give it time to do its work.
time.Sleep(100 * time.Millisecond)
if _, err := os.Stat(cachePath); !os.IsNotExist(err) {
t.Fatalf("version check cache was written: stat error = %v", err)
}
}
})
}
}
func TestRememberSavesNoUpdateCheck(t *testing.T) {
file := filepath.Join(t.TempDir(), "file.txt")
if err := os.WriteFile(file, []byte("data"), 0o600); err != nil {
t.Fatal(err)
}
// The relay is unreachable: both sides save their config before connecting.
// --ip keeps the receiver from discovering real senders on the network.
args := []string{
"croc", "--remember", "--no-update-check", "--yes", "--ignore-stdin", "--disable-clipboard",
"--relay", "127.0.0.1:1", "--ip", "127.0.0.1:1",
}
for _, test := range []struct {
config string
args []string
}{
{config: "send.json", args: append(args, "send", "--no-local", file)},
{config: "receive.json", args: args},
} {
t.Run(test.config, func(t *testing.T) {
configDir := t.TempDir()
t.Setenv("CROC_CONFIG_DIR", configDir)
t.Setenv("CROC_DO_CHECK", "")
t.Setenv("CROC_SECRET", "1234-remember-test")
t.Setenv("CROC_STORE_TOKEN", "")
app := newApp()
app.Writer = io.Discard
if err := app.Run(test.args); err == nil {
t.Fatalf("%v unexpectedly succeeded", test.args)
}
b, err := os.ReadFile(filepath.Join(configDir, test.config))
if err != nil {
t.Fatal(err)
}
var remembered croc.Options
if err := json.Unmarshal(b, &remembered); err != nil {
t.Fatal(err)
}
if !remembered.NoUpdateCheck {
t.Fatalf("%s did not save NoUpdateCheck: %s", test.config, b)
}
})
}
}
func newTestVersionChecker(
t *testing.T,
cachePath string,
now time.Time,
requests *atomic.Int32,
statusCode int,
body string,
) (transferVersionChecker, func()) {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
requests.Add(1)
response.WriteHeader(statusCode)
_, _ = io.WriteString(response, body)
}))
return transferVersionChecker{
cachePath: cachePath,
currentVersion: "11.2.2",
endpoint: server.URL,
client: server.Client(),
now: func() time.Time { return now },
timeout: time.Second,
}, server.Close
}
func awaitVersionCheck(t *testing.T, handle versionCheckHandle) versionCheckResult {
t.Helper()
select {
case result := <-handle.result:
handle.cancel()
return result
case <-time.After(2 * time.Second):
handle.cancel()
t.Fatal("version check did not finish")
return versionCheckResult{}
}
}
func mustMarshalCache(t *testing.T, cached versionCheckCache) []byte {
t.Helper()
contents, err := json.Marshal(cached)
if err != nil {
t.Fatal(err)
}
return contents
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return fn(request)
}