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)) } }