banger/internal/updater/manifest_test.go
Thales Maciel fb6d2b1dae
updater: manifest + SHA256SUMS parsing scaffolding
First slice of the `banger update` package. No CLI yet — this just
defines the wire shape and parsers the rest of the flow will plug
into.

  * internal/updater/manifest.go — Manifest / Release types,
    ManifestSchemaVersion = 1, the hardcoded URL
    https://releases.thaloco.com/banger/manifest.json (var instead
    of const so tests can point at httptest), and FetchManifest /
    ParseManifest / Manifest.LookupRelease / Manifest.Latest.
    The manifest only references URLs (tarball, SHA256SUMS, optional
    signature); actual binary hashes come from SHA256SUMS itself,
    so manifest tampering can't substitute a hash for a known-good
    tarball.
    SchemaVersion gates forward-compat: a CLI that doesn't know its
    server's schema_version refuses to update rather than guessing.
  * internal/updater/sha256sums.go — ParseSHA256Sums tolerates both
    GNU `<digest>  <file>` (with optional `*` binary prefix) and
    BSD `SHA256 (file) = <digest>` formats. Comments and blank
    lines are skipped; malformed lines that LOOK like entries are
    rejected (silent skipping is the wrong failure mode for a
    security-relevant input). Digests are lowercased so the caller
    can `==`-compare without worrying about case.

Caps: 1 MiB on the manifest body, 16 KiB on SHA256SUMS, 256 MiB on
release tarballs. Generous-but-bounded; bumping requires a code
change so a server-side mistake can't fill the disk.

Tests: ParseManifest happy path, schema-version-too-new rejection,
five malformed-input cases. ParseSHA256Sums covers GNU + BSD +
star-prefix + comments-and-blanks, six malformed-input rejections,
case-insensitive digest normalisation. FetchManifest end-to-end via
httptest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 12:24:36 -03:00

113 lines
3.5 KiB
Go

package updater
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
const sampleManifest = `{
"schema_version": 1,
"latest_stable": "v0.1.1",
"releases": [
{
"version": "v0.1.0",
"tarball_url": "https://releases.thaloco.com/banger/v0.1.0/banger-v0.1.0-linux-amd64.tar.gz",
"sha256sums_url": "https://releases.thaloco.com/banger/v0.1.0/SHA256SUMS",
"sha256sums_sig_url": "https://releases.thaloco.com/banger/v0.1.0/SHA256SUMS.sig",
"released_at": "2026-04-29T10:00:00Z"
},
{
"version": "v0.1.1",
"tarball_url": "https://releases.thaloco.com/banger/v0.1.1/banger-v0.1.1-linux-amd64.tar.gz",
"sha256sums_url": "https://releases.thaloco.com/banger/v0.1.1/SHA256SUMS",
"sha256sums_sig_url": "https://releases.thaloco.com/banger/v0.1.1/SHA256SUMS.sig",
"released_at": "2026-05-01T10:00:00Z"
}
]
}`
func TestParseManifestHappyPath(t *testing.T) {
m, err := ParseManifest([]byte(sampleManifest))
if err != nil {
t.Fatalf("ParseManifest: %v", err)
}
if m.LatestStable != "v0.1.1" || len(m.Releases) != 2 {
t.Fatalf("manifest = %+v, want 2 releases with latest_stable=v0.1.1", m)
}
}
func TestParseManifestRejectsNewerSchema(t *testing.T) {
body := strings.Replace(sampleManifest, `"schema_version": 1`, `"schema_version": 99`, 1)
_, err := ParseManifest([]byte(body))
if err == nil || !strings.Contains(err.Error(), "newer than this CLI") {
t.Fatalf("err = %v, want newer-schema rejection", err)
}
}
func TestParseManifestRejectsMissingFields(t *testing.T) {
for _, tc := range []struct {
name string
body string
}{
{name: "missing_schema_version", body: `{"latest_stable":"v0.1.0","releases":[]}`},
{name: "missing_tarball_url", body: `{"schema_version":1,"latest_stable":"v0.1.0","releases":[{"version":"v0.1.0","sha256sums_url":"x"}]}`},
{name: "missing_sha256sums_url", body: `{"schema_version":1,"latest_stable":"v0.1.0","releases":[{"version":"v0.1.0","tarball_url":"x"}]}`},
{name: "empty_version", body: `{"schema_version":1,"latest_stable":"v0.1.0","releases":[{"tarball_url":"x","sha256sums_url":"y"}]}`},
{name: "garbage", body: "not json"},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
if _, err := ParseManifest([]byte(tc.body)); err == nil {
t.Fatalf("expected error parsing %s; got success", tc.name)
}
})
}
}
func TestManifestLookupRelease(t *testing.T) {
m, _ := ParseManifest([]byte(sampleManifest))
r, err := m.LookupRelease("v0.1.0")
if err != nil {
t.Fatalf("LookupRelease(v0.1.0): %v", err)
}
if !strings.HasSuffix(r.TarballURL, "banger-v0.1.0-linux-amd64.tar.gz") {
t.Fatalf("wrong tarball url: %s", r.TarballURL)
}
if _, err := m.LookupRelease("v9.9.9"); err == nil {
t.Fatal("expected error looking up missing release")
}
}
func TestManifestLatest(t *testing.T) {
m, _ := ParseManifest([]byte(sampleManifest))
r, err := m.Latest()
if err != nil {
t.Fatalf("Latest: %v", err)
}
if r.Version != "v0.1.1" {
t.Fatalf("Latest.Version = %s, want v0.1.1", r.Version)
}
}
func TestFetchManifestRoundTrip(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(sampleManifest))
}))
defer srv.Close()
// Drive FetchManifest by overriding the global URL temporarily.
prev := manifestURL
manifestURL = srv.URL
defer func() { manifestURL = prev }()
m, err := FetchManifest(context.Background(), srv.Client())
if err != nil {
t.Fatalf("FetchManifest: %v", err)
}
if m.LatestStable != "v0.1.1" {
t.Fatalf("LatestStable = %s", m.LatestStable)
}
}