using System.Security.Claims; using System.Net; using JobTrackerApi.Controllers; using JobTrackerApi.Services; using JobTrackerApi.Tests.TestSupport; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; namespace JobTrackerApi.Tests; public sealed class ExternalOriginTests { [Theory] [InlineData(null)] [InlineData("")] [InlineData("http://jobs.example.test")] [InlineData("https://user@jobs.example.test")] [InlineData("https://jobs.example.test/path")] [InlineData("https://jobs.example.test?query=1")] [InlineData("https://jobs.example.test#fragment")] public void Production_requires_one_clean_https_origin(string? value) { Assert.Throws(() => ExternalOrigin.Parse(value, production: true)); } [Fact] public void Development_defaults_to_local_frontend() { Assert.Equal("http://localhost:3000", ExternalOrigin.Parse(null, production: false).BaseUrl); } [Fact] public void Canonical_host_matching_includes_the_configured_port() { var origin = ExternalOrigin.Parse("https://jobs.example.test:8443/", production: true); Assert.True(origin.Matches(new HostString("jobs.example.test", 8443))); Assert.False(origin.Matches(new HostString("jobs.example.test"))); Assert.False(origin.Matches(new HostString("attacker.example.test", 8443))); } [Fact] public void Internal_host_is_allowed_only_for_liveness() { var origin = ExternalOrigin.Parse("https://jobs.example.test", production: true); Assert.True(origin.AllowsRequest(new HostString("jobs.example.test"), "/api/auth/config")); Assert.True(origin.AllowsRequest(new HostString("localhost", 8080), "/health")); Assert.False(origin.AllowsRequest(new HostString("localhost", 8080), "/api/auth/config")); Assert.False(origin.AllowsRequest(new HostString("attacker.example.test"), "/health")); } [Fact] public void Forwarded_headers_require_an_explicit_proxy_network() { var missing = BuildConfig(new Dictionary()); var invalid = BuildConfig(new Dictionary { ["Proxy:KnownNetworks:0"] = "anywhere" }); Assert.Throws(() => ForwardedProxyConfiguration.Build(missing)); Assert.Throws(() => ForwardedProxyConfiguration.Build(invalid)); } [Fact] public void Forwarded_headers_trust_one_hop_from_the_configured_network_only() { var config = BuildConfig(new Dictionary { ["Proxy:KnownNetworks:0"] = "172.31.250.0/29" }); var options = ForwardedProxyConfiguration.Build(config); Assert.Equal(1, options.ForwardLimit); var network = Assert.Single(options.KnownNetworks); Assert.True(network.Contains(IPAddress.Parse("172.31.250.2"))); Assert.False(network.Contains(IPAddress.Parse("172.31.251.2"))); Assert.Empty(options.KnownProxies); } [Fact] public void OAuth_callback_ignores_request_host_and_legacy_redirect_override() { var config = BuildConfig(new Dictionary { ["App:PublicBaseUrl"] = "https://jobs.example.test", ["Microsoft:RedirectUri"] = "https://attacker.example.test/callback", }); var graph = new Mock(); graph.Setup(x => x.BuildAuthorizationUrl("user-1", "https://jobs.example.test/api/microsoft-graph/oauth/callback")) .Returns("https://login.microsoftonline.com/authorize"); var controller = new MicrosoftGraphController(graph.Object, config) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "user-1"), }, "test")), }, }, }; controller.Request.Host = new HostString("attacker.example.test"); controller.ConnectUrl(); graph.VerifyAll(); } [Fact] public void Csrf_cookie_security_comes_from_canonical_origin() { var config = BuildConfig(new Dictionary { ["App:PublicBaseUrl"] = "https://jobs.example.test" }); var controller = new AuthController( config, TestHostFactory.CreateUserManager().Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }, }; controller.Request.Scheme = "http"; controller.Request.Headers["X-Forwarded-Proto"] = "http"; controller.EnsureCsrfCookie(); Assert.Contains("secure", controller.Response.Headers.SetCookie.ToString(), StringComparison.OrdinalIgnoreCase); } private static IConfiguration BuildConfig(IDictionary values) => new ConfigurationBuilder().AddInMemoryCollection(values).Build(); }