Defense in depth on the redirect detector: Uri.Equals compares normalized components, so an escaping/casing difference can't be mistaken for a redirect and fail CLOSED -- the exact failure the check exists to prevent. A plex key can contain spaces or unicode. Honest note: this is NOT a fix for an observed bug. I wrote a test claiming to pin it, then ran the negative control and the test passed against the string comparison too -- Uri.ToString() unescapes, so both forms agree for our machine-generated URLs. The test was vacuous as written. It is kept, retitled and re-commented to describe what it actually guards (an un-redirected 404 on an escaping-sensitive url fails open), and the code comment says plainly that this is defense in depth rather than a repair. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
158 lines
6.1 KiB
C#
158 lines
6.1 KiB
C#
using System.Net;
|
|
using ErsatzTV.Infrastructure.Streaming;
|
|
using Microsoft.Extensions.Logging;
|
|
using NSubstitute;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
|
|
namespace ErsatzTV.Infrastructure.Tests.Streaming;
|
|
|
|
[TestFixture]
|
|
public class HttpRemoteStreamProberTests
|
|
{
|
|
private const string Url = "http://localhost:8409/media/jellyfin/abc123";
|
|
|
|
[Test]
|
|
public async Task Should_Report_Unavailable_On_404_From_The_Media_Server()
|
|
{
|
|
// a media-server 404 arrives after our /media/... endpoint redirected, so the response's
|
|
// final request uri is the media server's, not the probe url
|
|
HttpRemoteStreamProber prober = ProberReturning(
|
|
HttpStatusCode.NotFound,
|
|
finalUri: "http://jellyfin:8096/Videos/abc123/stream?static=true");
|
|
|
|
bool result = await prober.IsAvailable(Url, CancellationToken.None);
|
|
|
|
result.ShouldBeFalse();
|
|
}
|
|
|
|
// ersatztv#473 review finding: our OWN /media/{provider}/... endpoint 404s when the media source
|
|
// is unconfigured or momentarily missing. Failing closed there would blank every item on that
|
|
// source, which is exactly what the fail-open contract exists to prevent.
|
|
[Test]
|
|
public async Task Should_Fail_Open_On_404_That_Was_Not_Redirected()
|
|
{
|
|
HttpRemoteStreamProber prober = ProberReturning(HttpStatusCode.NotFound, finalUri: Url);
|
|
|
|
bool result = await prober.IsAvailable(Url, CancellationToken.None);
|
|
|
|
result.ShouldBeTrue();
|
|
}
|
|
|
|
// a plex key can contain spaces/unicode; pin that an un-redirected 404 on such a url still fails
|
|
// OPEN. (This passes against a naive string comparison too - Uri.ToString() unescapes - so it
|
|
// guards the behaviour, not the implementation choice.)
|
|
[Test]
|
|
public async Task Should_Fail_Open_On_404_For_An_Unredirected_Url_Needing_Escaping()
|
|
{
|
|
const string plexUrl = "http://localhost:8409/media/plex/1/library/parts/1/a file.mkv";
|
|
HttpRemoteStreamProber prober = ProberReturning(HttpStatusCode.NotFound, finalUri: plexUrl);
|
|
|
|
bool result = await prober.IsAvailable(plexUrl, CancellationToken.None);
|
|
|
|
result.ShouldBeTrue();
|
|
}
|
|
|
|
[TestCase(HttpStatusCode.OK)]
|
|
[TestCase(HttpStatusCode.PartialContent)]
|
|
[TestCase(HttpStatusCode.NoContent)]
|
|
public async Task Should_Report_Available_On_Success(HttpStatusCode statusCode)
|
|
{
|
|
HttpRemoteStreamProber prober = ProberReturning(statusCode);
|
|
|
|
bool result = await prober.IsAvailable(Url, CancellationToken.None);
|
|
|
|
result.ShouldBeTrue();
|
|
}
|
|
|
|
// the fail-open contract: a probe that cannot answer must never block a tune that would
|
|
// otherwise have worked. these cases exist so a future refactor can't silently invert it.
|
|
[TestCase(HttpStatusCode.InternalServerError)]
|
|
[TestCase(HttpStatusCode.BadGateway)]
|
|
[TestCase(HttpStatusCode.Unauthorized)]
|
|
[TestCase(HttpStatusCode.Forbidden)]
|
|
public async Task Should_Fail_Open_On_Other_Status_Codes(HttpStatusCode statusCode)
|
|
{
|
|
HttpRemoteStreamProber prober = ProberReturning(statusCode);
|
|
|
|
bool result = await prober.IsAvailable(Url, CancellationToken.None);
|
|
|
|
result.ShouldBeTrue();
|
|
}
|
|
|
|
[Test]
|
|
public async Task Should_Fail_Open_On_Transport_Failure()
|
|
{
|
|
var prober = new HttpRemoteStreamProber(
|
|
new StubHttpClientFactory(new ThrowingHttpMessageHandler(new HttpRequestException("no route to host"))),
|
|
Substitute.For<ILogger<HttpRemoteStreamProber>>());
|
|
|
|
bool result = await prober.IsAvailable(Url, CancellationToken.None);
|
|
|
|
result.ShouldBeTrue();
|
|
}
|
|
|
|
[Test]
|
|
public async Task Should_Fail_Open_On_Timeout()
|
|
{
|
|
var prober = new HttpRemoteStreamProber(
|
|
new StubHttpClientFactory(new ThrowingHttpMessageHandler(new TaskCanceledException("timed out"))),
|
|
Substitute.For<ILogger<HttpRemoteStreamProber>>());
|
|
|
|
bool result = await prober.IsAvailable(Url, CancellationToken.None);
|
|
|
|
result.ShouldBeTrue();
|
|
}
|
|
|
|
// caller cancellation (shutdown / client disconnect) is a genuine signal, NOT a probe failure --
|
|
// swallowing it would let the handler go on building an ffmpeg command on a dead token.
|
|
[Test]
|
|
public async Task Should_Propagate_Caller_Cancellation()
|
|
{
|
|
HttpRemoteStreamProber prober = ProberReturning(HttpStatusCode.OK);
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
await cts.CancelAsync();
|
|
|
|
await Should.ThrowAsync<OperationCanceledException>(() => prober.IsAvailable(Url, cts.Token));
|
|
}
|
|
|
|
private static HttpRemoteStreamProber ProberReturning(HttpStatusCode statusCode, string finalUri = null) =>
|
|
new(
|
|
new StubHttpClientFactory(new StatusCodeHttpMessageHandler(statusCode, finalUri)),
|
|
Substitute.For<ILogger<HttpRemoteStreamProber>>());
|
|
|
|
private sealed class StubHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory
|
|
{
|
|
public HttpClient CreateClient(string name) => new(handler, disposeHandler: false);
|
|
}
|
|
|
|
private sealed class StatusCodeHttpMessageHandler(HttpStatusCode statusCode, string finalUri = null)
|
|
: HttpMessageHandler
|
|
{
|
|
protected override Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
// HttpClient rewrites RequestMessage.RequestUri to the final hop when it follows a
|
|
// redirect; finalUri lets a test stand in for "the media server answered this".
|
|
if (finalUri is not null)
|
|
{
|
|
request.RequestUri = new Uri(finalUri);
|
|
}
|
|
|
|
return Task.FromResult(new HttpResponseMessage(statusCode) { RequestMessage = request });
|
|
}
|
|
}
|
|
|
|
private sealed class ThrowingHttpMessageHandler(Exception exception) : HttpMessageHandler
|
|
{
|
|
protected override Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request,
|
|
CancellationToken cancellationToken) =>
|
|
Task.FromException<HttpResponseMessage>(exception);
|
|
}
|
|
}
|