package main import ( "crypto/ed25519" "crypto/rand" "encoding/base64" "encoding/json" "io" "os" "path/filepath" "strings" "testing" "time" "gitgud.io/mike/mpv-manager/pkg/releasemanifest" "github.com/stretchr/testify/require" ) func TestPublisherChannelIsolationAndRollback(t *testing.T) { root := t.TempDir() legacy := []byte(`{"version":"1.2.0","manager":{"linux-amd64":{"URL":"https://example.test/old"}}}`) require.NoError(t, os.WriteFile(filepath.Join(root, "releases.json"), legacy, 0644)) pub, key, err := ed25519.GenerateKey(rand.Reader) require.NoError(t, err) t.Setenv("MANIFEST_PUBLIC_KEYS", "publisher="+base64.StdEncoding.EncodeToString(pub)) published := time.Now().UTC().Add(-time.Hour) write := func(channel, ver string, at time.Time) (string, []byte) { t.Helper() m := releasemanifest.Manifest{SchemaVersion: 2, Channel: channel, Version: ver, MinimumUpdaterVersion: "1.2.0", PublishedAt: at.Format(time.RFC3339), MpvVersion: "0.41.0"} assets := []releasemanifest.Asset{} entries := []struct { os, arch string entry *struct{ URL, BLAKE3 string } }{ {"linux", "amd64", &m.Manager.LinuxAMD64}, {"linux", "arm64", &m.Manager.LinuxARM64}, {"windows", "amd64", &m.Manager.WinX86_64}, {"windows", "arm64", &m.Manager.WinARM64}, {"darwin", "amd64", &m.Manager.MacosIntel}, {"darwin", "arm64", &m.Manager.MacosARM}, } for _, e := range entries { e.entry.URL = "https://example.test/" + ver + "/" + e.os + "-" + e.arch e.entry.BLAKE3 = "blake3:" + strings.Repeat("a", 64) baseline := "arm64" if e.arch == "amd64" { baseline = "x86-64-v2" } assets = append(assets, releasemanifest.Asset{GOOS: e.os, GOARCH: e.arch, CPUBaseline: baseline, URL: e.entry.URL, BLAKE3: e.entry.BLAKE3, Size: 10, Format: "raw", InstallScope: "portable", UpdateStrategy: "external-helper", ExpectedProduct: "mpv-manager", ExpectedComponent: "manager-portable"}) } m.Components = map[string]releasemanifest.Component{"manager-portable": {Version: ver, Assets: assets}} require.NoError(t, m.Sign("publisher", key)) data, err := json.Marshal(m) require.NoError(t, err) path := filepath.Join(t.TempDir(), "candidate.json") require.NoError(t, os.WriteFile(path, data, 0600)) return path, data } publish := func(path, channel string) error { return run([]string{"-manifest", path, "-api-dir", root, "-channel", channel}, io.Discard) } rc, rcBytes := write("rc", "1.3.0-rc.2", published) require.NoError(t, publish(rc, "rc")) got, err := os.ReadFile(filepath.Join(root, "releases.json")) require.NoError(t, err) require.Equal(t, legacy, got) require.NoFileExists(t, filepath.Join(root, "releases", "stable.json")) require.ErrorContains(t, publish(rc, "stable"), "authorized destination") require.NoError(t, publish(rc, "rc")) // retry is idempotent older, _ := write("rc", "1.3.0-rc.1", published.Add(time.Minute)) require.ErrorContains(t, publish(older, "rc"), "rollback") equivocation, _ := write("rc", "1.3.0-rc.3", published) require.ErrorContains(t, publish(equivocation, "rc"), "equivocation") stable, stableBytes := write("stable", "1.3.0", published.Add(time.Minute)) require.NoError(t, publish(stable, "stable")) for _, name := range []string{"releases.json", "releases/stable.json"} { got, err = os.ReadFile(filepath.Join(root, name)) require.NoError(t, err) require.Equal(t, stableBytes, got) } got, err = os.ReadFile(filepath.Join(root, "releases/rc.json")) require.NoError(t, err) require.Equal(t, rcBytes, got) // Legacy readers retain the exact spelling/shape they used before v1.3. var oldClient struct { Version string MpvVersion string Manager struct { Linux struct{ URL, BLAKE3 string } `json:"linux-amd64"` } } require.NoError(t, json.Unmarshal(stableBytes, &oldClient)) require.Equal(t, "1.3.0", oldClient.Version) require.Equal(t, "0.41.0", oldClient.MpvVersion) require.Contains(t, oldClient.Manager.Linux.URL, "1.3.0") tampered := strings.ReplaceAll(string(stableBytes), "0.41.0", "0.99.0") require.NoError(t, os.WriteFile(stable, []byte(tampered), 0600)) require.ErrorContains(t, publish(stable, "stable"), "signature") // Rejected publication cannot change the served feed. got, err = os.ReadFile(filepath.Join(root, "releases/stable.json")) require.NoError(t, err) require.Equal(t, stableBytes, got) }