package main import ( "bytes" "context" "crypto/ed25519" "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync/atomic" "testing" "time" "gitgud.io/mike/mpv-manager/pkg/releasemanifest" "gitgud.io/mike/mpv-manager/pkg/releaseprovenance" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // disableRetryDelays replaces the retry backoff with a no-op for the test. func disableRetryDelays(t *testing.T) { t.Helper() orig := retrySleep retrySleep = func(int) {} t.Cleanup(func() { retrySleep = orig }) } func TestFetchAllVersionsAutoFailsOnAPIError(t *testing.T) { disableRetryDelays(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "boom", http.StatusInternalServerError) })) defer server.Close() origAPI, origAuto := githubAPIBaseURL, *autoMode githubAPIBaseURL = server.URL *autoMode = true t.Cleanup(func() { githubAPIBaseURL, *autoMode = origAPI, origAuto }) _, _, _, err := fetchAllVersions() require.Error(t, err, "auto mode must fail when the GitHub API returns 500") } func TestFetchAllVersionsInteractiveKeepsFallbacks(t *testing.T) { disableRetryDelays(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "boom", http.StatusInternalServerError) })) defer server.Close() origAPI, origAuto := githubAPIBaseURL, *autoMode githubAPIBaseURL = server.URL *autoMode = false t.Cleanup(func() { githubAPIBaseURL, *autoMode = origAPI, origAuto }) versions, winTimestamp, _, err := fetchAllVersions() require.NoError(t, err, "interactive mode keeps fallback-with-prompt behavior") assert.Equal(t, "0.41.0", versions["mpv"]) assert.Equal(t, "5.12.0", versions["uosc"]) assert.NotEmpty(t, winTimestamp) } func TestFetchLatestReleaseSendsGitHubToken(t *testing.T) { disableRetryDelays(t) var gotAuth, gotUA string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") gotUA = r.Header.Get("User-Agent") w.Write([]byte(`{"tag_name":"v1.2.3"}`)) })) defer server.Close() origAPI := githubAPIBaseURL githubAPIBaseURL = server.URL t.Cleanup(func() { githubAPIBaseURL = origAPI }) t.Setenv("GITHUB_TOKEN", "test-token") release, err := fetchLatestRelease("owner", "repo") require.NoError(t, err) assert.Equal(t, "v1.2.3", release.TagName) assert.Equal(t, "Bearer test-token", gotAuth) assert.Equal(t, userAgent, gotUA) } func TestFetchLatestGitLabReleaseUsesEnvToken(t *testing.T) { disableRetryDelays(t) var gotAuth string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") w.Write([]byte(`[{"tag_name":"v1.2.0"}]`)) })) defer server.Close() origBase := gitGudAPIBaseURL gitGudAPIBaseURL = server.URL t.Cleanup(func() { gitGudAPIBaseURL = origBase }) t.Setenv("GITLAB_TOKEN", "env-token") release, err := fetchLatestGitLabRelease("mike/mpv-manager") require.NoError(t, err) assert.Equal(t, "v1.2.0", release.TagName) assert.Equal(t, "Bearer env-token", gotAuth, "GitLab fetch must be authenticated with GITLAB_TOKEN") } func TestDownloadPartialFailureLeavesNoFile(t *testing.T) { disableRetryDelays(t) // Declare more bytes than are actually sent: the client hits a // truncated body mid-copy. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Length", "100") w.WriteHeader(http.StatusOK) w.Write([]byte("short")) })) defer server.Close() // downloadFileIfNotExists writes under the relative releaseFilesDir workDir := t.TempDir() origWd, err := os.Getwd() require.NoError(t, err) require.NoError(t, os.Chdir(workDir)) t.Cleanup(func() { os.Chdir(origWd) }) _, err = downloadFileIfNotExists(server.URL + "/file.bin") require.Error(t, err, "truncated body must fail the download") localPath := filepath.Join(releaseFilesDir, "file.bin") _, statErr := os.Stat(localPath) assert.True(t, os.IsNotExist(statErr), "partial file must not be left behind") _, statErr = os.Stat(localPath + ".tmp") assert.True(t, os.IsNotExist(statErr), "temp file must not be left behind") } func TestDownloadRetriesAndLeavesNoTempFile(t *testing.T) { disableRetryDelays(t) var attempts int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if atomic.AddInt32(&attempts, 1) < 3 { http.Error(w, "boom", http.StatusInternalServerError) return } w.Write([]byte("payload")) })) defer server.Close() workDir := t.TempDir() origWd, err := os.Getwd() require.NoError(t, err) require.NoError(t, os.Chdir(workDir)) t.Cleanup(func() { os.Chdir(origWd) }) localPath, err := downloadFileIfNotExists(server.URL + "/file.bin") require.NoError(t, err) assert.EqualValues(t, 3, attempts, "transient 5xx must be retried") data, err := os.ReadFile(localPath) require.NoError(t, err) assert.Equal(t, "payload", string(data)) _, statErr := os.Stat(localPath + ".tmp") assert.True(t, os.IsNotExist(statErr), "temp file must be renamed away") } func TestDownloadRejectsDeclaredOversizeAndCleansTempFile(t *testing.T) { disableRetryDelays(t) originalLimit := maxReleaseArtifactBytes maxReleaseArtifactBytes = 8 t.Cleanup(func() { maxReleaseArtifactBytes = originalLimit }) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Length", "9") _, _ = w.Write([]byte("123456789")) })) defer server.Close() localPath := filepath.Join(t.TempDir(), "artifact") err := downloadToFile(server.URL, localPath) require.ErrorContains(t, err, "limit") _, statErr := os.Stat(localPath + ".tmp") assert.True(t, os.IsNotExist(statErr), "oversized download must not leave a temp file") } func TestDownloadRejectsUndeclaredOversizeAndCleansTempFile(t *testing.T) { disableRetryDelays(t) originalLimit := maxReleaseArtifactBytes maxReleaseArtifactBytes = 8 t.Cleanup(func() { maxReleaseArtifactBytes = originalLimit }) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Transfer-Encoding", "chunked") _, _ = w.Write([]byte("123456789")) })) defer server.Close() localPath := filepath.Join(t.TempDir(), "artifact") err := downloadToFile(server.URL, localPath) require.ErrorContains(t, err, "exceeded") _, statErr := os.Stat(localPath) assert.True(t, os.IsNotExist(statErr), "oversized download must not be finalized") _, statErr = os.Stat(localPath + ".tmp") assert.True(t, os.IsNotExist(statErr), "oversized download must not leave a temp file") } func TestDownloadRejectsIdleBodyAndCleansTempFile(t *testing.T) { disableRetryDelays(t) originalIdle := downloadIdleTimeout originalRateWindow := downloadRateWindow downloadIdleTimeout = 30 * time.Millisecond downloadRateWindow = time.Second t.Cleanup(func() { downloadIdleTimeout = originalIdle downloadRateWindow = originalRateWindow }) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) if flusher, ok := w.(http.Flusher); ok { flusher.Flush() } <-r.Context().Done() })) defer server.Close() localPath := filepath.Join(t.TempDir(), "artifact") err := downloadToFile(server.URL, localPath) require.ErrorContains(t, err, "no progress") _, statErr := os.Stat(localPath + ".tmp") assert.True(t, os.IsNotExist(statErr), "idle download must not leave a temp file") } func TestDownloadRejectsSustainedLowSpeedAndCleansTempFile(t *testing.T) { disableRetryDelays(t) originalIdle := downloadIdleTimeout originalRateWindow := downloadRateWindow originalMinimum := downloadMinimumWindowBytes downloadIdleTimeout = time.Second downloadRateWindow = 30 * time.Millisecond downloadMinimumWindowBytes = 10 t.Cleanup(func() { downloadIdleTimeout = originalIdle downloadRateWindow = originalRateWindow downloadMinimumWindowBytes = originalMinimum }) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("x")) if flusher, ok := w.(http.Flusher); ok { flusher.Flush() } <-r.Context().Done() })) defer server.Close() localPath := filepath.Join(t.TempDir(), "artifact") err := downloadToFile(server.URL, localPath) require.ErrorContains(t, err, "fewer than") _, statErr := os.Stat(localPath + ".tmp") assert.True(t, os.IsNotExist(statErr), "low-speed download must not leave a temp file") } func TestDownloadRejectsTotalDeadlineAndCleansTempFile(t *testing.T) { disableRetryDelays(t) originalTotal := downloadTotalTimeout originalIdle := downloadIdleTimeout originalRateWindow := downloadRateWindow downloadTotalTimeout = 30 * time.Millisecond downloadIdleTimeout = time.Second downloadRateWindow = time.Second t.Cleanup(func() { downloadTotalTimeout = originalTotal downloadIdleTimeout = originalIdle downloadRateWindow = originalRateWindow }) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) if flusher, ok := w.(http.Flusher); ok { flusher.Flush() } <-r.Context().Done() })) defer server.Close() localPath := filepath.Join(t.TempDir(), "artifact") err := downloadToFile(server.URL, localPath) require.Error(t, err) assert.ErrorIs(t, err, context.DeadlineExceeded) _, statErr := os.Stat(localPath + ".tmp") assert.True(t, os.IsNotExist(statErr), "deadline failure must not leave a temp file") } func TestDownloadTransportHonorsEnvironmentProxy(t *testing.T) { transport, ok := downloadClient.Transport.(*http.Transport) require.True(t, ok) require.NotNil(t, transport.Proxy) } func TestComputeHashForURLRequiresReviewedPinnedDigest(t *testing.T) { payload := []byte("reviewed upstream bytes") server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(payload) })) defer server.Close() workDir := t.TempDir() origWD, err := os.Getwd() require.NoError(t, err) require.NoError(t, os.Chdir(workDir)) originalClient := downloadClient originalLock := activeProvenance originalUsed := usedProvenanceAssets downloadClient = server.Client() t.Cleanup(func() { _ = os.Chdir(origWD) downloadClient = originalClient activeProvenance = originalLock usedProvenanceAssets = originalUsed }) payloadPath := filepath.Join(t.TempDir(), "payload") require.NoError(t, os.WriteFile(payloadPath, payload, 0o600)) digest, err := computeBLAKE3(payloadPath) require.NoError(t, err) lock := &releaseprovenance.Lock{ SchemaVersion: 1, ReleaseVersion: "1.3.0", ReviewedAt: "2026-08-31T12:00:00Z", Reviewer: "approver", Sources: releaseprovenance.Sources{MPV: "0.41.0", UOSC: "5.12.0", ModernZ: "0.3.3", MPCQT: "26.07", IINA: "1.4.4", WindowsBuildTag: "2026-08-31-deadbee", WindowsTimestamp: "20260831", WindowsMPVCommit: "deadbee", WindowsFFmpegHash: "cafebabe"}, Artifacts: []releaseprovenance.Artifact{{ID: "fixture", URL: server.URL + "/asset", BLAKE3: digest, Verification: "reviewed-digest", Evidence: "approval-42"}}, } require.NoError(t, lock.Validate("1.3.0")) activeProvenance = lock usedProvenanceAssets = make(map[string]bool) got, err := computeHashForURL("fixture", server.URL+"/asset", "") require.NoError(t, err) assert.Equal(t, digest, got) assert.True(t, usedProvenanceAssets[server.URL+"/asset"]) lock.Artifacts[0].BLAKE3 = "blake3:" + strings.Repeat("0", 64) require.NoError(t, lock.Validate("1.3.0")) _, err = computeHashForURL("fixture", server.URL+"/asset", "") require.Error(t, err) assert.Contains(t, err.Error(), "does not match reviewed digest") } func TestManagerArtifactHashUsesPipelineLocalBytes(t *testing.T) { directory := t.TempDir() artifact := filepath.Join(directory, "mpv-manager-linux-amd64") require.NoError(t, os.WriteFile(artifact, []byte("pipeline artifact"), 0o755)) original := *managerFiles *managerFiles = directory t.Cleanup(func() { *managerFiles = original }) want, err := computeBLAKE3(artifact) require.NoError(t, err) got, err := computeManagerArtifactHash("linux amd64", "https://registry.example/mpv-manager-linux-amd64") require.NoError(t, err) assert.Equal(t, want, got) } func validTestRelease() *Release { r := &Release{ SchemaVersion: releasemanifest.SchemaVersion, Channel: releasemanifest.StableChannel, PublishedAt: "2026-07-28T12:00:00Z", MinimumUpdaterVersion: "1.2.0", Version: "1.2.0", MpvVersion: "0.41.0", Date: "2026-07-28", } hash := "blake3:" + strings.Repeat("a", 64) set := func(entry *struct{ URL, BLAKE3 string }, url string) { entry.URL = url entry.BLAKE3 = hash } set(&r.Windows.X8664, "https://example.com/win.7z") set(&r.Windows.X8664v3, "https://example.com/winv3.7z") set(&r.Windows.Aarch64, "https://example.com/winarm.7z") set(&r.MacOS.ARMLatest, "https://example.com/mac-arm.zip") set(&r.MacOS.ARM15, "https://example.com/mac-arm15.zip") set(&r.MacOS.Intel15, "https://example.com/mac-intel.zip") set(&r.FFmpeg.X8664, "https://example.com/ffmpeg.7z") set(&r.FFmpeg.X8664v3, "https://example.com/ffmpegv3.7z") set(&r.FFmpeg.Aarch64, "https://example.com/ffmpegarm.7z") set(&r.MPCQT.X8664, "https://example.com/mpcqt.exe") set(&r.IINA.ARM, "https://example.com/iina-arm.dmg") set(&r.IINA.Intel, "https://example.com/iina-intel.dmg") set(&r.Manager.LinuxAMD64, "https://example.com/m-linux-amd64") set(&r.Manager.LinuxARM64, "https://example.com/m-linux-arm64") set(&r.Manager.WinX86_64, "https://example.com/m-win.exe") set(&r.Manager.WinARM64, "https://example.com/m-win-arm.exe") set(&r.Manager.MacosIntel, "https://example.com/m-macos-intel") set(&r.Manager.MacosARM, "https://example.com/m-macos-arm") r.UOSC.URL = "https://example.com/uosc.zip" r.UOSC.BLAKE3 = hash r.UOSC.ConfURL = "https://example.com/uosc.conf" r.UOSC.ConfBLAKE3 = hash r.ModernZ.ScriptURL = "https://example.com/modernz.lua" r.ModernZ.ScriptBLAKE3 = hash r.ModernZ.FontURL = "https://example.com/modernz-icons.ttf" r.ModernZ.FontBLAKE3 = hash r.ModernZ.ConfURL = "https://example.com/modernz.conf" r.ModernZ.ConfBLAKE3 = hash r.FFmpeg.AppVersion = "20260728-abc1234" r.UOSC.AppVersion = "5.12.0" r.ModernZ.AppVersion = "0.3.3" r.MPCQT.AppVersion = "26.07" r.IINA.AppVersion = "1.4.4" r.Components = map[string]releasemanifest.Component{ releasemanifest.ManagerPortableComponentID: { Version: r.Version, Assets: []releasemanifest.Asset{ testManagerAsset("linux", "amd64", "https://example.com/m-linux-amd64", hash), testManagerAsset("linux", "arm64", "https://example.com/m-linux-arm64", hash), testManagerAsset("windows", "amd64", "https://example.com/m-win.exe", hash), testManagerAsset("windows", "arm64", "https://example.com/m-win-arm.exe", hash), testManagerAsset("darwin", "amd64", "https://example.com/m-macos-intel", hash), testManagerAsset("darwin", "arm64", "https://example.com/m-macos-arm", hash), }, }, } return r } func testManagerAsset(goos, goarch, url, hash string) releasemanifest.Asset { cpuBaseline := "arm64" if goarch == "amd64" { cpuBaseline = "x86-64-v2" } return releasemanifest.Asset{ GOOS: goos, GOARCH: goarch, CPUBaseline: cpuBaseline, Format: releasemanifest.ManagerAssetFormat, URL: url, BLAKE3: hash, Size: 1024, InstallScope: releasemanifest.ManagerInstallScope, UpdateStrategy: releasemanifest.ManagerUpdateStrategy, ExpectedProduct: "mpv-manager", ExpectedComponent: releasemanifest.ManagerPortableComponentID, } } func TestValidateReleaseAcceptsValid(t *testing.T) { assert.NoError(t, validateRelease(validTestRelease())) } func TestValidateReleaseRejectsEmptyVersion(t *testing.T) { r := validTestRelease() r.Version = "" err := validateRelease(r) require.Error(t, err) assert.Contains(t, err.Error(), "version") } func TestValidateReleaseRejectsBadHash(t *testing.T) { r := validTestRelease() r.UOSC.BLAKE3 = "blake3:HASH" err := validateRelease(r) require.Error(t, err) assert.Contains(t, err.Error(), "blake3:<64 hex>") } func TestValidateReleaseRejectsNonHTTPSURL(t *testing.T) { r := validTestRelease() r.Manager.LinuxAMD64.URL = "http://example.com/m-linux-amd64" err := validateRelease(r) require.Error(t, err) assert.Contains(t, err.Error(), "not https") } func TestSignedGeneratorOutputDecodesWithProductionSchema(t *testing.T) { privateKey := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x24}, ed25519.SeedSize)) publicKey := privateKey.Public().(ed25519.PublicKey) t.Setenv(manifestSigningKeyIDEnv, "release-test") t.Setenv(manifestSigningKeyEnv, base64.StdEncoding.EncodeToString(privateKey.Seed())) release := validTestRelease() require.NoError(t, signRelease(release)) data, err := json.MarshalIndent(release, "", " ") require.NoError(t, err) var decoded releasemanifest.Manifest require.NoError(t, json.Unmarshal(data, &decoded)) require.NoError(t, decoded.Validate()) require.NoError(t, decoded.Verify(map[string]ed25519.PublicKey{"release-test": publicKey})) component, asset, err := decoded.SelectAsset(releasemanifest.ManagerPortableComponentID, "linux", "amd64") require.NoError(t, err) assert.Equal(t, release.Version, component.Version) assert.Equal(t, release.Manager.LinuxAMD64.URL, asset.URL) // Immutable v1.1/v1.2 clients decode these exact legacy tags. Keep this // assertion beside the signed producer test so schema evolution cannot // silently break the v1.3 bootstrap document. var legacy struct { Version string `json:"version"` MpvVersion string `json:"MpvVersion"` Manager struct { LinuxAMD64 struct { URL string `json:"URL"` BLAKE3 string `json:"BLAKE3"` } `json:"linux-amd64"` } `json:"manager"` } require.NoError(t, json.Unmarshal(data, &legacy)) assert.Equal(t, release.Version, legacy.Version) assert.Equal(t, release.MpvVersion, legacy.MpvVersion) assert.Equal(t, release.Manager.LinuxAMD64.URL, legacy.Manager.LinuxAMD64.URL) assert.Equal(t, release.Manager.LinuxAMD64.BLAKE3, legacy.Manager.LinuxAMD64.BLAKE3) } func TestManagerDownloadDirectory(t *testing.T) { got, err := managerDownloadDirectory("", "1.3.0-rc.1") require.NoError(t, err) require.Contains(t, got, "/v1.3.0-rc.1") got, err = managerDownloadDirectory("https://mpv.rocks/api/releases/artifacts/v1.3.0-rc.1/", "1.3.0-rc.1") require.NoError(t, err) require.Equal(t, "https://mpv.rocks/api/releases/artifacts/v1.3.0-rc.1", got) for _, input := range []string{"http://example.test/path", "https://user:pass@example.test/path", "https://example.test/path?q=1", "https://example.test/path#fragment", "/relative"} { _, err := managerDownloadDirectory(input, "1.3.0-rc.1") require.Error(t, err, input) } } func TestValidateReleaseAcceptsReleaseCandidate(t *testing.T) { r := validTestRelease() r.Version = "1.3.0-rc.1" r.Channel = releasemanifest.ReleaseCandidateChannel component := r.Components[releasemanifest.ManagerPortableComponentID] component.Version = r.Version r.Components[releasemanifest.ManagerPortableComponentID] = component require.NoError(t, validateRelease(r)) r.Channel = releasemanifest.StableChannel require.ErrorContains(t, validateRelease(r), "stable channel cannot contain a prerelease") }