mirror of
https://github.com/AtomUI/AtomUI.git
synced 2026-10-18 14:37:19 +08:00
313 lines
12 KiB
C#
313 lines
12 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using AtomUI.Controls;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace AtomUI.Controls.Shared.Tests.ImageLoading;
|
|
|
|
public class HttpImageTransportTests
|
|
{
|
|
[Fact]
|
|
public async Task NotModified_Reuses_Stale_Body_And_Merges_Freshness_Metadata()
|
|
{
|
|
var handler = new SequenceHttpMessageHandler(request =>
|
|
{
|
|
request.Headers.IfNoneMatch.Single().Tag.ShouldBe("\"v1\"");
|
|
var response = new HttpResponseMessage(HttpStatusCode.NotModified);
|
|
response.Headers.CacheControl = new CacheControlHeaderValue
|
|
{
|
|
MaxAge = TimeSpan.FromMinutes(10),
|
|
MustRevalidate = true
|
|
};
|
|
response.Headers.Date = DateTimeOffset.UtcNow;
|
|
response.Headers.ETag = new EntityTagHeaderValue("\"v2\"");
|
|
return response;
|
|
});
|
|
using var transport = new HttpImageTransport(ImageLoadingTestSupport.CreateOptions(), handler);
|
|
var request = Normalize("https://example.com/image.png");
|
|
var stale = ImageLoadingTestSupport.CreateContent(ImageLoadingTestSupport.CreatePngHeader()) with
|
|
{
|
|
ETag = "\"v1\"",
|
|
NoCache = true,
|
|
IsRemote = true
|
|
};
|
|
|
|
var result = await transport.FetchAsync(
|
|
request,
|
|
stale,
|
|
null,
|
|
TestContext.Current.CancellationToken);
|
|
|
|
result.Bytes.ShouldBeSameAs(stale.Bytes);
|
|
result.SourceValidation.ShouldBe(ImageSourceValidation.Revalidated);
|
|
result.ETag.ShouldBe("\"v2\"");
|
|
result.MustRevalidate.ShouldBeTrue();
|
|
result.FreshUntil.ShouldNotBeNull();
|
|
result.FreshUntil.Value.ShouldBeGreaterThan(DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unexpected_NotModified_Without_Body_Retries_Once_As_Full_Request()
|
|
{
|
|
var calls = 0;
|
|
var handler = new SequenceHttpMessageHandler(
|
|
_ =>
|
|
{
|
|
Interlocked.Increment(ref calls);
|
|
return new HttpResponseMessage(HttpStatusCode.NotModified);
|
|
},
|
|
request =>
|
|
{
|
|
Interlocked.Increment(ref calls);
|
|
request.Headers.IfNoneMatch.ShouldBeEmpty();
|
|
return OkResponse();
|
|
});
|
|
using var transport = new HttpImageTransport(ImageLoadingTestSupport.CreateOptions(), handler);
|
|
|
|
var result = await transport.FetchAsync(
|
|
Normalize("https://example.com/image.png"),
|
|
null,
|
|
null,
|
|
TestContext.Current.CancellationToken);
|
|
|
|
calls.ShouldBe(2);
|
|
result.Origin.ShouldBe(ImageLoadOrigin.Network);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Cross_Origin_Redirect_Strips_Credentials_By_Default()
|
|
{
|
|
var handler = new SequenceHttpMessageHandler(
|
|
request =>
|
|
{
|
|
request.Headers.Contains("Authorization").ShouldBeTrue();
|
|
return RedirectResponse("https://cdn.example.com/image.png");
|
|
},
|
|
request =>
|
|
{
|
|
request.Headers.Contains("Authorization").ShouldBeFalse();
|
|
return OkResponse();
|
|
});
|
|
using var transport = new HttpImageTransport(ImageLoadingTestSupport.CreateOptions(), handler);
|
|
|
|
var result = await transport.FetchAsync(
|
|
Normalize(
|
|
"https://origin.example.com/image.png",
|
|
new ImageRequestOptions
|
|
{
|
|
CachePartition = "account",
|
|
Headers = new Dictionary<string, string> { ["Authorization"] = "Bearer secret" }
|
|
}),
|
|
null,
|
|
null,
|
|
TestContext.Current.CancellationToken);
|
|
|
|
result.Origin.ShouldBe(ImageLoadOrigin.Network);
|
|
handler.CallCount.ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Credential_Forwarding_Allowlist_Preserves_Credentials_On_Approved_Redirect()
|
|
{
|
|
var options = ImageLoadingTestSupport.CreateOptions(
|
|
builder => builder.AllowCredentialForwardingOrigin("https://cdn.example.com"));
|
|
var handler = new SequenceHttpMessageHandler(
|
|
_ => RedirectResponse("https://cdn.example.com/image.png"),
|
|
request =>
|
|
{
|
|
request.Headers.GetValues("Authorization").Single().ShouldBe("Bearer secret");
|
|
return OkResponse();
|
|
});
|
|
using var transport = new HttpImageTransport(options, handler);
|
|
|
|
await transport.FetchAsync(
|
|
Normalize(
|
|
"https://origin.example.com/image.png",
|
|
new ImageRequestOptions
|
|
{
|
|
CachePartition = "account",
|
|
Headers = new Dictionary<string, string> { ["Authorization"] = "Bearer secret" }
|
|
},
|
|
options),
|
|
null,
|
|
null,
|
|
TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Https_To_Http_Redirect_Is_Rejected_Before_Second_Request()
|
|
{
|
|
var handler = new SequenceHttpMessageHandler(
|
|
_ => RedirectResponse("http://example.com/image.png"));
|
|
using var transport = new HttpImageTransport(ImageLoadingTestSupport.CreateOptions(), handler);
|
|
|
|
var exception = await Should.ThrowAsync<ImageLoadFailureException>(async () =>
|
|
await transport.FetchAsync(
|
|
Normalize("https://example.com/image.png"),
|
|
null,
|
|
null,
|
|
TestContext.Current.CancellationToken));
|
|
|
|
exception.Error.Code.ShouldBe(ImageLoadErrorCode.RedirectBlocked);
|
|
handler.CallCount.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Origin_Allowlist_Blocks_Unapproved_Origin_Without_Sending_Request()
|
|
{
|
|
var options = ImageLoadingTestSupport.CreateOptions(
|
|
builder => builder.AllowHttpOrigin("https://allowed.example.com"));
|
|
var handler = new SequenceHttpMessageHandler(_ => OkResponse());
|
|
using var transport = new HttpImageTransport(options, handler);
|
|
|
|
var exception = await Should.ThrowAsync<ImageLoadFailureException>(async () =>
|
|
await transport.FetchAsync(
|
|
Normalize("https://blocked.example.com/image.png", options: options),
|
|
null,
|
|
null,
|
|
TestContext.Current.CancellationToken));
|
|
|
|
exception.Error.Code.ShouldBe(ImageLoadErrorCode.OriginNotAllowed);
|
|
handler.CallCount.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Cache_Control_And_Vary_Metadata_Are_Normalized_Without_Storing_Header_Values()
|
|
{
|
|
const string secretLanguage = "en-US,private-token";
|
|
var handler = new SequenceHttpMessageHandler(_ =>
|
|
{
|
|
var response = OkResponse();
|
|
response.Headers.CacheControl = new CacheControlHeaderValue
|
|
{
|
|
MaxAge = TimeSpan.FromMinutes(5),
|
|
Private = true,
|
|
NoCache = true
|
|
};
|
|
response.Headers.Vary.Add("Accept-Language");
|
|
response.Headers.Date = DateTimeOffset.UtcNow;
|
|
response.Headers.Age = TimeSpan.FromSeconds(10);
|
|
return response;
|
|
});
|
|
using var transport = new HttpImageTransport(ImageLoadingTestSupport.CreateOptions(), handler);
|
|
|
|
var result = await transport.FetchAsync(
|
|
Normalize(
|
|
"https://example.com/image.png",
|
|
new ImageRequestOptions
|
|
{
|
|
Headers = new Dictionary<string, string> { ["Accept-Language"] = secretLanguage }
|
|
}),
|
|
null,
|
|
null,
|
|
TestContext.Current.CancellationToken);
|
|
|
|
result.NoCache.ShouldBeTrue();
|
|
result.IsPrivate.ShouldBeTrue();
|
|
result.MaxAge.ShouldBe(TimeSpan.FromMinutes(5));
|
|
result.ResponseAge.ShouldBe(TimeSpan.FromSeconds(10));
|
|
result.VaryHeaders.ShouldBe(["Accept-Language"]);
|
|
result.VaryDigest.ShouldNotBeNullOrWhiteSpace();
|
|
result.VaryDigest.ShouldNotContain(secretLanguage);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NoStore_And_VaryStar_Disable_Caching()
|
|
{
|
|
var handler = new SequenceHttpMessageHandler(_ =>
|
|
{
|
|
var response = OkResponse();
|
|
response.Headers.CacheControl = new CacheControlHeaderValue { NoStore = true };
|
|
response.Headers.Vary.Add("*");
|
|
return response;
|
|
});
|
|
using var transport = new HttpImageTransport(ImageLoadingTestSupport.CreateOptions(), handler);
|
|
|
|
var result = await transport.FetchAsync(
|
|
Normalize("https://example.com/image.png"),
|
|
null,
|
|
null,
|
|
TestContext.Current.CancellationToken);
|
|
|
|
result.NoStore.ShouldBeTrue();
|
|
result.VaryDigest.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Content_Length_Above_Limit_Is_Rejected_Before_Body_Read()
|
|
{
|
|
var options = ImageLoadingTestSupport.CreateOptions(builder => builder.MaxResponseBytes = 4);
|
|
var handler = new SequenceHttpMessageHandler(_ =>
|
|
{
|
|
var response = OkResponse();
|
|
response.Content.Headers.ContentLength = 5;
|
|
return response;
|
|
});
|
|
using var transport = new HttpImageTransport(options, handler);
|
|
|
|
var exception = await Should.ThrowAsync<ImageLoadFailureException>(async () =>
|
|
await transport.FetchAsync(
|
|
Normalize("https://example.com/image.png", options: options),
|
|
null,
|
|
null,
|
|
TestContext.Current.CancellationToken));
|
|
|
|
exception.Error.Code.ShouldBe(ImageLoadErrorCode.ResponseTooLarge);
|
|
}
|
|
|
|
private static NormalizedImageRequest Normalize(
|
|
string source,
|
|
ImageRequestOptions? requestOptions = null,
|
|
ImageLoadingOptions? options = null)
|
|
{
|
|
options ??= ImageLoadingTestSupport.CreateOptions();
|
|
return ImageCacheKey.Normalize(
|
|
new ImageLoadRequest(ImageSource.Parse(source)) { Options = requestOptions },
|
|
options,
|
|
forceReload: false);
|
|
}
|
|
|
|
private static HttpResponseMessage OkResponse()
|
|
{
|
|
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new ByteArrayContent(ImageLoadingTestSupport.CreatePngHeader())
|
|
};
|
|
response.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
|
|
return response;
|
|
}
|
|
|
|
private static HttpResponseMessage RedirectResponse(string location)
|
|
{
|
|
var response = new HttpResponseMessage(HttpStatusCode.Redirect);
|
|
response.Headers.Location = new Uri(location);
|
|
return response;
|
|
}
|
|
|
|
private sealed class SequenceHttpMessageHandler : HttpMessageHandler
|
|
{
|
|
private readonly Queue<Func<HttpRequestMessage, HttpResponseMessage>> _responses;
|
|
private int _callCount;
|
|
|
|
internal SequenceHttpMessageHandler(
|
|
params Func<HttpRequestMessage, HttpResponseMessage>[] responses)
|
|
{
|
|
_responses = new Queue<Func<HttpRequestMessage, HttpResponseMessage>>(responses);
|
|
}
|
|
|
|
internal int CallCount => Volatile.Read(ref _callCount);
|
|
|
|
protected override Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
Interlocked.Increment(ref _callCount);
|
|
var response = _responses.Dequeue()(request);
|
|
response.RequestMessage ??= request;
|
|
return Task.FromResult(response);
|
|
}
|
|
}
|
|
}
|