Files
GameList/GameList.Tests/HelperTests.cs

296 lines
11 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Reflection;
using GameList.Infrastructure;
using GameList.Endpoints;
using GameList.Tests.Support;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Configuration;
using System.Text.Json;
using System.Net.Http.Json;
namespace GameList.Tests;
public class HelperTests
{
[Fact]
public void PasswordHasher_roundtrip_and_empty_guard()
{
var (hash, salt) = PasswordHasher.HashPassword("secret");
Assert.True(PasswordHasher.Verify("secret", hash, salt));
Assert.False(PasswordHasher.Verify("other", hash, salt));
Assert.Throws<ArgumentException>(() => PasswordHasher.HashPassword(""));
}
[Fact]
public void UpdateIndexMetaBase_rewrites_content_value()
{
var webRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
Directory.CreateDirectory(webRoot);
var index = Path.Combine(webRoot, "index.html");
File.WriteAllText(index, "<meta name=\"app-base\" content=\"\">");
var env = new FakeEnv { WebRootPath = webRoot };
var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).First(m => m.Name.Contains("UpdateIndexMetaBase"));
method.Invoke(null, [env, "/pick"]);
var text = File.ReadAllText(index);
Assert.Contains("content=\"/pick\"", text);
}
[Fact]
public void UpdateIndexMetaBase_no_marker_no_change()
{
var webRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
Directory.CreateDirectory(webRoot);
var index = Path.Combine(webRoot, "index.html");
File.WriteAllText(index, "<html></html>");
var env = new FakeEnv { WebRootPath = webRoot };
var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).First(m => m.Name.Contains("UpdateIndexMetaBase"));
method.Invoke(null, [env, "/pick"]);
Assert.Equal("<html></html>", File.ReadAllText(index));
}
[Fact]
public async Task IsReachableImageAsync_rejects_redirect_and_accepts_image()
{
Assert.True(await EndpointHelpers.IsReachableImageAsync(null, new StubHttpClientFactory(new StubHttpMessageHandler())));
Assert.False(await EndpointHelpers.IsReachableImageAsync("http://127.0.0.1/img.png", new StubHttpClientFactory(new StubHttpMessageHandler())));
}
[Fact]
public async Task IsReachableImageAsync_handles_head_success_redirect_and_size_guard()
{
var handler = new StubHttpMessageHandler();
handler.SetResponder(req =>
{
if (req.Method == HttpMethod.Head)
{
var resp = new HttpResponseMessage(HttpStatusCode.OK);
resp.Content = new ByteArrayContent([]);
resp.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
resp.Content.Headers.ContentLength = 100;
return resp;
}
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent([])
{
Headers =
{
ContentType = new MediaTypeHeaderValue("image/png"),
ContentLength = 100
}
}
};
});
var ok = await EndpointHelpers.IsReachableImageAsync("http://example.com/img.png", new StubHttpClientFactory(handler), handler);
Assert.True(ok);
handler.SetResponder(_ =>
{
var resp = new HttpResponseMessage(HttpStatusCode.Redirect);
resp.Headers.Location = new Uri("http://example.com/other");
return resp;
});
var redirect = await EndpointHelpers.IsReachableImageAsync("http://example.com/img.png", new StubHttpClientFactory(handler), handler);
Assert.False(redirect);
handler.SetResponder(_ =>
{
var resp = new HttpResponseMessage(HttpStatusCode.OK);
resp.Content = new ByteArrayContent(new byte[10]);
resp.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
resp.Content.Headers.ContentLength = 6 * 1024 * 1024; // over 5 MB
return resp;
});
var tooLarge = await EndpointHelpers.IsReachableImageAsync("http://example.com/img.png", new StubHttpClientFactory(handler), handler);
Assert.False(tooLarge);
}
[Fact]
public async Task IsReachableImageAsync_rejects_followed_redirect_chain()
{
var handler = new RedirectFollowingHandler();
var reachable = await EndpointHelpers.IsReachableImageAsync("http://example.com/img.png", new StubHttpClientFactory(new StubHttpMessageHandler()), handler);
Assert.False(reachable);
}
[Fact]
public async Task IsReachableImageAsync_rejects_non_image_content()
{
var handler = new StubHttpMessageHandler();
handler.SetResponder(_ =>
{
var resp = new HttpResponseMessage(HttpStatusCode.OK);
resp.Content = new ByteArrayContent("not image"u8.ToArray());
resp.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
resp.Content.Headers.ContentLength = 9;
return resp;
});
var result = await EndpointHelpers.IsReachableImageAsync("http://example.com/img.png", new StubHttpClientFactory(handler), handler);
Assert.False(result);
}
[Fact]
public void Link_root_helpers_handle_groups()
{
var roots = EndpointHelpers.BuildLinkRoots([(1, null), (2, 1), (3, null)]);
Assert.Equal(1, roots[1]);
Assert.Equal(1, roots[2]);
Assert.Equal(3, roots[3]);
var linked = EndpointHelpers.LinkedIdsFor(2, roots);
Assert.Contains(1, linked);
Assert.Contains(2, linked);
}
[Fact]
public void Url_validation_rules()
{
Assert.True(EndpointHelpers.IsValidImageUrl("https://x.com/img.png"));
Assert.False(EndpointHelpers.IsValidImageUrl("ftp://x/img.png"));
Assert.True(EndpointHelpers.IsValidHttpUrl("http://x"));
Assert.False(EndpointHelpers.IsValidHttpUrl("file://x"));
}
[Fact]
public void Find_root_handles_cycles()
{
var parentMap = new Dictionary<int, int?>
{
{ 1, 2 },
{ 2, 3 },
{ 3, 1 }
};
var root = EndpointHelpers.FindRootId(1, parentMap);
Assert.Equal(3, root); // cycle breaks on revisit
}
[Fact]
public async Task Global_exception_handler_returns_json_error()
{
await using var factory = new TestWebApplicationFactory().WithWebHostBuilder(builder =>
{
builder.Configure(app =>
{
app.UseGlobalExceptionLogging();
app.UseRouting();
app.UseEndpoints(endpoints => { endpoints.MapGet("/boom", _ => throw new InvalidOperationException("boom")); });
});
});
var client = factory.CreateClient();
var resp = await client.GetAsync("/boom");
Assert.Equal(HttpStatusCode.InternalServerError, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("Unexpected server error", json.GetProperty("error").GetString());
}
[Fact]
public void BuildForwardedHeadersOptions_reads_only_valid_proxies_and_networks()
{
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ForwardedHeaders:KnownProxies:0"] = "203.0.113.10",
["ForwardedHeaders:KnownProxies:1"] = "not-an-ip",
["ForwardedHeaders:KnownNetworks:0"] = "10.20.0.0/16",
["ForwardedHeaders:KnownNetworks:1"] = "invalid"
}).Build();
var options = BuildForwardedHeadersOptionsForTest(config);
Assert.Single(options.KnownProxies);
Assert.Single(options.KnownIPNetworks);
}
[Fact]
public async Task Forwarded_headers_are_ignored_for_untrusted_proxy()
{
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ForwardedHeaders:KnownProxies:0"] = "203.0.113.10"
}).Build();
var options = BuildForwardedHeadersOptionsForTest(config);
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
await using var app = builder.Build();
app.Use((ctx, next) =>
{
ctx.Connection.RemoteIpAddress = IPAddress.Parse("198.51.100.25");
return next();
});
app.UseForwardedHeaders(options);
app.MapGet("/scheme", (HttpContext ctx) => ctx.Request.Scheme);
await app.StartAsync();
try
{
var request = new HttpRequestMessage(HttpMethod.Get, "/scheme");
request.Headers.TryAddWithoutValidation("X-Forwarded-Proto", "https");
var response = await app.GetTestClient().SendAsync(request);
var scheme = await response.Content.ReadAsStringAsync();
Assert.Equal("http", scheme);
}
finally
{
await app.StopAsync();
}
}
private class FakeEnv : IWebHostEnvironment
{
public string ApplicationName { get; set; } = "";
public IFileProvider WebRootFileProvider { get; set; } = null!;
public string WebRootPath { get; set; } = "";
public string EnvironmentName { get; set; } = "";
public string ContentRootPath { get; set; } = "";
public IFileProvider ContentRootFileProvider { get; set; } = null!;
}
private static ForwardedHeadersOptions BuildForwardedHeadersOptionsForTest(IConfiguration config)
{
var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).First(m => m.Name.Contains("BuildForwardedHeadersOptions"));
return (ForwardedHeadersOptions)method.Invoke(null, [config])!;
}
private sealed class RedirectFollowingHandler : HttpMessageHandler
{
private static readonly Uri Source = new("http://example.com/img.png");
private static readonly Uri Destination = new("http://example.com/final.png");
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.RequestUri != Source)
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) { RequestMessage = request });
}
var redirectedRequest = new HttpRequestMessage(request.Method, Destination);
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
RequestMessage = redirectedRequest,
Content = new ByteArrayContent("PNG"u8.ToArray())
};
response.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
response.Content.Headers.ContentLength = 3;
return Task.FromResult(response);
}
}
}