// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. // SPDX-License-Identifier: Apache-2.0 package model import ( "net/http" "os" "testing" "github.com/gorilla/mux" "miniflux.app/v2/internal/config" ) func TestEnclosure_Html5MimeTypeGivesOriginalMimeType(t *testing.T) { enclosure := Enclosure{MimeType: "thing/thisMimeTypeIsNotExpectedToBeReplaced"} if enclosure.Html5MimeType() != enclosure.MimeType { t.Fatalf( "HTML5 MimeType must provide original MimeType if not explicitly Replaced. Got %s ,expected '%s' ", enclosure.Html5MimeType(), enclosure.MimeType, ) } } func TestEnclosure_Html5MimeTypeReplaceStandardM4vByAppleSpecificMimeType(t *testing.T) { enclosure := Enclosure{MimeType: "video/m4v"} if enclosure.Html5MimeType() != "video/x-m4v" { // Solution from this stackoverflow discussion: // https://stackoverflow.com/questions/15277147/m4v-mimetype-video-mp4-or-video-m4v/66945470#66945470 // tested at the time of this commit (06/2023) on latest Firefox & Vivaldi on this feed // https://www.florenceporcel.com/podcast/lfhdu.xml t.Fatalf( "HTML5 MimeType must be replaced by 'video/x-m4v' when originally video/m4v to ensure playbacks in browsers. Got '%s'", enclosure.Html5MimeType(), ) } } func TestEnclosure_IsAudio(t *testing.T) { testCases := []struct { name string mimeType string expected bool }{ {"MP3 audio", "audio/mpeg", true}, {"WAV audio", "audio/wav", true}, {"OGG audio", "audio/ogg", true}, {"Mixed case audio", "Audio/MP3", true}, {"Video file", "video/mp4", false}, {"Image file", "image/jpeg", false}, {"Text file", "text/plain", false}, {"Empty mime type", "", false}, {"Audio with extra info", "audio/mpeg; charset=utf-8", true}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { enclosure := &Enclosure{MimeType: tc.mimeType} if got := enclosure.IsAudio(); got != tc.expected { t.Errorf("IsAudio() = %v, want %v for mime type %s", got, tc.expected, tc.mimeType) } }) } } func TestEnclosure_IsVideo(t *testing.T) { testCases := []struct { name string mimeType string expected bool }{ {"MP4 video", "video/mp4", true}, {"AVI video", "video/avi", true}, {"WebM video", "video/webm", true}, {"M4V video", "video/m4v", true}, {"Mixed case video", "Video/MP4", true}, {"Audio file", "audio/mpeg", false}, {"Image file", "image/jpeg", false}, {"Text file", "text/plain", false}, {"Empty mime type", "", false}, {"Video with extra info", "video/mp4; codecs=\"avc1.42E01E\"", true}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { enclosure := &Enclosure{MimeType: tc.mimeType} if got := enclosure.IsVideo(); got != tc.expected { t.Errorf("IsVideo() = %v, want %v for mime type %s", got, tc.expected, tc.mimeType) } }) } } func TestEnclosure_IsImage(t *testing.T) { testCases := []struct { name string mimeType string url string expected bool }{ {"JPEG image by mime", "image/jpeg", "http://example.com/file", true}, {"PNG image by mime", "image/png", "http://example.com/file", true}, {"GIF image by mime", "image/gif", "http://example.com/file", true}, {"Mixed case image mime", "Image/JPEG", "http://example.com/file", true}, {"JPG file extension", "application/octet-stream", "http://example.com/photo.jpg", true}, {"JPEG file extension", "text/plain", "http://example.com/photo.jpeg", true}, {"PNG file extension", "unknown/type", "http://example.com/photo.png", true}, {"GIF file extension", "binary/data", "http://example.com/photo.gif", true}, {"Mixed case extension", "text/plain", "http://example.com/photo.JPG", true}, {"Image mime and extension", "image/jpeg", "http://example.com/photo.jpg", true}, {"Video file", "video/mp4", "http://example.com/video.mp4", false}, {"Audio file", "audio/mpeg", "http://example.com/audio.mp3", false}, {"Text file", "text/plain", "http://example.com/file.txt", false}, {"No extension", "text/plain", "http://example.com/file", false}, {"Other extension", "text/plain", "http://example.com/file.pdf", false}, {"Empty values", "", "", false}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { enclosure := &Enclosure{MimeType: tc.mimeType, URL: tc.url} if got := enclosure.IsImage(); got != tc.expected { t.Errorf("IsImage() = %v, want %v for mime type %s and URL %s", got, tc.expected, tc.mimeType, tc.url) } }) } } func TestEnclosureList_FindMediaPlayerEnclosure(t *testing.T) { testCases := []struct { name string enclosures EnclosureList expectedNil bool }{ { name: "Returns first audio enclosure", enclosures: EnclosureList{ &Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"}, &Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"}, }, expectedNil: false, }, { name: "Returns first video enclosure", enclosures: EnclosureList{ &Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"}, &Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"}, }, expectedNil: false, }, { name: "Skips image enclosure and returns audio", enclosures: EnclosureList{ &Enclosure{URL: "http://example.com/image.jpg", MimeType: "image/jpeg"}, &Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"}, }, expectedNil: false, }, { name: "Skips enclosure with empty URL", enclosures: EnclosureList{ &Enclosure{URL: "", MimeType: "audio/mpeg"}, &Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"}, }, expectedNil: false, }, { name: "Returns nil for no media enclosures", enclosures: EnclosureList{ &Enclosure{URL: "http://example.com/image.jpg", MimeType: "image/jpeg"}, &Enclosure{URL: "http://example.com/doc.pdf", MimeType: "application/pdf"}, }, expectedNil: true, }, { name: "Returns nil for empty list", enclosures: EnclosureList{}, expectedNil: true, }, { name: "Returns nil for all empty URLs", enclosures: EnclosureList{ &Enclosure{URL: "", MimeType: "audio/mpeg"}, &Enclosure{URL: "", MimeType: "video/mp4"}, }, expectedNil: true, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { result := tc.enclosures.FindMediaPlayerEnclosure() if tc.expectedNil { if result != nil { t.Errorf("FindMediaPlayerEnclosure() = %v, want nil", result) } } else { if result == nil { t.Errorf("FindMediaPlayerEnclosure() = nil, want non-nil") } else if !result.IsAudio() && !result.IsVideo() { t.Errorf("FindMediaPlayerEnclosure() returned non-media enclosure: %s", result.MimeType) } } }) } } func TestEnclosureList_ContainsAudioOrVideo(t *testing.T) { testCases := []struct { name string enclosures EnclosureList expected bool }{ { name: "Contains audio", enclosures: EnclosureList{ &Enclosure{MimeType: "audio/mpeg"}, &Enclosure{MimeType: "image/jpeg"}, }, expected: true, }, { name: "Contains video", enclosures: EnclosureList{ &Enclosure{MimeType: "image/jpeg"}, &Enclosure{MimeType: "video/mp4"}, }, expected: true, }, { name: "Contains both audio and video", enclosures: EnclosureList{ &Enclosure{MimeType: "audio/mpeg"}, &Enclosure{MimeType: "video/mp4"}, }, expected: true, }, { name: "Contains only images", enclosures: EnclosureList{ &Enclosure{MimeType: "image/jpeg"}, &Enclosure{MimeType: "image/png"}, }, expected: false, }, { name: "Contains only documents", enclosures: EnclosureList{ &Enclosure{MimeType: "application/pdf"}, &Enclosure{MimeType: "text/plain"}, }, expected: false, }, { name: "Empty list", enclosures: EnclosureList{}, expected: false, }, { name: "Single audio enclosure", enclosures: EnclosureList{ &Enclosure{MimeType: "audio/wav"}, }, expected: true, }, { name: "Single video enclosure", enclosures: EnclosureList{ &Enclosure{MimeType: "video/webm"}, }, expected: true, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { result := tc.enclosures.ContainsAudioOrVideo() if result != tc.expected { t.Errorf("ContainsAudioOrVideo() = %v, want %v", result, tc.expected) } }) } } func TestEnclosure_ProxifyEnclosureURL(t *testing.T) { // Initialize config for testing os.Clearenv() os.Setenv("BASE_URL", "http://localhost") os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key") var err error parser := config.NewParser() config.Opts, err = parser.ParseEnvironmentVariables() if err != nil { t.Fatalf(`Config parsing failure: %v`, err) } router := mux.NewRouter() router.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy") testCases := []struct { name string url string mimeType string mediaProxyOption string mediaProxyResourceTypes []string expectedURLChanged bool }{ { name: "HTTP URL with audio type - proxy mode all", url: "http://example.com/audio.mp3", mimeType: "audio/mpeg", mediaProxyOption: "all", mediaProxyResourceTypes: []string{"audio", "video"}, expectedURLChanged: true, }, { name: "HTTPS URL with video type - proxy mode all", url: "https://example.com/video.mp4", mimeType: "video/mp4", mediaProxyOption: "all", mediaProxyResourceTypes: []string{"audio", "video"}, expectedURLChanged: true, }, { name: "HTTP URL with video type - proxy mode http-only", url: "http://example.com/video.mp4", mimeType: "video/mp4", mediaProxyOption: "http-only", mediaProxyResourceTypes: []string{"audio", "video"}, expectedURLChanged: true, }, { name: "HTTPS URL with video type - proxy mode http-only", url: "https://example.com/video.mp4", mimeType: "video/mp4", mediaProxyOption: "http-only", mediaProxyResourceTypes: []string{"audio", "video"}, expectedURLChanged: false, }, { name: "HTTP URL with image type - not in resource types", url: "http://example.com/image.jpg", mimeType: "image/jpeg", mediaProxyOption: "all", mediaProxyResourceTypes: []string{"audio", "video"}, expectedURLChanged: false, }, { name: "HTTP URL with image type - in resource types", url: "http://example.com/image.jpg", mimeType: "image/jpeg", mediaProxyOption: "all", mediaProxyResourceTypes: []string{"audio", "video", "image"}, expectedURLChanged: true, }, { name: "HTTP URL - proxy mode none", url: "http://example.com/audio.mp3", mimeType: "audio/mpeg", mediaProxyOption: "none", mediaProxyResourceTypes: []string{"audio", "video"}, expectedURLChanged: false, }, { name: "Empty URL", url: "", mimeType: "audio/mpeg", mediaProxyOption: "all", mediaProxyResourceTypes: []string{"audio", "video"}, expectedURLChanged: false, }, { name: "Non-media MIME type", url: "http://example.com/doc.pdf", mimeType: "application/pdf", mediaProxyOption: "all", mediaProxyResourceTypes: []string{"audio", "video"}, expectedURLChanged: false, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { enclosure := &Enclosure{ URL: tc.url, MimeType: tc.mimeType, } originalURL := enclosure.URL // Call the method enclosure.ProxifyEnclosureURL(router, tc.mediaProxyOption, tc.mediaProxyResourceTypes) // Check if URL changed as expected urlChanged := enclosure.URL != originalURL if urlChanged != tc.expectedURLChanged { t.Errorf("ProxifyEnclosureURL() URL changed = %v, want %v. Original: %s, New: %s", urlChanged, tc.expectedURLChanged, originalURL, enclosure.URL) } // If URL should have changed, verify it's not empty if tc.expectedURLChanged && enclosure.URL == "" { t.Error("ProxifyEnclosureURL() resulted in empty URL when proxification was expected") } // If URL shouldn't have changed, verify it's identical if !tc.expectedURLChanged && enclosure.URL != originalURL { t.Errorf("ProxifyEnclosureURL() URL changed unexpectedly from %s to %s", originalURL, enclosure.URL) } }) } } func TestEnclosureList_ProxifyEnclosureURL(t *testing.T) { // Initialize config for testing os.Clearenv() os.Setenv("BASE_URL", "http://localhost") os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key") var err error parser := config.NewParser() config.Opts, err = parser.ParseEnvironmentVariables() if err != nil { t.Fatalf(`Config parsing failure: %v`, err) } router := mux.NewRouter() router.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy") testCases := []struct { name string enclosures EnclosureList mediaProxyOption string mediaProxyResourceTypes []string expectedChangedCount int }{ { name: "Mixed enclosures with all proxy mode", enclosures: EnclosureList{ &Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"}, &Enclosure{URL: "https://example.com/video.mp4", MimeType: "video/mp4"}, &Enclosure{URL: "http://example.com/image.jpg", MimeType: "image/jpeg"}, &Enclosure{URL: "http://example.com/doc.pdf", MimeType: "application/pdf"}, }, mediaProxyOption: "all", mediaProxyResourceTypes: []string{"audio", "video"}, expectedChangedCount: 2, // audio and video should be proxified }, { name: "Mixed enclosures with http-only proxy mode", enclosures: EnclosureList{ &Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"}, &Enclosure{URL: "https://example.com/video.mp4", MimeType: "video/mp4"}, &Enclosure{URL: "http://example.com/video2.mp4", MimeType: "video/mp4"}, }, mediaProxyOption: "http-only", mediaProxyResourceTypes: []string{"audio", "video"}, expectedChangedCount: 2, // only HTTP URLs should be proxified }, { name: "No media types in resource list", enclosures: EnclosureList{ &Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"}, &Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"}, }, mediaProxyOption: "all", mediaProxyResourceTypes: []string{"image"}, expectedChangedCount: 0, // no matching resource types }, { name: "Proxy mode none", enclosures: EnclosureList{ &Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"}, &Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"}, }, mediaProxyOption: "none", mediaProxyResourceTypes: []string{"audio", "video"}, expectedChangedCount: 0, }, { name: "Empty enclosure list", enclosures: EnclosureList{}, mediaProxyOption: "all", mediaProxyResourceTypes: []string{"audio", "video"}, expectedChangedCount: 0, }, { name: "Enclosures with empty URLs", enclosures: EnclosureList{ &Enclosure{URL: "", MimeType: "audio/mpeg"}, &Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"}, }, mediaProxyOption: "all", mediaProxyResourceTypes: []string{"audio", "video"}, expectedChangedCount: 1, // only the non-empty URL should be processed }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Store original URLs originalURLs := make([]string, len(tc.enclosures)) for i, enclosure := range tc.enclosures { originalURLs[i] = enclosure.URL } // Call the method tc.enclosures.ProxifyEnclosureURL(router, tc.mediaProxyOption, tc.mediaProxyResourceTypes) // Count how many URLs actually changed changedCount := 0 for i, enclosure := range tc.enclosures { if enclosure.URL != originalURLs[i] { changedCount++ // Verify that changed URLs are not empty (unless they were empty originally) if originalURLs[i] != "" && enclosure.URL == "" { t.Errorf("Enclosure %d: ProxifyEnclosureURL resulted in empty URL", i) } } } if changedCount != tc.expectedChangedCount { t.Errorf("ProxifyEnclosureURL() changed %d URLs, want %d", changedCount, tc.expectedChangedCount) } }) } } func TestEnclosure_ProxifyEnclosureURL_EdgeCases(t *testing.T) { // Initialize config for testing os.Clearenv() os.Setenv("BASE_URL", "http://localhost") os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key") var err error parser := config.NewParser() config.Opts, err = parser.ParseEnvironmentVariables() if err != nil { t.Fatalf(`Config parsing failure: %v`, err) } router := mux.NewRouter() router.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy") t.Run("Empty resource types slice", func(t *testing.T) { enclosure := &Enclosure{ URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg", } originalURL := enclosure.URL enclosure.ProxifyEnclosureURL(router, "all", []string{}) // With empty resource types, URL should not change if enclosure.URL != originalURL { t.Errorf("URL should not change with empty resource types. Original: %s, New: %s", originalURL, enclosure.URL) } }) t.Run("Nil resource types slice", func(t *testing.T) { enclosure := &Enclosure{ URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg", } originalURL := enclosure.URL enclosure.ProxifyEnclosureURL(router, "all", nil) // With nil resource types, URL should not change if enclosure.URL != originalURL { t.Errorf("URL should not change with nil resource types. Original: %s, New: %s", originalURL, enclosure.URL) } }) t.Run("Invalid proxy mode", func(t *testing.T) { enclosure := &Enclosure{ URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg", } originalURL := enclosure.URL enclosure.ProxifyEnclosureURL(router, "invalid-mode", []string{"audio"}) // With invalid proxy mode, the function still proxifies non-HTTPS URLs // because shouldProxifyURL defaults to checking URL scheme if enclosure.URL == originalURL { t.Errorf("URL should change for HTTP URL even with invalid proxy mode. Original: %s, New: %s", originalURL, enclosure.URL) } }) }