aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorCody Robibero <cody@robibe.ro>2026-08-22 08:49:07 -0400
committerGitHub <noreply@github.com>2026-08-22 08:49:07 -0400
commit49112163ebb5cb6fbec37c3b7b3e9a4875f47940 (patch)
tree1fc16ae1bd610095efc019e546ddd7faee3c694a /tests
parent0d4cbb999bbac9920caeeb5cd7174b1dc9758bff (diff)
parent42c70fba63d72a4113a7b8f2e06293e512cc6914 (diff)
Merge pull request #17584 from Shadowghost/safeguard-invalid-provider-ids
Safeguard against invalid provider ids
Diffstat (limited to 'tests')
-rw-r--r--tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs103
-rw-r--r--tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs253
-rw-r--r--tests/Jellyfin.Providers.Tests/Music/AlbumInfoExtensionsTests.cs59
-rw-r--r--tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs37
4 files changed, 452 insertions, 0 deletions
diff --git a/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs b/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs
index a6f4164144..2347c08961 100644
--- a/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs
+++ b/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs
@@ -186,6 +186,109 @@ namespace Jellyfin.Model.Tests.Entities
Assert.Null(nullProvider.ProviderIds);
}
+ [Theory]
+ [InlineData(nameof(MetadataProvider.Imdb), "tt0113375", true)]
+ [InlineData(nameof(MetadataProvider.Imdb), "nm0000123", true)]
+ [InlineData(nameof(MetadataProvider.Imdb), "0113375", true)]
+ [InlineData(nameof(MetadataProvider.Imdb), "https://www.imdb.com/title/tt0113375", false)]
+ [InlineData(nameof(MetadataProvider.Tmdb), "11", true)]
+ [InlineData(nameof(MetadataProvider.Tmdb), "nm0000123", false)]
+ [InlineData(nameof(MetadataProvider.Tmdb), "0", false)]
+ [InlineData(nameof(MetadataProvider.Tmdb), "-11", false)]
+ [InlineData(nameof(MetadataProvider.TmdbCollection), "nm0000123", false)]
+ [InlineData(nameof(MetadataProvider.AudioDbArtist), "111239", true)]
+ [InlineData(nameof(MetadataProvider.AudioDbArtist), "a3cb23fc-acd3-4ce0-8f36-1e5aa6a18432", false)]
+ [InlineData(nameof(MetadataProvider.MusicBrainzArtist), "a3cb23fc-acd3-4ce0-8f36-1e5aa6a18432", true)]
+ [InlineData(nameof(MetadataProvider.MusicBrainzArtist), "111239", false)]
+ [InlineData(nameof(MetadataProvider.MusicBrainzAlbum), "not-an-mbid", false)]
+ [InlineData(nameof(MetadataProvider.Tvdb), "anything-goes", true)]
+ [InlineData("SomePlugin", "anything-goes", true)]
+ [InlineData(nameof(MetadataProvider.Tmdb), null, false)]
+ [InlineData(null, "11", false)]
+ public void IsValidProviderId_ChecksKnownFormats(string? name, string? value, bool expected)
+ {
+ Assert.Equal(expected, ProviderIdsExtensions.IsValidProviderId(name, value));
+ }
+
+ [Fact]
+ public void TrySetProviderId_ForeignId_False()
+ {
+ var provider = new ProviderIdsExtensionsTestsObject();
+
+ Assert.False(provider.TrySetProviderId(MetadataProvider.Tmdb, "nm0000123"));
+ Assert.Empty(provider.ProviderIds);
+ }
+
+ [Fact]
+ public void TrySetProviderId_ForeignId_KeepsExisting()
+ {
+ var provider = new ProviderIdsExtensionsTestsObject();
+ provider.ProviderIds[MetadataProvider.Tmdb.ToString()] = "11";
+
+ Assert.False(provider.TrySetProviderId(MetadataProvider.Tmdb, "nm0000123"));
+ Assert.Equal("11", provider.GetProviderId(MetadataProvider.Tmdb));
+ }
+
+ [Theory]
+ [InlineData(nameof(MetadataProvider.Imdb), " tt0113375 ")]
+ [InlineData(" Imdb", ExampleImdbId)]
+ public void TrySetProviderId_SurroundingWhitespace_Trimmed(string name, string value)
+ {
+ var provider = new ProviderIdsExtensionsTestsObject();
+
+ Assert.True(provider.TrySetProviderId(name, value));
+ Assert.Equal(ExampleImdbId, provider.GetProviderId(MetadataProvider.Imdb));
+ }
+
+ [Fact]
+ public void SetProviderIds_ReplacesAll()
+ {
+ var provider = new ProviderIdsExtensionsTestsObject();
+ provider.ProviderIds[MetadataProvider.Tvdb.ToString()] = "12345";
+
+ provider.SetProviderIds(new Dictionary<string, string>
+ {
+ [MetadataProvider.Imdb.ToString()] = ExampleImdbId
+ });
+
+ Assert.Equal(ExampleImdbId, provider.GetProviderId(MetadataProvider.Imdb));
+ Assert.False(provider.HasProviderId(MetadataProvider.Tvdb));
+ }
+
+ [Fact]
+ public void SetProviderIds_ForeignId_Dropped()
+ {
+ var provider = new ProviderIdsExtensionsTestsObject();
+
+ provider.SetProviderIds(new Dictionary<string, string>
+ {
+ [MetadataProvider.Tmdb.ToString()] = "nm0000123",
+ [MetadataProvider.Imdb.ToString()] = ExampleImdbId,
+ [MetadataProvider.Tvdb.ToString()] = string.Empty
+ });
+
+ Assert.False(provider.HasProviderId(MetadataProvider.Tmdb));
+ Assert.False(provider.HasProviderId(MetadataProvider.Tvdb));
+ Assert.Equal(ExampleImdbId, provider.GetProviderId(MetadataProvider.Imdb));
+ }
+
+ [Fact]
+ public void SetProviderIds_Null_Clears()
+ {
+ var provider = new ProviderIdsExtensionsTestsObject();
+ provider.ProviderIds[MetadataProvider.Imdb.ToString()] = ExampleImdbId;
+
+ provider.SetProviderIds(null);
+
+ Assert.Empty(provider.ProviderIds);
+ }
+
+ [Fact]
+ public void SetProviderIds_NullInstance_ThrowsArgumentNullException()
+ {
+ Assert.Throws<ArgumentNullException>(() => ProviderIdsExtensions.SetProviderIds(null!, new Dictionary<string, string>()));
+ }
+
[Fact]
public void RemoveProviderId_Null_Remove()
{
diff --git a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs
new file mode 100644
index 0000000000..1d2fb2e760
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs
@@ -0,0 +1,253 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Providers.Manager;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.Manager
+{
+ public class MetadataServiceRefreshTests
+ {
+ [Theory]
+ // RemoveOldMetadata is only ever set by an explicit user action - a refresh with "replace all
+ // metadata", or Identify. A provider failing must not silently downgrade that to a merge: the
+ // providers that did answer supplied the replacement, and the old values are the wrong match
+ // the user asked to get rid of.
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task RefreshWithProviders_ReplaceAllMetadata_ErasesOldDataWhenAProviderAnswers(bool allProvidersSucceed)
+ {
+ var item = new Movie
+ {
+ Name = "Test Movie",
+ Overview = "existing overview"
+ };
+
+ // The provider owning the overview fails, so it contributes nothing to the replacement.
+ var failing = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
+ failing.Setup(p => p.Name).Returns("Failing");
+ failing.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
+ .Returns(allProvidersSucceed
+ ? Task.FromResult(new MetadataResult<Movie> { HasMetadata = true, Item = new Movie() })
+ : Task.FromException<MetadataResult<Movie>>(new FormatException("bad id")));
+
+ var succeeding = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
+ succeeding.Setup(p => p.Name).Returns("Succeeding");
+ succeeding.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
+ .ReturnsAsync(new MetadataResult<Movie>
+ {
+ HasMetadata = true,
+ Item = new Movie { Name = "Test Movie", Tagline = "new tagline" }
+ });
+
+ var service = new TestMetadataService();
+ var result = await service.RefreshWithProvidersInternal(
+ new MetadataResult<Movie> { Item = item },
+ new MovieInfo { Name = item.Name },
+ new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
+ {
+ MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
+ ReplaceAllMetadata = true,
+ RemoveOldMetadata = true
+ },
+ [failing.Object, succeeding.Object]).ConfigureAwait(true);
+
+ Assert.Equal(allProvidersSucceed ? 0 : 1, result.Failures);
+ Assert.Equal("new tagline", item.Tagline);
+ Assert.Null(item.Overview);
+ }
+
+ [Fact]
+ public async Task RefreshWithProviders_ReplaceAllMetadata_KeepsExistingDataWhenEveryRemoteProviderFails()
+ {
+ var item = new Movie
+ {
+ Name = "Test Movie",
+ Overview = "existing overview"
+ };
+
+ // Something has to contribute for the merge to run at all, otherwise the item is never touched
+ // and the case is moot. The local provider is the replacement the remote ones did not deliver.
+ var local = new Mock<ILocalMetadataProvider<Movie>>(MockBehavior.Loose);
+ local.Setup(p => p.Name).Returns("Local");
+ local.Setup(p => p.GetMetadata(It.IsAny<ItemInfo>(), It.IsAny<IDirectoryService>(), It.IsAny<CancellationToken>()))
+ .ReturnsAsync(new MetadataResult<Movie>
+ {
+ HasMetadata = true,
+ Item = new Movie { Name = "Test Movie", Tagline = "new tagline" }
+ });
+
+ var remote = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
+ remote.Setup(p => p.Name).Returns("Failing");
+ remote.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
+ .Returns(Task.FromException<MetadataResult<Movie>>(new HttpRequestException("unreachable")));
+
+ var service = new TestMetadataService();
+ var result = await service.RefreshWithProvidersInternal(
+ new MetadataResult<Movie> { Item = item },
+ new MovieInfo { Name = item.Name },
+ new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
+ {
+ MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
+ ReplaceAllMetadata = true,
+ RemoveOldMetadata = true
+ },
+ [local.Object, remote.Object]).ConfigureAwait(true);
+
+ Assert.Equal(1, result.Failures);
+ Assert.Equal("new tagline", item.Tagline);
+
+ // No remote provider answered, so erasing the overview would lose it for good.
+ Assert.Equal("existing overview", item.Overview);
+ }
+
+ [Fact]
+ public async Task RefreshWithProviders_ForeignProviderId_NotStored()
+ {
+ var item = new Movie { Name = "Test Movie" };
+
+ var provider = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
+ provider.Setup(p => p.Name).Returns("Provider");
+ provider.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
+ .ReturnsAsync(() =>
+ {
+ var found = new Movie { Name = "Test Movie" };
+ found.ProviderIds[MetadataProvider.Tmdb.ToString()] = "nm0000123";
+ found.ProviderIds[MetadataProvider.Imdb.ToString()] = "tt0113375";
+ return new MetadataResult<Movie> { HasMetadata = true, Item = found };
+ });
+
+ var service = new TestMetadataService();
+ await service.RefreshWithProvidersInternal(
+ new MetadataResult<Movie> { Item = item },
+ new MovieInfo { Name = item.Name },
+ new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
+ {
+ MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
+ ReplaceAllMetadata = true
+ },
+ [provider.Object]).ConfigureAwait(true);
+
+ Assert.False(item.HasProviderId(MetadataProvider.Tmdb));
+ Assert.Equal("tt0113375", item.GetProviderId(MetadataProvider.Imdb));
+ }
+
+ [Fact]
+ public async Task RefreshWithProviders_ForeignProviderId_ReplacedInLookupInfo()
+ {
+ var item = new Movie { Name = "Test Movie" };
+ var lookupInfo = new MovieInfo { Name = item.Name };
+ lookupInfo.ProviderIds[MetadataProvider.Tmdb.ToString()] = "nm0000123";
+
+ var answering = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
+ answering.Setup(p => p.Name).Returns("Answering");
+ answering.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
+ .ReturnsAsync(() =>
+ {
+ var found = new Movie { Name = "Test Movie" };
+ found.ProviderIds[MetadataProvider.Tmdb.ToString()] = "12345";
+ return new MetadataResult<Movie> { HasMetadata = true, Item = found };
+ });
+
+ string? tmdbIdSeenBySecondProvider = null;
+ var following = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
+ following.Setup(p => p.Name).Returns("Following");
+ following.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
+ .ReturnsAsync((MovieInfo info, CancellationToken _) =>
+ {
+ tmdbIdSeenBySecondProvider = info.GetProviderId(MetadataProvider.Tmdb);
+ return new MetadataResult<Movie> { HasMetadata = false };
+ });
+
+ var service = new TestMetadataService();
+ await service.RefreshWithProvidersInternal(
+ new MetadataResult<Movie> { Item = item },
+ lookupInfo,
+ new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
+ {
+ MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
+ ReplaceAllMetadata = true
+ },
+ [answering.Object, following.Object]).ConfigureAwait(true);
+
+ // The stored id cannot be a TMDb one, so the provider that still has to run must get the id
+ // that was just found instead of failing on the same bad one.
+ Assert.Equal("12345", tmdbIdSeenBySecondProvider);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task RefreshWithProviders_ForeignPersonProviderId_NotStored(bool replaceAllMetadata)
+ {
+ var item = new Movie { Name = "Test Movie" };
+ var existing = new MetadataResult<Movie> { Item = item };
+ existing.AddPerson(new PersonInfo { Name = "Some Actor", Type = PersonKind.Actor });
+
+ var provider = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
+ provider.Setup(p => p.Name).Returns("Provider");
+ provider.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
+ .ReturnsAsync(() =>
+ {
+ var person = new PersonInfo { Name = "Some Actor", Type = PersonKind.Actor };
+ person.ProviderIds[MetadataProvider.Tmdb.ToString()] = "nm0000123";
+ person.ProviderIds[MetadataProvider.Imdb.ToString()] = "nm0000123";
+
+ var found = new MetadataResult<Movie> { HasMetadata = true, Item = new Movie { Name = "Test Movie" } };
+ found.AddPerson(person);
+ return found;
+ });
+
+ var service = new TestMetadataService();
+ await service.RefreshWithProvidersInternal(
+ existing,
+ new MovieInfo { Name = item.Name },
+ new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
+ {
+ MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
+ ReplaceAllMetadata = replaceAllMetadata
+ },
+ [provider.Object]).ConfigureAwait(true);
+
+ var mergedPerson = Assert.Single(existing.People);
+ Assert.False(mergedPerson.HasProviderId(MetadataProvider.Tmdb));
+ Assert.Equal("nm0000123", mergedPerson.GetProviderId(MetadataProvider.Imdb));
+ }
+
+ private sealed class TestMetadataService : MetadataService<Movie, MovieInfo>
+ {
+ public TestMetadataService()
+ : base(
+ Mock.Of<IServerConfigurationManager>(),
+ NullLogger<MetadataService<Movie, MovieInfo>>.Instance,
+ Mock.Of<IProviderManager>(),
+ Mock.Of<IFileSystem>(),
+ Mock.Of<ILibraryManager>(),
+ Mock.Of<IExternalDataManager>(),
+ Mock.Of<IItemRepository>())
+ {
+ }
+
+ public Task<RefreshResult> RefreshWithProvidersInternal(
+ MetadataResult<Movie> metadata,
+ MovieInfo id,
+ MetadataRefreshOptions options,
+ ICollection<IMetadataProvider> providers)
+ => RefreshWithProviders(metadata, id, options, providers, ImageProvider, false, CancellationToken.None);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Providers.Tests/Music/AlbumInfoExtensionsTests.cs b/tests/Jellyfin.Providers.Tests/Music/AlbumInfoExtensionsTests.cs
new file mode 100644
index 0000000000..c5ec0de02c
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/Music/AlbumInfoExtensionsTests.cs
@@ -0,0 +1,59 @@
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Providers.Music;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.Music;
+
+public static class AlbumInfoExtensionsTests
+{
+ private const string ExampleMbid = "59b5a40b-e2fd-3f18-a218-e8c9aae12ab5";
+ private const string SongMbid = "6c301dbd-6ccb-3403-a6c4-6a22240a0297";
+
+ [Theory]
+ [InlineData(ExampleMbid, ExampleMbid)]
+ // Another provider's id under a MusicBrainz key reads as no id, so the caller searches instead of
+ // handing a value the MusicBrainz client throws on.
+ [InlineData("111239", null)]
+ [InlineData("", null)]
+ public static void GetReleaseId_OnlyReturnsMbids(string id, string? expected)
+ {
+ var info = new AlbumInfo();
+ info.ProviderIds[MetadataProvider.MusicBrainzAlbum.ToString()] = id;
+
+ Assert.Equal(expected, info.GetReleaseId());
+ }
+
+ [Fact]
+ public static void GetReleaseId_ForeignId_FallsBackToSongs()
+ {
+ var song = new SongInfo();
+ song.ProviderIds[MetadataProvider.MusicBrainzAlbum.ToString()] = SongMbid;
+
+ var info = new AlbumInfo { SongInfos = [song] };
+ info.ProviderIds[MetadataProvider.MusicBrainzAlbum.ToString()] = "111239";
+
+ Assert.Equal(SongMbid, info.GetReleaseId());
+ }
+
+ [Fact]
+ public static void GetMusicBrainzArtistId_ForeignId_FallsBackToArtistIds()
+ {
+ var info = new AlbumInfo();
+ info.ProviderIds[MetadataProvider.MusicBrainzAlbumArtist.ToString()] = "111239";
+ info.ArtistProviderIds[MetadataProvider.MusicBrainzArtist.ToString()] = ExampleMbid;
+
+ Assert.Equal(ExampleMbid, info.GetMusicBrainzArtistId());
+ }
+
+ [Theory]
+ [InlineData(ExampleMbid, ExampleMbid)]
+ [InlineData("111239", null)]
+ public static void GetMusicBrainzArtistId_ArtistInfo_OnlyReturnsMbids(string id, string? expected)
+ {
+ var info = new ArtistInfo();
+ info.ProviderIds[MetadataProvider.MusicBrainzArtist.ToString()] = id;
+
+ Assert.Equal(expected, info.GetMusicBrainzArtistId());
+ }
+}
diff --git a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs
index fb0a08c29c..4c4dd5e92f 100644
--- a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs
+++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs
@@ -1,3 +1,5 @@
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Model.Entities;
using MediaBrowser.Providers.Plugins.Tmdb;
using Xunit;
@@ -34,5 +36,40 @@ namespace Jellyfin.Providers.Tests.Tmdb
{
Assert.Equal(expected, TmdbUtils.AdjustImageLanguage(imageLanguage, requestLanguage));
}
+
+ [Theory]
+ [InlineData("11", true, 11)]
+ // An id another provider filed under the TMDb key must not throw, it is simply not a TMDb id.
+ [InlineData("nm0000123", false, 0)]
+ [InlineData("tt0113375", false, 0)]
+ [InlineData("11.0", false, 0)]
+ [InlineData("-11", false, 0)]
+ [InlineData("0", false, 0)]
+ [InlineData("", false, 0)]
+ [InlineData(null, false, 0)]
+ public static void TryParseTmdbId_OnlyAcceptsTmdbIds(string? value, bool expected, int expectedId)
+ {
+ Assert.Equal(expected, TmdbUtils.TryParseTmdbId(value, out var tmdbId));
+ Assert.Equal(expectedId, tmdbId);
+ }
+
+ [Theory]
+ [InlineData("11", true, 11)]
+ [InlineData("nm0000123", false, 0)]
+ public static void TryGetTmdbId_OnlyAcceptsTmdbIds(string value, bool expected, int expectedId)
+ {
+ var item = new Movie();
+ item.ProviderIds[MetadataProvider.Tmdb.ToString()] = value;
+
+ Assert.Equal(expected, item.TryGetTmdbId(out var tmdbId));
+ Assert.Equal(expectedId, tmdbId);
+ }
+
+ [Fact]
+ public static void TryGetTmdbId_NoId_False()
+ {
+ Assert.False(new Movie().TryGetTmdbId(out var tmdbId));
+ Assert.Equal(0, tmdbId);
+ }
}
}