using ErsatzTV.Core.Domain; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data.Repositories; using ErsatzTV.Tests.Support; using Microsoft.Extensions.Logging.Abstractions; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Tests.Infrastructure; // Design #202 findings 2c/8 — repo-level defense-in-depth: the UPDATE for path replacements must be // scoped to the owning media source so a PUT against source A can never silently overwrite a row // that belongs to source B, even if a caller bypasses the handler-level ownership guard. [TestFixture] public class MediaSourceRepositoryPathReplacementScopeTests { private InMemoryTvContext _db = null!; private MediaSourceRepository _repository = null!; [SetUp] public async Task SetUp() { _db = await InMemoryTvContext.CreateAsync(); _repository = new MediaSourceRepository(_db.Factory, NullLogger.Instance); } [TearDown] public async Task TearDown() => await _db.DisposeAsync(); [Test] public async Task UpdatePathReplacements_Should_Not_Update_A_Row_Owned_By_Another_Jellyfin_Source() { var sourceA = new JellyfinMediaSource { ServerName = "A", OperatingSystem = "Linux", Connections = [], PathReplacements = [new JellyfinPathReplacement { JellyfinPath = "/a", LocalPath = "/a-local" }] }; var sourceB = new JellyfinMediaSource { ServerName = "B", OperatingSystem = "Linux", Connections = [], PathReplacements = [new JellyfinPathReplacement { JellyfinPath = "/b", LocalPath = "/b-local" }] }; await using (TvContext context = _db.CreateContext()) { context.MediaSources.AddRange(sourceA, sourceB); await context.SaveChangesAsync(); } int bRowId = sourceB.PathReplacements.Single().Id; // attack: source A's PUT carries source B's row id as an "update" var maliciousUpdate = new JellyfinPathReplacement { Id = bRowId, JellyfinPath = "/hijacked", LocalPath = "/hijacked-local" }; await _repository.UpdatePathReplacements(sourceA.Id, [], [maliciousUpdate], []); await using TvContext verifyContext = _db.CreateContext(); JellyfinPathReplacement bRowAfter = await verifyContext.JellyfinPathReplacements.FindAsync(bRowId); bRowAfter.JellyfinPath.ShouldBe("/b"); bRowAfter.LocalPath.ShouldBe("/b-local"); } [Test] public async Task UpdatePathReplacements_Should_Not_Update_A_Row_Owned_By_Another_Emby_Source() { var sourceA = new EmbyMediaSource { ServerName = "A", OperatingSystem = "Linux", Connections = [], PathReplacements = [new EmbyPathReplacement { EmbyPath = "/a", LocalPath = "/a-local" }] }; var sourceB = new EmbyMediaSource { ServerName = "B", OperatingSystem = "Linux", Connections = [], PathReplacements = [new EmbyPathReplacement { EmbyPath = "/b", LocalPath = "/b-local" }] }; await using (TvContext context = _db.CreateContext()) { context.MediaSources.AddRange(sourceA, sourceB); await context.SaveChangesAsync(); } int bRowId = sourceB.PathReplacements.Single().Id; var maliciousUpdate = new EmbyPathReplacement { Id = bRowId, EmbyPath = "/hijacked", LocalPath = "/hijacked-local" }; await _repository.UpdatePathReplacements(sourceA.Id, [], [maliciousUpdate], []); await using TvContext verifyContext = _db.CreateContext(); EmbyPathReplacement bRowAfter = await verifyContext.EmbyPathReplacements.FindAsync(bRowId); bRowAfter.EmbyPath.ShouldBe("/b"); bRowAfter.LocalPath.ShouldBe("/b-local"); } }