package version import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestPublicUpdateSelectionVerifiesCallerSuppliedManifest(t *testing.T) { encoded := signedManifestJSON(t, `{"version":"9.9.9"}`) var manifest ReleaseInfo require.NoError(t, json.Unmarshal([]byte(encoded), &manifest)) check := CheckForUpdateFromReleaseInfo(&manifest) require.NoError(t, check.Error) require.True(t, check.UpdateAvailable) require.NoError(t, validateAuthenticatedUpdateSelection(check)) component := manifest.Components[ComponentID] component.Assets[0].URL = "https://attacker.invalid/manager" manifest.Components[ComponentID] = component forged := CheckForUpdateFromReleaseInfo(&manifest) require.ErrorContains(t, forged.Error, "verify release manifest") require.Nil(t, forged.authenticated) require.False(t, forged.UpdateAvailable) } func TestReleaseRetryCancellationInterruptsBackoff(t *testing.T) { requested := make(chan struct{}, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requested <- struct{}{} w.WriteHeader(http.StatusServiceUnavailable) })) defer server.Close() ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { _, err := fetchReleaseInfoWithRetryFromContext(ctx, server.URL, 3, time.Hour); done <- err }() <-requested cancel() select { case err := <-done: require.True(t, errors.Is(err, context.Canceled), "%v", err) case <-time.After(2 * time.Second): t.Fatal("retry ignored cancellation") } } const testReleaseManifest = `{ "version": "1.2.0", "date": "2026-01-01", "MpvVersion": "0.40.0", "ffmpeg": {"app_version": "20260121"} }` func TestFetchReleaseInfoFrom(t *testing.T) { t.Run("success", func(t *testing.T) { manifest := signedManifestJSON(t, testReleaseManifest) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write([]byte(manifest)) })) defer server.Close() info, err := fetchReleaseInfoFrom(context.Background(), server.URL) require.NoError(t, err) require.NotNil(t, info) assert.Equal(t, "1.2.0", info.Version) assert.Equal(t, "0.40.0", info.MpvVersion) assert.Equal(t, "20260121", info.FFmpeg.AppVersion) }) t.Run("non-200 status", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) defer server.Close() _, err := fetchReleaseInfoFrom(context.Background(), server.URL) require.Error(t, err) assert.Contains(t, err.Error(), "HTTP 500") }) t.Run("empty manifest object is an error", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write([]byte(`{}`)) })) defer server.Close() info, err := fetchReleaseInfoFrom(context.Background(), server.URL) require.Error(t, err) assert.Nil(t, info) assert.Contains(t, err.Error(), "release manifest is empty or invalid") }) t.Run("malformed JSON", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write([]byte(`{not json`)) })) defer server.Close() _, err := fetchReleaseInfoFrom(context.Background(), server.URL) require.Error(t, err) }) } func TestFetchReleaseInfoWithRetryFrom(t *testing.T) { t.Run("succeeds on second attempt after HTTP 500", func(t *testing.T) { attempts := 0 manifest := signedManifestJSON(t, testReleaseManifest) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attempts++ if attempts == 1 { w.WriteHeader(http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write([]byte(manifest)) })) defer server.Close() info, err := fetchReleaseInfoWithRetryFromContext(context.Background(), server.URL, 3, time.Millisecond) require.NoError(t, err) require.NotNil(t, info) assert.Equal(t, "1.2.0", info.Version) assert.Equal(t, 2, attempts) }) t.Run("persistent failure returns error after exactly N attempts", func(t *testing.T) { attempts := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attempts++ w.WriteHeader(http.StatusInternalServerError) })) defer server.Close() _, err := fetchReleaseInfoWithRetryFromContext(context.Background(), server.URL, 3, time.Millisecond) require.Error(t, err) assert.Contains(t, err.Error(), "failed to fetch release info after 3 attempts") assert.Equal(t, 3, attempts) }) t.Run("empty manifest object is retried, never a success", func(t *testing.T) { attempts := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attempts++ w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write([]byte(`{}`)) })) defer server.Close() _, err := fetchReleaseInfoWithRetryFromContext(context.Background(), server.URL, 2, time.Millisecond) require.Error(t, err) assert.Contains(t, err.Error(), "release manifest is empty or invalid") assert.Equal(t, 2, attempts) }) t.Run("network error is retried", func(t *testing.T) { // Close the server immediately to get a connection error. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) url := server.URL server.Close() _, err := fetchReleaseInfoWithRetryFromContext(context.Background(), url, 2, time.Millisecond) require.Error(t, err) assert.Contains(t, err.Error(), "failed to fetch release info after 2 attempts") }) }