Files
2026-07-13 13:00:08 +08:00

429 lines
13 KiB
Go

package main
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"runtime"
"strings"
"sync/atomic"
"testing"
"time"
"reasonix/desktop/internal/update"
)
func TestNormalizeVersion(t *testing.T) {
cases := []struct {
in string
want string
ok bool
}{
{"dev", "", false},
{"", "", false},
{" ", "", false},
{"1.2.3", "v1.2.3", true},
{"v1.2.3", "v1.2.3", true},
{"v1.2", "v1.2.0", true}, // semver.Canonical fills the patch
{"garbage", "", false},
}
for _, c := range cases {
got, ok := normalizeVersion(c.in)
if got != c.want || ok != c.ok {
t.Errorf("normalizeVersion(%q) = (%q,%v), want (%q,%v)", c.in, got, ok, c.want, c.ok)
}
}
}
func TestEvaluate(t *testing.T) {
mk := func(version string) *update.Manifest {
return &update.Manifest{
Version: version,
Notes: "notes",
Platforms: map[string]update.Asset{update.CurrentPlatform(): {Size: 999}},
}
}
if got := evaluate("v1.0.0", mk("v1.1.0")); !got.Available {
t.Error("v1.0.0 -> v1.1.0 should be available")
}
if got := evaluate("v1.1.0", mk("v1.1.0")); got.Available {
t.Error("same version should not be available")
}
if got := evaluate("v1.2.0", mk("v1.1.0")); got.Available {
t.Error("newer-than-manifest should not be available")
}
// A dev build must never auto-prompt, even against a real release.
if got := evaluate("dev", mk("v1.1.0")); got.Available {
t.Error("dev build should not prompt to update")
}
// An invalid manifest version is a check error, not an update.
got := evaluate("v1.0.0", mk("not-a-version"))
if got.Available || got.Err == "" {
t.Errorf("invalid manifest version: got %+v", got)
}
// Metadata carries through.
full := evaluate("v1.0.0", mk("v1.1.0"))
if full.Latest != "v1.1.0" || full.Notes != "notes" || full.AssetSize != 999 {
t.Errorf("metadata not carried: %+v", full)
}
if full.CanSelfUpdate != (runtime.GOOS != "darwin") {
t.Errorf("CanSelfUpdate = %v on %s", full.CanSelfUpdate, runtime.GOOS)
}
}
func TestChannelSelectsDistinctPointers(t *testing.T) {
orig := channel
t.Cleanup(func() { channel = orig })
channel = "stable"
stable := manifestEndpoints()
channel = "canary"
canary := manifestEndpoints()
for _, u := range stable {
if strings.Contains(u, "canary") {
t.Errorf("stable endpoint leaks into canary: %q", u)
}
}
if !strings.Contains(stable[0], "/latest/latest.json") {
t.Errorf("stable primary = %q, want the latest/ pointer", stable[0])
}
if stable[1] != releaseGatewayBase+"/stable/latest.json" {
t.Errorf("stable fallback = %q, want the release gateway", stable[1])
}
// GitHub is stable's explicit last resort only (#6005: both first-party
// endpoints share one Cloudflare zone). Stable desktop releases own the
// repo-wide latest release and carry latest.json directly; no other slot may
// lean on repository-wide latest.
if len(stable) != 3 || stable[2] != githubManifestFallback {
t.Errorf("stable endpoints = %q, want the GitHub compatibility manifest last", stable)
}
for _, u := range append(stable[:2:2], canary...) {
if strings.Contains(u, "/releases/latest") {
t.Errorf("manifest endpoint uses GitHub's repository-wide latest release: %q", u)
}
}
for _, u := range canary {
if strings.Contains(u, "/latest/") {
t.Errorf("canary endpoint hits the stable latest/ pointer: %q", u)
}
}
if !strings.Contains(canary[0], "/canary/latest.json") {
t.Errorf("canary primary = %q, want the canary/ pointer", canary[0])
}
if canary[1] != releaseGatewayBase+"/canary/latest.json" {
t.Errorf("canary fallback = %q, want the release gateway", canary[1])
}
if strings.Contains(downloadPage(), "/releases/latest") {
t.Errorf("download page should not use GitHub's repository-wide latest release: %q", downloadPage())
}
}
func withUpdateCacheDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
restore := updateCacheBaseDir
updateCacheBaseDir = func() (string, error) { return dir, nil }
t.Cleanup(func() { updateCacheBaseDir = restore })
return dir
}
func sha256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func TestSaveCachedUpdateMarksEvaluateDownloaded(t *testing.T) {
withUpdateCacheDir(t)
oldChannel := channel
channel = "stable"
t.Cleanup(func() { channel = oldChannel })
data := []byte("verified artifact")
asset := update.Asset{
URL: "https://dl.reasonix.io/desktop-v9.9.9/Reasonix-linux-amd64.tar.gz",
Size: int64(len(data)),
SHA256: sha256Hex(data),
}
manifest := &update.Manifest{
Version: "v9.9.9",
Platforms: map[string]update.Asset{update.CurrentPlatform(): asset},
}
if got := evaluate("v1.0.0", manifest); got.Downloaded {
t.Fatal("fresh cache should not report a downloaded update")
}
meta, err := saveCachedUpdate("v9.9.9", asset, data)
if err != nil {
t.Fatalf("saveCachedUpdate: %v", err)
}
if meta.Version != "v9.9.9" || meta.Channel != "stable" || meta.Platform != update.CurrentPlatform() {
t.Fatalf("cached metadata mismatch: %+v", meta)
}
if got := evaluate("v1.0.0", manifest); !got.Downloaded {
t.Fatalf("evaluate did not detect cached update: %+v", got)
}
}
func TestCachedUpdateRejectsTamperedArtifact(t *testing.T) {
withUpdateCacheDir(t)
oldChannel := channel
channel = "stable"
t.Cleanup(func() { channel = oldChannel })
data := []byte("verified artifact")
asset := update.Asset{
URL: "https://dl.reasonix.io/desktop-v9.9.9/Reasonix-linux-amd64.tar.gz",
Size: int64(len(data)),
SHA256: sha256Hex(data),
}
meta, err := saveCachedUpdate("v9.9.9", asset, data)
if err != nil {
t.Fatalf("saveCachedUpdate: %v", err)
}
if err := os.WriteFile(meta.Path, []byte("tampered"), 0o600); err != nil {
t.Fatal(err)
}
if cachedUpdateMatches("v9.9.9", asset) {
t.Fatal("tampered cached artifact should not match")
}
if _, _, err := readVerifiedCachedUpdate(); err == nil {
t.Fatal("readVerifiedCachedUpdate should reject a tampered artifact")
}
}
func TestCachedUpdateRejectsDifferentChannel(t *testing.T) {
withUpdateCacheDir(t)
oldChannel := channel
channel = "stable"
t.Cleanup(func() { channel = oldChannel })
data := []byte("verified artifact")
asset := update.Asset{
URL: "https://dl.reasonix.io/desktop-v9.9.9/Reasonix-linux-amd64.tar.gz",
Size: int64(len(data)),
SHA256: sha256Hex(data),
}
if _, err := saveCachedUpdate("v9.9.9", asset, data); err != nil {
t.Fatalf("saveCachedUpdate: %v", err)
}
channel = "canary"
if _, _, err := readVerifiedCachedUpdate(); err == nil {
t.Fatal("readVerifiedCachedUpdate should reject a cache from another channel")
}
}
func TestCheckSHA256(t *testing.T) {
data := []byte("hello world")
// echo -n "hello world" | shasum -a 256
const sum = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
if err := checkSHA256(data, sum); err != nil {
t.Errorf("matching digest should pass: %v", err)
}
if err := checkSHA256(data, "deadbeef"); err == nil {
t.Error("mismatched digest should fail")
}
// Case-insensitive hex.
if err := checkSHA256(data, "B94D27B9934D3E08A52E52D7DA7DABFAC484EFE37A5380EE9088F7ACE2EFCDE9"); err != nil {
t.Errorf("uppercase digest should pass: %v", err)
}
}
func TestExtractBinary(t *testing.T) {
want := []byte("#!/bin/sh\necho reasonix\n")
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
files := map[string][]byte{"README": []byte("ignore me"), "reasonix-desktop": want}
for name, body := range files {
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(body)), Typeflag: tar.TypeReg}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(body); err != nil {
t.Fatal(err)
}
}
tw.Close()
gz.Close()
got, err := extractBinary(buf.Bytes(), "reasonix-desktop")
if err != nil {
t.Fatalf("extractBinary: %v", err)
}
if !bytes.Equal(got, want) {
t.Fatalf("extracted %q, want %q", got, want)
}
if _, err := extractBinary(buf.Bytes(), "missing"); err == nil {
t.Error("missing entry should error")
}
}
func fastRetry(t *testing.T) {
t.Helper()
restore := retryBackoff
retryBackoff = func(int) time.Duration { return time.Millisecond }
t.Cleanup(func() { retryBackoff = restore })
}
func TestDownloadRecoversFromMidStreamReset(t *testing.T) {
fastRetry(t)
const body = "complete-installer-bytes"
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if atomic.AddInt32(&calls, 1) < int32(downloadAttempts) {
// Mid-stream reset: promise 100 bytes, send a few, drop the socket —
// the client's body read fails with unexpected EOF, exactly the CN-IPv6
// "forcibly closed" case the retry exists for.
conn, bw, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Errorf("hijack: %v", err)
return
}
bw.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\npartial")
bw.Flush()
conn.Close()
return
}
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
data, err := download(context.Background(), srv.Client(), nil, srv.URL, 0, nil)
if err != nil {
t.Fatalf("download should recover after %d resets: %v", downloadAttempts-1, err)
}
if string(data) != body {
t.Fatalf("got %q, want %q", data, body)
}
if n := atomic.LoadInt32(&calls); n != int32(downloadAttempts) {
t.Fatalf("made %d attempts, want %d", n, downloadAttempts)
}
}
func TestDownloadGivesUpAfterCap(t *testing.T) {
fastRetry(t)
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&calls, 1)
conn, _, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Errorf("hijack: %v", err)
return
}
conn.Close()
}))
defer srv.Close()
if _, err := download(context.Background(), srv.Client(), nil, srv.URL, 0, nil); err == nil {
t.Fatal("download should fail after exhausting retries")
}
if n := atomic.LoadInt32(&calls); n != int32(downloadAttempts) {
t.Fatalf("made %d attempts, want %d", n, downloadAttempts)
}
}
func TestRetryTransientStopsWhenCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
calls := 0
if err := retryTransient(ctx, func(int) error {
calls++
return errors.New("boom")
}); err == nil {
t.Fatal("cancelled retry should return the error")
}
if calls != 1 {
t.Fatalf("cancelled retry made %d calls, want 1", calls)
}
}
func TestDownloadResumesWithRange(t *testing.T) {
fastRetry(t)
full := bytes.Repeat([]byte("0123456789"), 50) // 500 bytes
const cut = 200
var calls int32
rangeCh := make(chan string, 4)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if atomic.AddInt32(&calls, 1) == 1 {
// First attempt: promise the whole file, send a prefix, drop the socket.
conn, bw, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Errorf("hijack: %v", err)
return
}
fmt.Fprintf(bw, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\n\r\n", len(full))
bw.Write(full[:cut])
bw.Flush()
conn.Close()
return
}
// Resume attempt: honor the Range header with a 206 + Content-Range.
rng := r.Header.Get("Range")
rangeCh <- rng
start := 0
fmt.Sscanf(rng, "bytes=%d-", &start)
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, len(full)-1, len(full)))
w.WriteHeader(http.StatusPartialContent)
w.Write(full[start:])
}))
defer srv.Close()
data, err := download(context.Background(), srv.Client(), nil, srv.URL, 0, nil)
if err != nil {
t.Fatalf("download: %v", err)
}
if !bytes.Equal(data, full) {
t.Fatalf("assembled %d bytes, want %d (equal=%v)", len(data), len(full), bytes.Equal(data, full))
}
select {
case rng := <-rangeCh:
if rng != fmt.Sprintf("bytes=%d-", cut) {
t.Fatalf("resume Range = %q, want bytes=%d-", rng, cut)
}
default:
t.Fatal("resume attempt sent no Range header")
}
}
func TestDownloadFallsBackToSecondClient(t *testing.T) {
fastRetry(t)
const body = "served-over-ipv4"
primary := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New("connection reset (ipv6)")
})}
var fbCalls int32
fallback := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) {
atomic.AddInt32(&fbCalls, 1)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
ContentLength: int64(len(body)),
Header: make(http.Header),
}, nil
})}
data, err := download(context.Background(), primary, fallback, "http://example.invalid/x", 0, nil)
if err != nil {
t.Fatalf("download: %v", err)
}
if string(data) != body {
t.Fatalf("got %q, want %q", data, body)
}
if atomic.LoadInt32(&fbCalls) == 0 {
t.Fatal("fallback client was never used after the primary failed")
}
}
type rtFunc func(*http.Request) (*http.Response, error)
func (f rtFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }