Implement internal/updater: selfupdate via Forgejo Releases API
Adds a small, well-tested package that: - Queries /api/v1/repos/root/drover-go/releases/latest (404 = no updates, not an error). - Compares the published tag against the running Version using golang.org/x/mod/semver, so v0.1.0-rc.2 < v0.1.0. "dev" or any semver-invalid current version is treated as "always update". - Downloads the windows-amd64 asset + SHA256SUMS.txt, verifies the sha256 of the binary against its line in the sums file (tolerates the asterisk binary-mode prefix), and atomically swaps the running exe via github.com/minio/selfupdate. - Uses a 15s connect timeout with no overall request deadline, so large asset downloads aren't truncated. - Reports progress via an optional callback. Public surface: Source interface + ForgejoSource implementation, CheckForUpdate, ApplyUpdate, SetVersion. No GUI/cobra/Wails imports in the package, so the same code is reusable from the CLI, the Windows service, and the future tray UI. Wires the package into "drover update" / "drover update --check-only" in cmd/drover/main.go. --check-only exits 0 whether or not an update is available; only network/sha/apply errors are non-zero. Tests cover CheckForUpdate (table-driven incl. semver pre-release ordering, dev fallthrough, source errors), parseSHA256Sums (text and binary modes, CRLF, malformed lines, missing entries), ForgejoSource.Latest (httptest with canned JSON, 404, 500, missing asset, missing SHA256SUMS), and downloadAndVerify (success, sha mismatch, HTTP 404, context cancellation). All run with -race. Smoke-tested manually: built drover.exe and "drover update --check-only" against git.okcu.io prints "No updates available" and exits 0 (no releases yet). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,612 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeSource is an in-memory Source implementation for tests.
|
||||
type fakeSource struct {
|
||||
rel *Release
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeSource) Latest(ctx context.Context) (*Release, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.rel, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CheckForUpdate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCheckForUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rel := func(tag string) *Release {
|
||||
return &Release{TagName: tag, Name: tag, AssetURL: "u", SumsURL: "s"}
|
||||
}
|
||||
sentinel := errors.New("network down")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
current string
|
||||
src Source
|
||||
wantHasUpdate bool
|
||||
wantNilRelease bool
|
||||
wantErrIs error
|
||||
}{
|
||||
{
|
||||
name: "newer release available",
|
||||
current: "0.1.0",
|
||||
src: &fakeSource{rel: rel("v0.2.0")},
|
||||
wantHasUpdate: true,
|
||||
},
|
||||
{
|
||||
name: "newer release with v-prefixed current version",
|
||||
current: "v0.1.0",
|
||||
src: &fakeSource{rel: rel("v0.2.0")},
|
||||
wantHasUpdate: true,
|
||||
},
|
||||
{
|
||||
name: "same version — no update",
|
||||
current: "0.2.0",
|
||||
src: &fakeSource{rel: rel("v0.2.0")},
|
||||
wantHasUpdate: false,
|
||||
wantNilRelease: true,
|
||||
},
|
||||
{
|
||||
name: "older release on remote — no update",
|
||||
current: "0.5.0",
|
||||
src: &fakeSource{rel: rel("v0.2.0")},
|
||||
wantHasUpdate: false,
|
||||
wantNilRelease: true,
|
||||
},
|
||||
{
|
||||
name: "rc < final (semver pre-release ordering)",
|
||||
current: "0.1.0-rc.2",
|
||||
src: &fakeSource{rel: rel("v0.1.0")},
|
||||
wantHasUpdate: true,
|
||||
},
|
||||
{
|
||||
name: "final >= rc",
|
||||
current: "0.1.0",
|
||||
src: &fakeSource{rel: rel("v0.1.0-rc.2")},
|
||||
wantHasUpdate: false,
|
||||
wantNilRelease: true,
|
||||
},
|
||||
{
|
||||
name: "dev current — always update",
|
||||
current: "dev",
|
||||
src: &fakeSource{rel: rel("v0.1.0")},
|
||||
wantHasUpdate: true,
|
||||
},
|
||||
{
|
||||
name: "garbage current — always update (treated as invalid)",
|
||||
current: "not-a-version",
|
||||
src: &fakeSource{rel: rel("v0.1.0")},
|
||||
wantHasUpdate: true,
|
||||
},
|
||||
{
|
||||
name: "source returns nil release — no update",
|
||||
current: "0.1.0",
|
||||
src: &fakeSource{rel: nil},
|
||||
wantHasUpdate: false,
|
||||
wantNilRelease: true,
|
||||
},
|
||||
{
|
||||
name: "source error propagates wrapped",
|
||||
current: "0.1.0",
|
||||
src: &fakeSource{err: sentinel},
|
||||
wantErrIs: sentinel,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
got, has, err := CheckForUpdate(ctx, tc.src, tc.current)
|
||||
if tc.wantErrIs != nil {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error wrapping %v, got nil", tc.wantErrIs)
|
||||
}
|
||||
if !errors.Is(err, tc.wantErrIs) {
|
||||
t.Fatalf("err = %v; expected to wrap %v", err, tc.wantErrIs)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if has != tc.wantHasUpdate {
|
||||
t.Fatalf("hasUpdate = %v; want %v", has, tc.wantHasUpdate)
|
||||
}
|
||||
if tc.wantNilRelease && got != nil {
|
||||
t.Fatalf("expected nil release; got %+v", got)
|
||||
}
|
||||
if !tc.wantNilRelease && tc.wantHasUpdate && got == nil {
|
||||
t.Fatalf("expected non-nil release on update; got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseSHA256Sums
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestParseSHA256Sums(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const a = "8da085332782708d8767bcace5327a6ec7283c17cfb85e40b03cd2323a90ddc2"
|
||||
const b = "c1e060ee19444a259b2162f8af0f3fe8c4428a1c6f694dce20de194ac8d7d9a2"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
wantName string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "standard text-mode line",
|
||||
input: a + " drover-v0.1.0-windows-amd64.exe\n",
|
||||
want: a,
|
||||
wantName: "drover-v0.1.0-windows-amd64.exe",
|
||||
},
|
||||
{
|
||||
name: "binary-mode asterisk prefix",
|
||||
input: b + " *drover-v0.1.0-setup.exe\n",
|
||||
want: b,
|
||||
wantName: "drover-v0.1.0-setup.exe",
|
||||
},
|
||||
{
|
||||
name: "multi-line, target on second",
|
||||
input: a + " some-other-file.txt\n" +
|
||||
b + " drover-v0.1.0-setup.exe\n",
|
||||
want: b,
|
||||
wantName: "drover-v0.1.0-setup.exe",
|
||||
},
|
||||
{
|
||||
name: "multi-line, blank lines and CRLF tolerated",
|
||||
input: "\r\n" +
|
||||
a + " drover-v0.1.0-windows-amd64.exe\r\n" +
|
||||
"\n",
|
||||
want: a,
|
||||
wantName: "drover-v0.1.0-windows-amd64.exe",
|
||||
},
|
||||
{
|
||||
name: "missing entry",
|
||||
input: a + " some-other-file.txt\n",
|
||||
wantName: "drover-v0.1.0-windows-amd64.exe",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "malformed line — no separator",
|
||||
input: a + "drover-v0.1.0-windows-amd64.exe\n",
|
||||
wantName: "drover-v0.1.0-windows-amd64.exe",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "malformed line — bad hex length",
|
||||
input: "deadbeef drover-v0.1.0-windows-amd64.exe\n",
|
||||
wantName: "drover-v0.1.0-windows-amd64.exe",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
wantName: "drover-v0.1.0-windows-amd64.exe",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := parseSHA256Sums([]byte(tc.input), tc.wantName)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error; got hash %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("hash = %q; want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ForgejoSource.Latest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const sampleReleaseJSON = `{
|
||||
"tag_name": "v0.1.0",
|
||||
"name": "v0.1.0",
|
||||
"draft": false,
|
||||
"prerelease": false,
|
||||
"created_at": "2026-04-25T13:35:33+03:00",
|
||||
"assets": [
|
||||
{
|
||||
"name": "drover-v0.1.0-windows-amd64.exe",
|
||||
"size": 5800000,
|
||||
"browser_download_url": "%s/drover-v0.1.0-windows-amd64.exe"
|
||||
},
|
||||
{
|
||||
"name": "SHA256SUMS.txt",
|
||||
"size": 200,
|
||||
"browser_download_url": "%s/SHA256SUMS.txt"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
func TestForgejoSource_Latest_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var srv *httptest.Server
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/repos/root/drover-go/releases/latest" {
|
||||
t.Errorf("unexpected request path: %s", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if ua := r.Header.Get("User-Agent"); !strings.HasPrefix(ua, "drover-go/") {
|
||||
t.Errorf("expected User-Agent drover-go/...; got %q", ua)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, sampleReleaseJSON, srv.URL, srv.URL)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
src := &ForgejoSource{
|
||||
baseURL: srv.URL,
|
||||
owner: "root",
|
||||
repo: "drover-go",
|
||||
assetPattern: "windows-amd64.exe",
|
||||
client: srv.Client(),
|
||||
userAgent: "drover-go/test",
|
||||
}
|
||||
|
||||
rel, err := src.Latest(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Latest err = %v", err)
|
||||
}
|
||||
if rel == nil {
|
||||
t.Fatal("nil release returned")
|
||||
}
|
||||
if rel.TagName != "v0.1.0" {
|
||||
t.Errorf("TagName = %q; want v0.1.0", rel.TagName)
|
||||
}
|
||||
wantAsset := srv.URL + "/drover-v0.1.0-windows-amd64.exe"
|
||||
if rel.AssetURL != wantAsset {
|
||||
t.Errorf("AssetURL = %q; want %q", rel.AssetURL, wantAsset)
|
||||
}
|
||||
wantSums := srv.URL + "/SHA256SUMS.txt"
|
||||
if rel.SumsURL != wantSums {
|
||||
t.Errorf("SumsURL = %q; want %q", rel.SumsURL, wantSums)
|
||||
}
|
||||
if rel.Prerelease {
|
||||
t.Error("Prerelease = true; want false")
|
||||
}
|
||||
if rel.CreatedAt.IsZero() {
|
||||
t.Error("CreatedAt is zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForgejoSource_Latest_404_NoUpdates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
src := &ForgejoSource{
|
||||
baseURL: srv.URL,
|
||||
owner: "root",
|
||||
repo: "drover-go",
|
||||
assetPattern: "windows-amd64.exe",
|
||||
client: srv.Client(),
|
||||
userAgent: "drover-go/test",
|
||||
}
|
||||
|
||||
rel, err := src.Latest(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error on 404; got %v", err)
|
||||
}
|
||||
if rel != nil {
|
||||
t.Fatalf("expected nil release on 404; got %+v", rel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForgejoSource_Latest_500_Error(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
src := &ForgejoSource{
|
||||
baseURL: srv.URL,
|
||||
owner: "root",
|
||||
repo: "drover-go",
|
||||
assetPattern: "windows-amd64.exe",
|
||||
client: srv.Client(),
|
||||
userAgent: "drover-go/test",
|
||||
}
|
||||
|
||||
_, err := src.Latest(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error on 500; got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "500") {
|
||||
t.Errorf("expected error to mention HTTP 500; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForgejoSource_Latest_AssetMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Sample with only SHA256SUMS, no exe asset.
|
||||
body := `{
|
||||
"tag_name": "v0.1.0",
|
||||
"name": "v0.1.0",
|
||||
"draft": false,
|
||||
"prerelease": false,
|
||||
"created_at": "2026-04-25T13:35:33+03:00",
|
||||
"assets": [
|
||||
{"name":"SHA256SUMS.txt","size":1,"browser_download_url":"https://example.invalid/s"}
|
||||
]
|
||||
}`
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, body)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
src := &ForgejoSource{
|
||||
baseURL: srv.URL,
|
||||
owner: "root",
|
||||
repo: "drover-go",
|
||||
assetPattern: "windows-amd64.exe",
|
||||
client: srv.Client(),
|
||||
userAgent: "drover-go/test",
|
||||
}
|
||||
_, err := src.Latest(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when matching asset is missing; got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForgejoSource_Latest_SumsMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := `{
|
||||
"tag_name": "v0.1.0",
|
||||
"name": "v0.1.0",
|
||||
"draft": false,
|
||||
"prerelease": false,
|
||||
"created_at": "2026-04-25T13:35:33+03:00",
|
||||
"assets": [
|
||||
{"name":"drover-v0.1.0-windows-amd64.exe","size":1,"browser_download_url":"https://example.invalid/x"}
|
||||
]
|
||||
}`
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, body)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
src := &ForgejoSource{
|
||||
baseURL: srv.URL,
|
||||
owner: "root",
|
||||
repo: "drover-go",
|
||||
assetPattern: "windows-amd64.exe",
|
||||
client: srv.Client(),
|
||||
userAgent: "drover-go/test",
|
||||
}
|
||||
_, err := src.Latest(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when SHA256SUMS.txt is missing; got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "SHA256SUMS.txt") {
|
||||
t.Errorf("expected error to mention SHA256SUMS.txt; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewForgejoSource_BuildsURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
src := NewForgejoSource("git.okcu.io", "root", "drover-go", "windows-amd64.exe").(*ForgejoSource)
|
||||
if want := "https://git.okcu.io"; src.baseURL != want {
|
||||
t.Errorf("baseURL = %q; want %q", src.baseURL, want)
|
||||
}
|
||||
if src.owner != "root" || src.repo != "drover-go" {
|
||||
t.Errorf("owner/repo wrong: %s/%s", src.owner, src.repo)
|
||||
}
|
||||
if src.assetPattern != "windows-amd64.exe" {
|
||||
t.Errorf("assetPattern = %q", src.assetPattern)
|
||||
}
|
||||
if src.client == nil {
|
||||
t.Error("client must not be nil")
|
||||
}
|
||||
if !strings.HasPrefix(src.userAgent, "drover-go/") {
|
||||
t.Errorf("userAgent = %q; want drover-go/...", src.userAgent)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// downloadAndVerify (the testable extract from ApplyUpdate)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDownloadAndVerify_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
exeData := []byte("MZ\x90fake-windows-binary-content")
|
||||
hash := sha256.Sum256(exeData)
|
||||
hexHash := hex.EncodeToString(hash[:])
|
||||
|
||||
exeName := "drover-v0.1.0-windows-amd64.exe"
|
||||
sumsBody := hexHash + " " + exeName + "\n"
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/exe":
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(exeData)))
|
||||
_, _ = w.Write(exeData)
|
||||
case "/sums":
|
||||
_, _ = io.WriteString(w, sumsBody)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
rel := &Release{
|
||||
TagName: "v0.1.0",
|
||||
Name: "v0.1.0",
|
||||
AssetURL: srv.URL + "/exe",
|
||||
SumsURL: srv.URL + "/sums",
|
||||
}
|
||||
|
||||
var pcDownloaded, pcTotal int64
|
||||
bin, err := downloadAndVerify(context.Background(), rel, exeName, srv.Client(), "drover-go/test", func(d, total int64) {
|
||||
pcDownloaded = d
|
||||
pcTotal = total
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("downloadAndVerify err = %v", err)
|
||||
}
|
||||
if !equalBytes(bin, exeData) {
|
||||
t.Fatalf("downloaded bytes do not match source")
|
||||
}
|
||||
if pcTotal != int64(len(exeData)) {
|
||||
t.Errorf("progress total = %d; want %d", pcTotal, len(exeData))
|
||||
}
|
||||
if pcDownloaded != int64(len(exeData)) {
|
||||
t.Errorf("progress downloaded final = %d; want %d", pcDownloaded, len(exeData))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadAndVerify_SHAMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
exeData := []byte("real-binary-bytes")
|
||||
exeName := "drover-v0.1.0-windows-amd64.exe"
|
||||
wrongHash := strings.Repeat("0", 64)
|
||||
sumsBody := wrongHash + " " + exeName + "\n"
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/exe":
|
||||
_, _ = w.Write(exeData)
|
||||
case "/sums":
|
||||
_, _ = io.WriteString(w, sumsBody)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
rel := &Release{
|
||||
TagName: "v0.1.0",
|
||||
Name: "v0.1.0",
|
||||
AssetURL: srv.URL + "/exe",
|
||||
SumsURL: srv.URL + "/sums",
|
||||
}
|
||||
|
||||
_, err := downloadAndVerify(context.Background(), rel, exeName, srv.Client(), "drover-go/test", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected SHA mismatch error; got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "sha256") && !strings.Contains(err.Error(), "checksum") {
|
||||
t.Errorf("expected error to mention sha256/checksum; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadAndVerify_AssetHTTP404(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
rel := &Release{
|
||||
TagName: "v0.1.0",
|
||||
AssetURL: srv.URL + "/exe",
|
||||
SumsURL: srv.URL + "/sums",
|
||||
}
|
||||
_, err := downloadAndVerify(context.Background(), rel, "drover-v0.1.0-windows-amd64.exe", srv.Client(), "drover-go/test", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on 404; got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadAndVerify_ContextCanceled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Block forever — handler unblocks when client closes connection
|
||||
// (which happens when the test's ctx is canceled).
|
||||
select {
|
||||
case <-time.After(5 * time.Second):
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
rel := &Release{AssetURL: srv.URL + "/exe", SumsURL: srv.URL + "/sums"}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
|
||||
_, err := downloadAndVerify(ctx, rel, "x", srv.Client(), "drover-go/test", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from canceled context; got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// equalBytes is here to avoid a bytes import for one comparison.
|
||||
func equalBytes(a, b []byte) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Sanity check that the Forgejo schema fields we care about decode correctly.
|
||||
func TestReleaseJSONShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
var rel forgejoRelease
|
||||
body := fmt.Sprintf(sampleReleaseJSON, "https://example.invalid", "https://example.invalid")
|
||||
if err := json.Unmarshal([]byte(body), &rel); err != nil {
|
||||
t.Fatalf("decode err: %v", err)
|
||||
}
|
||||
if len(rel.Assets) != 2 {
|
||||
t.Fatalf("got %d assets; want 2", len(rel.Assets))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user