fix(security): enforce audited account, network and resource boundaries
Implement B01-B06 with regression coverage and retained validation evidence. Preserve the production ingress preflight; F04 runtime verification remains open.
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
using System.Text;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.VisualBasic.FileIO;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class CsvExportTests(ITestOutputHelper output)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Export_neutralizes_the_original_arithmetic_title_without_changing_storage()
|
||||
{
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var company = new Company { Name = "Ærlig Økonomi Ås", OwnerUserId = "user-1" };
|
||||
var job = new JobApplication { Company = company, OwnerUserId = "user-1", JobTitle = "=1+1" };
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
var result = Assert.IsType<FileContentResult>(await new ExportController(db).ExportJobs("csv"));
|
||||
output.WriteLine("B05_CSV_SAMPLE:" + Convert.ToBase64String(result.FileContents));
|
||||
var rows = Parse(result.FileContents);
|
||||
Assert.Equal("'=1+1", rows[1][3]);
|
||||
Assert.Equal("Ærlig Økonomi Ås", rows[1][0]);
|
||||
Assert.Equal(new byte[] { 0xef, 0xbb, 0xbf }, result.FileContents[..3]);
|
||||
Assert.Equal("text/csv; charset=utf-8", result.ContentType);
|
||||
Assert.Matches(@"^job-tracker-export-\d{4}-\d{2}-\d{2}\.csv$", result.FileDownloadName);
|
||||
db.ChangeTracker.Clear();
|
||||
Assert.Equal("=1+1", (await db.JobApplications.FindAsync(job.Id))!.JobTitle);
|
||||
var json = Assert.IsType<FileContentResult>(await new ExportController(db).ExportJobs("json"));
|
||||
using var raw = JsonDocument.Parse(json.FileContents);
|
||||
Assert.Equal("=1+1", raw.RootElement[0].GetProperty("JobTitle").GetString());
|
||||
Assert.Equal(result.FileContents, Assert.IsType<FileContentResult>(await new ExportController(db).ExportJobs("CSV")).FileContents);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("normal text", "normal text")]
|
||||
[InlineData("=1+1", "'=1+1")]
|
||||
[InlineData("+SUM(A1:A2)", "'+SUM(A1:A2)")]
|
||||
[InlineData("-2+3", "'-2+3")]
|
||||
[InlineData("@SUM(A1:A2)", "'@SUM(A1:A2)")]
|
||||
[InlineData(" \t=1+1", "' \t=1+1")]
|
||||
[InlineData("\r\n=1+1", "'\r\n=1+1")]
|
||||
[InlineData("\0\u000b+1", "'\0\u000b+1")]
|
||||
[InlineData("\u00a0\u200b\ufeff=1+1", "'\u00a0\u200b\ufeff=1+1")]
|
||||
[InlineData("\U000E0001=1+1", "'\U000E0001=1+1")]
|
||||
[InlineData("'=1+1", "'=1+1")]
|
||||
[InlineData(" '=1+1", " '=1+1")]
|
||||
[InlineData("a,b", "a,b")]
|
||||
[InlineData("a\"b", "a\"b")]
|
||||
[InlineData("a\r\nb\nc\rd", "a\r\nb\nc\rd")]
|
||||
[InlineData("=SUM(1,2)", "'=SUM(1,2)")]
|
||||
[InlineData("safe\",=1+1\r\n@SUM(A1:A2)", "safe\",=1+1\r\n@SUM(A1:A2)")]
|
||||
[InlineData("Ærlig, Økonomi og Ås — æøå 😀", "Ærlig, Økonomi og Ås — æøå 😀")]
|
||||
[InlineData(" ordinary text", " ordinary text")]
|
||||
[InlineData("123.50", "123.50")]
|
||||
[InlineData("", "")]
|
||||
[InlineData(null, "")]
|
||||
public async Task Text_policy_is_idempotent_and_survives_independent_CSV_parsing(string? value, string expected)
|
||||
{
|
||||
Assert.Equal(expected, CsvCellSafety.SpreadsheetText(value));
|
||||
Assert.Equal(expected, CsvCellSafety.SpreadsheetText(CsvCellSafety.SpreadsheetText(value)));
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
db.JobApplications.Add(new JobApplication { OwnerUserId = "user-1", Company = new Company { Name = "Synthetic", OwnerUserId = "user-1" }, Notes = value });
|
||||
await db.SaveChangesAsync();
|
||||
var result = Assert.IsType<FileContentResult>(await new ExportController(db).ExportJobs("csv"));
|
||||
var rows = Parse(result.FileContents);
|
||||
Assert.Equal(2, rows.Count);
|
||||
Assert.All(rows, row => Assert.Equal(17, row.Length));
|
||||
Assert.Equal(expected, rows[1][15]);
|
||||
Assert.Equal(value, (await db.JobApplications.AsNoTracking().SingleAsync()).Notes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Every_text_column_is_safe_while_typed_numbers_and_dates_keep_their_formats()
|
||||
{
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var date = new DateTime(2026, 9, 9, 12, 30, 0, DateTimeKind.Utc);
|
||||
db.JobApplications.Add(new JobApplication
|
||||
{
|
||||
OwnerUserId = "user-1",
|
||||
Company = new Company { Name = "=1+1", Location = " +SUM(1,2)", Source = "\t-2+3", OwnerUserId = "user-1" },
|
||||
JobTitle = "@SUM(A1:A2)", Status = "\r\n=1+1", Location = "\u200b=1+1", Salary = "-750",
|
||||
SalaryMin = -750.50m, SalaryMax = 1200.75m, SalaryCurrency = "+NOK", SalaryPeriod = "@year", NextAction = "=1+1",
|
||||
DateApplied = date, FollowUpAt = date.AddDays(1), JobUrl = "+1", Notes = "=SUM(1,2)\næøå", CoverLetterText = "=1+1\r\nÆØÅ",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var result = Assert.IsType<FileContentResult>(await new ExportController(db).ExportJobs("csv"));
|
||||
output.WriteLine("B05_TYPED_SAMPLE:" + Convert.ToBase64String(result.FileContents));
|
||||
var cells = Parse(result.FileContents)[1];
|
||||
Assert.Equal(17, cells.Length);
|
||||
foreach (var index in new[] { 0, 1, 2, 3, 4, 6, 7, 10, 11, 12, 14, 15, 16 }) Assert.StartsWith("'", cells[index]);
|
||||
Assert.Equal("-750.50", cells[8]);
|
||||
Assert.Equal("1200.75", cells[9]);
|
||||
Assert.Equal(date.ToString("o"), cells[5]);
|
||||
Assert.Equal(date.AddDays(1).ToString("o"), cells[13]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Long_text_null_typed_cells_and_deleted_row_selection_remain_valid()
|
||||
{
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var longText = "=1+1,\"æøå\"\r\n" + new string('ø', 70_000);
|
||||
db.JobApplications.Add(new JobApplication { OwnerUserId = "user-1", Company = new Company { Name = "Synthetic", OwnerUserId = "user-1" }, Notes = longText, IsDeleted = true });
|
||||
await db.SaveChangesAsync();
|
||||
var controller = new ExportController(db);
|
||||
Assert.Single(Parse(Assert.IsType<FileContentResult>(await controller.ExportJobs("csv")).FileContents));
|
||||
var cells = Parse(Assert.IsType<FileContentResult>(await controller.ExportJobs("csv", includeDeleted: true)).FileContents)[1];
|
||||
Assert.Equal("'" + longText, cells[15]);
|
||||
foreach (var index in new[] { 5, 8, 9, 13 }) Assert.Equal("", cells[index]);
|
||||
}
|
||||
|
||||
private static List<string[]> Parse(byte[] bytes)
|
||||
{
|
||||
using var parser = new TextFieldParser(new MemoryStream(bytes), Encoding.UTF8)
|
||||
{
|
||||
TextFieldType = FieldType.Delimited,
|
||||
HasFieldsEnclosedInQuotes = true,
|
||||
TrimWhiteSpace = false,
|
||||
};
|
||||
parser.SetDelimiters(",");
|
||||
var rows = new List<string[]>();
|
||||
while (!parser.EndOfData) rows.Add(parser.ReadFields()!);
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using JobTrackerApi.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
@@ -30,7 +31,8 @@ public sealed class CvExportRetentionTests
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["Data:Root"] = root }).Build();
|
||||
var environment = new Mock<IHostEnvironment>();
|
||||
environment.SetupGet(x => x.ContentRootPath).Returns(root);
|
||||
var exporter = new PlaywrightCvPdfExporter(new AppPaths(config, environment.Object), NullLogger<PlaywrightCvPdfExporter>.Instance, config);
|
||||
var exporter = new PlaywrightCvPdfExporter(new AppPaths(config, environment.Object), NullLogger<PlaywrightCvPdfExporter>.Instance, config,
|
||||
new PdfRenderAdmission(Options.Create(new PdfRenderOptions()), NullLogger<PdfRenderAdmission>.Instance), Mock.Of<IHostApplicationLifetime>());
|
||||
var prune = typeof(PlaywrightCvPdfExporter).GetMethod("PruneExpiredExports", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
|
||||
prune.Invoke(exporter, [new DateOnly(2026, 7, 1)]);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Net;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class ForwardedIngressTests
|
||||
{
|
||||
private static async Task<DefaultHttpContext> Forward(string peer, string forwardedFor, string proto = "https")
|
||||
{
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Proxy:KnownNetworks:0"] = "172.31.250.0/29",
|
||||
}).Build();
|
||||
var context = new DefaultHttpContext();
|
||||
context.Connection.RemoteIpAddress = IPAddress.Parse(peer);
|
||||
context.Request.Scheme = "http";
|
||||
context.Request.Host = new HostString("jobs.example.test");
|
||||
context.Request.Headers["X-Forwarded-For"] = forwardedFor;
|
||||
context.Request.Headers["X-Forwarded-Proto"] = proto;
|
||||
context.Request.Headers["X-Forwarded-Host"] = "attacker.example.test";
|
||||
context.Request.Headers["Forwarded"] = "for=203.0.113.99;proto=https;host=attacker.example.test";
|
||||
var middleware = new ForwardedHeadersMiddleware(_ => Task.CompletedTask, NullLoggerFactory.Instance,
|
||||
Options.Create(ForwardedProxyConfiguration.Build(config)));
|
||||
await middleware.Invoke(context);
|
||||
Assert.Equal("jobs.example.test", context.Request.Host.Value);
|
||||
return context;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("172.31.250.2", "198.51.100.10")]
|
||||
[InlineData("172.31.250.2", "198.51.100.11")]
|
||||
[InlineData("::ffff:172.31.250.2", "2001:db8::1")]
|
||||
public async Task Trusted_nginx_peer_resolves_client_and_scheme_but_never_forwarded_host(string peer, string client)
|
||||
{
|
||||
var context = await Forward(peer, client);
|
||||
Assert.Equal(IPAddress.Parse(client), context.Connection.RemoteIpAddress);
|
||||
Assert.Equal("https", context.Request.Scheme);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("198.51.100.20")]
|
||||
[InlineData("172.31.251.2")]
|
||||
[InlineData("::ffff:198.51.100.20")]
|
||||
[InlineData("2001:db8::20")]
|
||||
public async Task Untrusted_socket_peer_cannot_forge_IP_scheme_or_host(string peer)
|
||||
{
|
||||
var context = await Forward(peer, "203.0.113.99");
|
||||
Assert.Equal(IPAddress.Parse(peer), context.Connection.RemoteIpAddress);
|
||||
Assert.Equal("http", context.Request.Scheme);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-an-ip")]
|
||||
[InlineData("999.999.999.999")]
|
||||
[InlineData("198.51.100.1, invalid")]
|
||||
public async Task Invalid_rightmost_address_cannot_manufacture_a_client(string input)
|
||||
{
|
||||
var context = await Forward("172.31.250.2", input);
|
||||
Assert.Equal(IPAddress.Parse("172.31.250.2"), context.Connection.RemoteIpAddress);
|
||||
Assert.Equal("http", context.Request.Scheme);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Backend_defence_consumes_only_one_hop_even_if_nginx_contract_is_broken()
|
||||
{
|
||||
var context = await Forward("172.31.250.2", "203.0.113.99, 198.51.100.10");
|
||||
Assert.Equal(IPAddress.Parse("198.51.100.10"), context.Connection.RemoteIpAddress);
|
||||
Assert.Equal("203.0.113.99", context.Request.Headers["X-Forwarded-For"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0.0.0.0/0")]
|
||||
[InlineData("::/0")]
|
||||
public void Trusting_the_entire_internet_fails_configuration(string value)
|
||||
{
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Proxy:KnownNetworks:0"] = value,
|
||||
}).Build();
|
||||
Assert.Throws<InvalidOperationException>(() => ForwardedProxyConfiguration.Build(config));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
using System.IO.Compression;
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Authentication;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using JobTrackerApi.Services.JobImport;
|
||||
using JobTrackerApi.Services.JobImport.Translation;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class JobImportHttpTransportTests
|
||||
{
|
||||
private const string PublicIp = "93.184.216.34";
|
||||
private const string JobHtml = """
|
||||
<html><script type="application/ld+json">
|
||||
{"@type":"JobPosting","title":"Engineer","description":"Build useful software.","hiringOrganization":{"name":"Example"}}
|
||||
</script></html>
|
||||
""";
|
||||
|
||||
public static TheoryData<string> DeniedAddresses => new()
|
||||
{
|
||||
"0.0.0.0", "0.1.2.3", "127.0.0.1", "127.99.1.2", "10.1.2.3",
|
||||
"172.16.0.1", "172.31.255.255", "192.168.1.1", "169.254.169.254",
|
||||
"100.64.0.1", "100.127.255.255", "198.18.0.1", "198.19.255.255",
|
||||
"224.0.0.1", "239.255.255.255", "240.0.0.1", "255.255.255.255",
|
||||
"::", "::1", "fe80::1", "febf::1", "fec0::1", "fc00::1", "fdff::1", "ff02::1",
|
||||
"2001:0000:4136:e378:8000:63bf:3fff:fdd2", // Teredo
|
||||
"::ffff:10.1.2.3", "::ffff:a01:203", "::ffff:127.0.0.1", "::ffff:169.254.169.254",
|
||||
"::ffff:192.168.1.1", "::ffff:0.0.0.0", "::ffff:224.0.0.1",
|
||||
"::10.1.2.3", "64:ff9b::a01:203", "64:ff9b:1::a01:203", "2002:0a01:0203::1",
|
||||
"2606:4700:4700::1111%3"
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(DeniedAddresses))]
|
||||
public async Task Handler_rejects_denied_dns_addresses_before_any_socket_attempt(string address)
|
||||
{
|
||||
var resolver = Resolver(address);
|
||||
var attempts = 0;
|
||||
using var handler = JobImportHttpTransport.CreateHandler(resolver.Object, (_, _) =>
|
||||
{
|
||||
attempts++;
|
||||
throw new InvalidOperationException("Denied destination reached the socket boundary.");
|
||||
});
|
||||
using var client = new HttpClient(handler);
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync("http://jobs.example.test/job"));
|
||||
|
||||
Assert.Equal(0, attempts);
|
||||
resolver.Verify(x => x.ResolveAsync("jobs.example.test", It.IsAny<CancellationToken>()), Times.Once);
|
||||
resolver.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("http://127.0.0.1/")]
|
||||
[InlineData("http://2130706433/")]
|
||||
[InlineData("http://0x7f000001/")]
|
||||
[InlineData("http://0177.0.0.1/")]
|
||||
[InlineData("http://[::1]/")]
|
||||
[InlineData("http://[::ffff:a01:203]/")]
|
||||
[InlineData("http://[::ffff:169.254.169.254]/")]
|
||||
[InlineData("http://[fd00::1]/")]
|
||||
public async Task Handler_rejects_literal_without_dns_or_dial(string url)
|
||||
{
|
||||
var resolver = new Mock<IHostAddressResolver>(MockBehavior.Strict);
|
||||
var attempts = 0;
|
||||
using var handler = JobImportHttpTransport.CreateHandler(resolver.Object, (_, _) =>
|
||||
{
|
||||
attempts++;
|
||||
throw new InvalidOperationException("Unexpected dial.");
|
||||
});
|
||||
using var client = new HttpClient(handler);
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync(url));
|
||||
|
||||
Assert.Equal(0, attempts);
|
||||
resolver.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Production_dial_path_cannot_reach_a_loopback_listener()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
using var handler = JobImportHttpTransport.CreateHandler(Resolver("127.0.0.1").Object);
|
||||
using var client = new HttpClient(handler);
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync($"http://jobs.example.test:{port}/"));
|
||||
|
||||
Assert.False(listener.Pending());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Empty_or_mixed_dns_answers_fail_closed(bool mixed)
|
||||
{
|
||||
var resolver = mixed ? Resolver(PublicIp, "::ffff:10.1.2.3") : Resolver();
|
||||
var attempts = 0;
|
||||
using var handler = JobImportHttpTransport.CreateHandler(resolver.Object, (_, _) =>
|
||||
{
|
||||
attempts++;
|
||||
throw new SocketException();
|
||||
});
|
||||
using var client = new HttpClient(handler);
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync("http://jobs.example.test/"));
|
||||
|
||||
Assert.Equal(0, attempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dns_failure_fails_closed_without_fallback_resolution()
|
||||
{
|
||||
var resolver = new Mock<IHostAddressResolver>(MockBehavior.Strict);
|
||||
resolver.Setup(x => x.ResolveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new SocketException((int)SocketError.HostNotFound));
|
||||
var attempts = 0;
|
||||
using var handler = JobImportHttpTransport.CreateHandler(resolver.Object, (_, _) =>
|
||||
{
|
||||
attempts++;
|
||||
throw new SocketException();
|
||||
});
|
||||
using var client = new HttpClient(handler);
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync("http://jobs.example.test/"));
|
||||
|
||||
Assert.Equal(0, attempts);
|
||||
resolver.Verify(x => x.ResolveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("10.1.2.3")]
|
||||
[InlineData("::ffff:169.254.169.254")]
|
||||
[InlineData("fd00::1")]
|
||||
public async Task Preview_rechecks_changed_dns_at_connection_and_never_dials_private_peer(string nextAddress)
|
||||
{
|
||||
var resolver = new Mock<IHostAddressResolver>(MockBehavior.Strict);
|
||||
resolver.SetupSequence(x => x.ResolveAsync("jobs.example.test", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([IPAddress.Parse(PublicIp)])
|
||||
.ReturnsAsync([IPAddress.Parse(nextAddress)]);
|
||||
var attempts = 0;
|
||||
using var handler = JobImportHttpTransport.CreateHandler(resolver.Object, (_, _) =>
|
||||
{
|
||||
attempts++;
|
||||
throw new SocketException();
|
||||
});
|
||||
using var client = new HttpClient(handler);
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => Service(client, resolver.Object)
|
||||
.PreviewAsync("http://jobs.example.test/job", CancellationToken.None));
|
||||
|
||||
Assert.Equal(0, attempts);
|
||||
resolver.Verify(x => x.ResolveAsync("jobs.example.test", It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("jobs.example.test", PublicIp)]
|
||||
[InlineData("jobs.example.test", "2606:4700:4700::1111")]
|
||||
[InlineData("jobs.example.test", "::ffff:93.184.216.34")]
|
||||
[InlineData("93.184.216.34", PublicIp)]
|
||||
[InlineData("[::ffff:93.184.216.34]", PublicIp)]
|
||||
public async Task Public_http_imports_keep_the_original_host_and_parse(string host, string resolvedAddress)
|
||||
{
|
||||
await using var server = new LocalServer((stream, _) => Respond(stream, JobHtml));
|
||||
var resolver = Resolver(resolvedAddress);
|
||||
using var handler = JobImportHttpTransport.CreateHandler(resolver.Object, server.DialAsync);
|
||||
using var client = new HttpClient(handler);
|
||||
|
||||
var result = await Service(client, resolver.Object)
|
||||
.PreviewAsync($"http://{host}:{server.Port}/job", CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success, result.Error);
|
||||
Assert.Equal("Engineer", result.Title);
|
||||
Assert.Contains($"Host: {new Uri($"http://{host}:{server.Port}/").Host}:{server.Port}", server.Request);
|
||||
Assert.Equal(IPAddress.Parse(resolvedAddress).IsIPv4MappedToIPv6
|
||||
? IPAddress.Parse(resolvedAddress).MapToIPv4() : IPAddress.Parse(resolvedAddress), Assert.Single(server.Attempts).Address);
|
||||
Assert.False(handler.UseProxy);
|
||||
Assert.False(handler.AllowAutoRedirect);
|
||||
Assert.Null(handler.SslOptions.RemoteCertificateValidationCallback);
|
||||
Assert.Equal(TimeSpan.FromSeconds(100), client.Timeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connection_uses_only_its_checked_answer_even_if_dns_would_change_again()
|
||||
{
|
||||
await using var server = new LocalServer((stream, _) => Respond(stream, JobHtml));
|
||||
var resolver = new Mock<IHostAddressResolver>(MockBehavior.Strict);
|
||||
resolver.SetupSequence(x => x.ResolveAsync("jobs.example.test", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([IPAddress.Parse("1.1.1.1")])
|
||||
.ReturnsAsync([IPAddress.Parse(PublicIp)])
|
||||
.ReturnsAsync([IPAddress.Loopback]);
|
||||
using var handler = JobImportHttpTransport.CreateHandler(resolver.Object, server.DialAsync);
|
||||
using var client = new HttpClient(handler);
|
||||
|
||||
var result = await Service(client, resolver.Object).PreviewAsync($"http://jobs.example.test:{server.Port}/job", default);
|
||||
|
||||
Assert.True(result.Success, result.Error);
|
||||
Assert.Equal(IPAddress.Parse(PublicIp), Assert.Single(server.Attempts).Address);
|
||||
resolver.Verify(x => x.ResolveAsync("jobs.example.test", It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failed_public_address_falls_back_only_within_the_checked_answer()
|
||||
{
|
||||
await using var server = new LocalServer((stream, _) => Respond(stream, JobHtml));
|
||||
var resolver = Resolver("2606:4700:4700::1111", PublicIp);
|
||||
var attempts = new List<IPEndPoint>();
|
||||
using var handler = JobImportHttpTransport.CreateHandler(resolver.Object, (endpoint, ct) =>
|
||||
{
|
||||
attempts.Add(endpoint);
|
||||
if (attempts.Count == 1) throw new SocketException((int)SocketError.NetworkUnreachable);
|
||||
return server.DialAsync(endpoint, ct);
|
||||
});
|
||||
using var client = new HttpClient(handler);
|
||||
|
||||
using var response = await client.GetAsync($"http://jobs.example.test:{server.Port}/job");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(2, attempts.Count);
|
||||
Assert.Equal(IPAddress.Parse(PublicIp), attempts[1].Address);
|
||||
resolver.Verify(x => x.ResolveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_new_connection_on_the_same_handler_revalidates_dns()
|
||||
{
|
||||
await using var server = new LocalServer((stream, _) => Respond(stream, JobHtml));
|
||||
var resolver = new Mock<IHostAddressResolver>(MockBehavior.Strict);
|
||||
resolver.SetupSequence(x => x.ResolveAsync("jobs.example.test", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([IPAddress.Parse(PublicIp)])
|
||||
.ReturnsAsync([IPAddress.Parse("::ffff:10.1.2.3")]);
|
||||
using var handler = JobImportHttpTransport.CreateHandler(resolver.Object, server.DialAsync);
|
||||
using var client = new HttpClient(handler);
|
||||
var url = $"http://jobs.example.test:{server.Port}/job";
|
||||
|
||||
Assert.Contains("Engineer", await client.GetStringAsync(url)); // Server closes its connection.
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => client.GetStringAsync(url));
|
||||
|
||||
Assert.Single(server.Attempts);
|
||||
resolver.Verify(x => x.ResolveAsync("jobs.example.test", It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Proxy_configuration_cannot_create_an_unchecked_origin_connection()
|
||||
{
|
||||
using var proxyTrap = new TcpListener(IPAddress.Loopback, 0);
|
||||
proxyTrap.Start();
|
||||
await using var server = new LocalServer((stream, _) => Respond(stream, JobHtml));
|
||||
var resolver = Resolver(PublicIp);
|
||||
using var handler = JobImportHttpTransport.CreateHandler(resolver.Object, server.DialAsync);
|
||||
handler.Proxy = new WebProxy($"http://127.0.0.1:{((IPEndPoint)proxyTrap.LocalEndpoint).Port}");
|
||||
using var client = new HttpClient(handler);
|
||||
|
||||
Assert.Contains("Engineer", await client.GetStringAsync($"http://jobs.example.test:{server.Port}/job"));
|
||||
|
||||
Assert.False(proxyTrap.Pending());
|
||||
Assert.Equal(IPAddress.Parse(PublicIp), Assert.Single(server.Attempts).Address);
|
||||
resolver.Verify(x => x.ResolveAsync("jobs.example.test", It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(301)]
|
||||
[InlineData(302)]
|
||||
[InlineData(303)]
|
||||
[InlineData(307)]
|
||||
[InlineData(308)]
|
||||
public async Task Redirect_to_private_destination_is_returned_as_fetch_failure_without_following(int status)
|
||||
{
|
||||
await using var server = new LocalServer((stream, _) => Write(stream,
|
||||
$"HTTP/1.1 {status} Redirect\r\nLocation: http://[::ffff:169.254.169.254]/secret\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"));
|
||||
var resolver = Resolver(PublicIp);
|
||||
using var handler = JobImportHttpTransport.CreateHandler(resolver.Object, server.DialAsync);
|
||||
using var client = new HttpClient(handler);
|
||||
|
||||
var result = await Service(client, resolver.Object).PreviewAsync($"http://jobs.example.test:{server.Port}/job", default);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("fetch", result.Parser);
|
||||
Assert.Single(server.Attempts);
|
||||
resolver.Verify(x => x.ResolveAsync("jobs.example.test", It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
resolver.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("jobs.example.test", true, true)]
|
||||
[InlineData("other.example.test", true, false)]
|
||||
[InlineData("jobs.example.test", false, false)]
|
||||
public async Task Https_keeps_sni_and_requires_trusted_certificate_for_original_hostname(string certificateHost, bool trustRoot, bool succeeds)
|
||||
{
|
||||
using var certificates = new TestCertificates(certificateHost);
|
||||
string? sni = null;
|
||||
await using var server = new LocalServer((stream, _) => Respond(stream, JobHtml), async (stream, ct) =>
|
||||
{
|
||||
var tls = new SslStream(stream);
|
||||
try
|
||||
{
|
||||
await tls.AuthenticateAsServerAsync(new SslServerAuthenticationOptions
|
||||
{
|
||||
ServerCertificateSelectionCallback = (_, host) => { sni = host; return certificates.Server; },
|
||||
EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13
|
||||
}, ct);
|
||||
return tls;
|
||||
}
|
||||
catch { await tls.DisposeAsync(); throw; }
|
||||
});
|
||||
var resolver = Resolver(PublicIp);
|
||||
using var handler = JobImportHttpTransport.CreateHandler(resolver.Object, server.DialAsync);
|
||||
if (trustRoot)
|
||||
{
|
||||
// Trust only this fixture CA without changing OS trust or bypassing hostname validation.
|
||||
handler.SslOptions.CertificateChainPolicy = new X509ChainPolicy
|
||||
{
|
||||
TrustMode = X509ChainTrustMode.CustomRootTrust,
|
||||
RevocationMode = X509RevocationMode.NoCheck
|
||||
};
|
||||
handler.SslOptions.CertificateChainPolicy.CustomTrustStore.Add(certificates.Root);
|
||||
}
|
||||
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) };
|
||||
var request = () => Service(client, resolver.Object).PreviewAsync($"https://jobs.example.test:{server.Port}/job", default);
|
||||
|
||||
if (succeeds)
|
||||
{
|
||||
var result = await request();
|
||||
Assert.True(result.Success, result.Error);
|
||||
Assert.Contains($"Host: jobs.example.test:{server.Port}", server.Request);
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = await Assert.ThrowsAsync<HttpRequestException>(request);
|
||||
Assert.IsType<AuthenticationException>(error.InnerException);
|
||||
Assert.Null(server.Request); // No HTTP data sent before TLS authenticates the origin.
|
||||
}
|
||||
Assert.Equal("jobs.example.test", sni);
|
||||
Assert.Null(handler.SslOptions.RemoteCertificateValidationCallback);
|
||||
Assert.Equal(IPAddress.Parse(PublicIp), Assert.Single(server.Attempts).Address);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("dns")]
|
||||
[InlineData("connect")]
|
||||
[InlineData("headers")]
|
||||
public async Task Http_client_timeout_cancels_resolution_connect_and_headers(string phase)
|
||||
{
|
||||
await using var server = new LocalServer((_, ct) => Task.Delay(Timeout.Infinite, ct));
|
||||
var resolver = Resolver(PublicIp);
|
||||
if (phase == "dns")
|
||||
resolver.Setup(x => x.ResolveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (string _, CancellationToken ct) => { await Task.Delay(Timeout.Infinite, ct); return Array.Empty<IPAddress>(); });
|
||||
using var handler = JobImportHttpTransport.CreateHandler(resolver.Object, async (endpoint, ct) =>
|
||||
{
|
||||
if (phase == "connect") await Task.Delay(Timeout.Infinite, ct);
|
||||
return await server.DialAsync(endpoint, ct);
|
||||
});
|
||||
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(300) };
|
||||
|
||||
var error = await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
client.GetAsync($"http://jobs.example.test:{server.Port}/job").WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
|
||||
Assert.IsType<TimeoutException>(error.InnerException);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Caller_cancellation_interrupts_a_stalled_streamed_body()
|
||||
{
|
||||
var bodyStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
await using var server = new LocalServer(async (stream, ct) =>
|
||||
{
|
||||
await Write(stream, "HTTP/1.1 200 OK\r\nContent-Length: 100\r\nConnection: close\r\n\r\n");
|
||||
bodyStarted.SetResult();
|
||||
await Task.Delay(Timeout.Infinite, ct);
|
||||
});
|
||||
var resolver = Resolver(PublicIp);
|
||||
using var handler = JobImportHttpTransport.CreateHandler(resolver.Object, server.DialAsync);
|
||||
using var client = new HttpClient(handler);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
|
||||
var preview = Service(client, resolver.Object).PreviewAsync($"http://jobs.example.test:{server.Port}/job", cancellation.Token);
|
||||
await bodyStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
cancellation.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => preview.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Size_limit_rejects_oversized_declared_and_decompressed_responses(bool compressed)
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes(new string('x', 4_000_001));
|
||||
if (compressed)
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
using (var gzip = new GZipStream(buffer, CompressionLevel.Fastest, leaveOpen: true)) gzip.Write(payload);
|
||||
payload = buffer.ToArray();
|
||||
}
|
||||
await using var server = new LocalServer(async (stream, _) =>
|
||||
{
|
||||
await Write(stream, $"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {payload.Length}\r\n" +
|
||||
(compressed ? "Content-Encoding: gzip\r\n" : "") + "Connection: close\r\n\r\n");
|
||||
if (compressed) await stream.WriteAsync(payload);
|
||||
});
|
||||
var resolver = Resolver(PublicIp);
|
||||
using var handler = JobImportHttpTransport.CreateHandler(resolver.Object, server.DialAsync);
|
||||
using var client = new HttpClient(handler);
|
||||
|
||||
var result = await Service(client, resolver.Object).PreviewAsync($"http://jobs.example.test:{server.Port}/job", default);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("fetch", result.Parser);
|
||||
}
|
||||
|
||||
private static Mock<IHostAddressResolver> Resolver(params string[] addresses)
|
||||
{
|
||||
var resolver = new Mock<IHostAddressResolver>(MockBehavior.Strict);
|
||||
resolver.Setup(x => x.ResolveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(addresses.Select(IPAddress.Parse).ToArray());
|
||||
return resolver;
|
||||
}
|
||||
|
||||
private static JobImportService Service(HttpClient client, IHostAddressResolver resolver)
|
||||
{
|
||||
var factory = new Mock<IHttpClientFactory>();
|
||||
factory.Setup(x => x.CreateClient("jobimport")).Returns(client);
|
||||
return new JobImportService(factory.Object, new UniversalJobParser(), [], new NoOpTranslationService(), resolver);
|
||||
}
|
||||
|
||||
private static Task Respond(Stream stream, string body) => Write(stream,
|
||||
$"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {Encoding.UTF8.GetByteCount(body)}\r\nConnection: close\r\n\r\n{body}");
|
||||
|
||||
private static Task Write(Stream stream, string text) => stream.WriteAsync(Encoding.UTF8.GetBytes(text)).AsTask();
|
||||
|
||||
// Only this test dialer substitutes a loopback peer AFTER the production callback approves
|
||||
// an IP. No test performs public DNS/network I/O or makes private IPs allowed in production.
|
||||
private sealed class LocalServer : IAsyncDisposable
|
||||
{
|
||||
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
|
||||
private readonly CancellationTokenSource _stop = new(TimeSpan.FromSeconds(10));
|
||||
private readonly Task _serve;
|
||||
internal int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
|
||||
internal string? Request { get; private set; }
|
||||
internal List<IPEndPoint> Attempts { get; } = [];
|
||||
|
||||
internal LocalServer(Func<Stream, CancellationToken, Task> respond, Func<Stream, CancellationToken, Task<Stream>>? secure = null)
|
||||
{
|
||||
_listener.Start();
|
||||
_serve = ServeAsync(respond, secure);
|
||||
}
|
||||
|
||||
internal ValueTask<Stream> DialAsync(IPEndPoint approved, CancellationToken ct)
|
||||
{
|
||||
Assert.False(JobImportHttpTransport.IsBlockedAddress(approved.Address));
|
||||
Assert.Equal(Port, approved.Port);
|
||||
Attempts.Add(approved);
|
||||
return JobImportHttpTransport.ConnectSocketAsync(new IPEndPoint(IPAddress.Loopback, Port), ct);
|
||||
}
|
||||
|
||||
private async Task ServeAsync(Func<Stream, CancellationToken, Task> respond, Func<Stream, CancellationToken, Task<Stream>>? secure)
|
||||
{
|
||||
using var socket = await _listener.AcceptTcpClientAsync(_stop.Token);
|
||||
await using var stream = secure is null ? socket.GetStream() : await secure(socket.GetStream(), _stop.Token);
|
||||
using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true);
|
||||
var headers = new StringBuilder();
|
||||
while (await reader.ReadLineAsync(_stop.Token) is { Length: > 0 } line) headers.AppendLine(line);
|
||||
Request = headers.ToString();
|
||||
await respond(stream, _stop.Token);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _stop.CancelAsync();
|
||||
_listener.Stop();
|
||||
try { await _serve; }
|
||||
catch (OperationCanceledException) { }
|
||||
catch (IOException) { } // Client may reject TLS or stop reading at its byte limit.
|
||||
catch (AuthenticationException) { }
|
||||
finally { _stop.Dispose(); }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestCertificates : IDisposable
|
||||
{
|
||||
internal X509Certificate2 Root { get; }
|
||||
internal X509Certificate2 Server { get; }
|
||||
|
||||
internal TestCertificates(string hostname)
|
||||
{
|
||||
using var rootKey = RSA.Create(2048);
|
||||
var root = new CertificateRequest("CN=B01 isolated test CA", rootKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
root.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
||||
root.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign, true));
|
||||
Root = root.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1));
|
||||
using var serverKey = RSA.Create(2048);
|
||||
var server = new CertificateRequest($"CN={hostname}", serverKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
var san = new SubjectAlternativeNameBuilder();
|
||||
san.AddDnsName(hostname);
|
||||
server.CertificateExtensions.Add(san.Build());
|
||||
server.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection { new("1.3.6.1.5.5.7.3.1") }, true));
|
||||
using var signed = server.Create(Root, DateTimeOffset.UtcNow.AddHours(-1), DateTimeOffset.UtcNow.AddHours(1), RandomNumberGenerator.GetBytes(16));
|
||||
using var withKey = signed.CopyWithPrivateKey(serverKey);
|
||||
// Schannel needs a persisted private key for a local TLS server on Windows.
|
||||
Server = X509CertificateLoader.LoadPkcs12(withKey.Export(X509ContentType.Pfx), null);
|
||||
}
|
||||
|
||||
public void Dispose() { Server.Dispose(); Root.Dispose(); }
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,38 @@ namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class JobImportServiceTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("http://10.1.2.3/")]
|
||||
[InlineData("http://[::ffff:10.1.2.3]/")]
|
||||
[InlineData("http://169.254.169.254/")]
|
||||
[InlineData("http://[::ffff:169.254.169.254]/")]
|
||||
[InlineData("http://192.168.1.1/")]
|
||||
[InlineData("http://[::ffff:192.168.1.1]/")]
|
||||
[InlineData("http://[::ffff:a01:203]/")]
|
||||
[InlineData("http://[::ffff:127.0.0.1]/")]
|
||||
[InlineData("http://2130706433/")]
|
||||
[InlineData("http://0x7f000001/")]
|
||||
[InlineData("http://0177.0.0.1/")]
|
||||
[InlineData("http://127.1/")]
|
||||
public async Task Preview_rejects_equivalent_private_literals_before_http_send(string url)
|
||||
{
|
||||
var resolver = new Mock<IHostAddressResolver>(MockBehavior.Strict);
|
||||
var sends = 0;
|
||||
var handler = new StubHttpMessageHandler(_ =>
|
||||
{
|
||||
sends++;
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("<html></html>") };
|
||||
});
|
||||
|
||||
var result = await CreateService(resolver.Object, handler).PreviewAsync(url, CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, sends);
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("none", result.Parser);
|
||||
Assert.Equal("Local or private network URLs are not allowed.", result.Error);
|
||||
resolver.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Preview_rejects_hostname_that_resolves_to_loopback()
|
||||
{
|
||||
|
||||
@@ -22,5 +22,14 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\JobTrackerApi\JobTrackerApi.csproj" />
|
||||
<ProjectReference Include="PdfBrowserStub/PdfBrowserStub.csproj" ReferenceOutputAssembly="false" />
|
||||
<Compile Remove="PdfBrowserStub/**/*.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyPdfBrowserStub" AfterTargets="Build">
|
||||
<ItemGroup>
|
||||
<PdfBrowserFiles Include="PdfBrowserStub/bin/$(Configuration)/net9.0/*" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(PdfBrowserFiles)" DestinationFolder="$(TargetDir)PdfBrowserStub" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,25 @@
|
||||
// Inert process fixture: no browser, HTML execution, network, or production data.
|
||||
var root = Environment.GetEnvironmentVariable("PDF_STUB_ROOT")!;
|
||||
var output = args.Single(arg => arg.StartsWith("--print-to-pdf=", StringComparison.Ordinal))[15..];
|
||||
var input = new Uri(args[^1]).LocalPath;
|
||||
var mode = await File.ReadAllTextAsync(File.Exists(Path.Combine(root, "mode")) ? Path.Combine(root, "mode") : input);
|
||||
await File.WriteAllTextAsync(Path.Combine(root, $"{Environment.ProcessId}.started"), input);
|
||||
if (!mode.Contains("no-output")) await File.WriteAllTextAsync(output, "%PDF-partial");
|
||||
if (mode.Contains("hold"))
|
||||
{
|
||||
// Bound the fixture itself if its parent test runner is forcibly terminated.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(30);
|
||||
while (!File.Exists(Path.Combine(root, "release")))
|
||||
{
|
||||
if (DateTime.UtcNow >= deadline) return 3;
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
if (mode.Contains("logs"))
|
||||
{
|
||||
await Console.Out.WriteAsync(new string('o', 100_000));
|
||||
await Console.Error.WriteAsync(new string('e', 100_000));
|
||||
}
|
||||
if (mode.Contains("fail")) return 2;
|
||||
if (!mode.Contains("no-output")) await File.WriteAllTextAsync(output, "%PDF-synthetic-success");
|
||||
return 0;
|
||||
@@ -0,0 +1,304 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
[CollectionDefinition("PDF process environment", DisableParallelization = true)]
|
||||
public sealed class PdfProcessCollection;
|
||||
|
||||
[Collection("PDF process environment")]
|
||||
public sealed class PdfRenderAdmissionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Default_limit_refuses_second_actual_browser_until_first_exits()
|
||||
{
|
||||
using var fixture = new Fixture();
|
||||
var first = fixture.Exporter.ExportAsync("owner-a", Render("hold"), default);
|
||||
try
|
||||
{
|
||||
await fixture.WaitForStarts(1);
|
||||
var excess = fixture.Exporter.ExportAsync("owner-b", Render("hold"), default);
|
||||
// Assert the real process boundary, not just a mocked semaphore.
|
||||
await Assert.ThrowsAsync<PdfCapacityException>(() => excess);
|
||||
Assert.True(excess.IsFaulted, $"An excess export must be rejected; actual browser launches: {fixture.Started().Length}.");
|
||||
Assert.Single(fixture.Started());
|
||||
}
|
||||
finally
|
||||
{
|
||||
fixture.Release();
|
||||
await first;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Two_exporter_instances_share_global_and_owner_capacity_without_waiting()
|
||||
{
|
||||
using var fixture = new Fixture(limit: 2);
|
||||
var other = fixture.CreateExporter();
|
||||
var a = fixture.Exporter.ExportAsync("a", Render("hold"), default);
|
||||
await fixture.WaitForStarts(1);
|
||||
await Assert.ThrowsAsync<PdfCapacityException>(() => other.ExportAsync("a", Render("hold"), default));
|
||||
var b = other.ExportAsync("b", Render("hold"), default);
|
||||
try
|
||||
{
|
||||
await fixture.WaitForStarts(2);
|
||||
for (var i = 0; i < 10; i++)
|
||||
await Assert.ThrowsAsync<PdfCapacityException>(() => other.ExportAsync($"c-{i}", Render("hold"), default));
|
||||
Assert.Equal(2, fixture.Started().Length);
|
||||
}
|
||||
finally { fixture.Release(); await Task.WhenAll(a, b); }
|
||||
fixture.AssertProcessesExited();
|
||||
Assert.StartsWith("%PDF-", System.Text.Encoding.UTF8.GetString((await other.ExportAsync("a", Render("success"), default)).Bytes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancelled_admission_has_no_queue_or_late_launch()
|
||||
{
|
||||
using var fixture = new Fixture();
|
||||
var a = fixture.Exporter.ExportAsync("a", Render("hold"), default);
|
||||
await fixture.WaitForStarts(1);
|
||||
using var cancelled = new CancellationTokenSource();
|
||||
cancelled.Cancel();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => fixture.Exporter.ExportAsync("b", Render("success"), cancelled.Token));
|
||||
fixture.Release();
|
||||
await a;
|
||||
Assert.Single(fixture.Started());
|
||||
await fixture.Exporter.ExportAsync("b", Render("success"), default);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("fail")]
|
||||
[InlineData("no-output")]
|
||||
[InlineData("cancel")]
|
||||
[InlineData("timeout")]
|
||||
public async Task Repeated_failures_restore_all_slots_and_remove_partial_outputs(string failure)
|
||||
{
|
||||
using var fixture = new Fixture(limit: 2, timeout: 1);
|
||||
for (var cycle = 0; cycle < 3; cycle++)
|
||||
{
|
||||
using var cancel = new CancellationTokenSource();
|
||||
var mode = failure is "cancel" or "timeout" ? "hold" : failure;
|
||||
var tasks = new[] { fixture.Exporter.ExportAsync("a", Render(mode), cancel.Token), fixture.Exporter.ExportAsync("b", Render(mode), cancel.Token) };
|
||||
await fixture.WaitForStarts((cycle + 1) * 2);
|
||||
if (failure == "cancel") cancel.Cancel();
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
if (failure == "cancel") await Assert.ThrowsAnyAsync<OperationCanceledException>(() => task);
|
||||
else if (failure == "timeout") await Assert.ThrowsAsync<PdfRenderTimeoutException>(() => task);
|
||||
else await Assert.ThrowsAsync<InvalidOperationException>(() => task);
|
||||
}
|
||||
fixture.AssertProcessesExited();
|
||||
Assert.Empty(Directory.GetFiles(fixture.Paths.CvExportsRoot, "*.pdf", SearchOption.AllDirectories));
|
||||
foreach (var started in fixture.Started()) Assert.False(Directory.Exists(Path.GetDirectoryName(File.ReadAllText(started))));
|
||||
}
|
||||
var next = await Task.WhenAll(fixture.Exporter.ExportAsync("a", Render("success"), default), fixture.Exporter.ExportAsync("b", Render("success"), default));
|
||||
Assert.All(next, artifact => Assert.True(artifact.Bytes.Length > 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Launch_exception_does_not_consume_capacity()
|
||||
{
|
||||
using var fixture = new Fixture();
|
||||
var browser = Environment.GetEnvironmentVariable("CV_PDF_BROWSER_PATH");
|
||||
var invalid = Path.Combine(fixture.Root, "not-an-executable");
|
||||
File.WriteAllText(invalid, "inert");
|
||||
Environment.SetEnvironmentVariable("CV_PDF_BROWSER_PATH", invalid);
|
||||
await Assert.ThrowsAsync<System.ComponentModel.Win32Exception>(() => fixture.Exporter.ExportAsync("a", Render("success"), default));
|
||||
Environment.SetEnvironmentVariable("CV_PDF_BROWSER_PATH", browser);
|
||||
await fixture.Exporter.ExportAsync("a", Render("success"), default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Browser_output_pipes_are_drained_without_deadlock()
|
||||
{
|
||||
using var fixture = new Fixture(timeout: 5);
|
||||
var result = await fixture.Exporter.ExportAsync("a", Render("logs"), default);
|
||||
Assert.True(result.Bytes.Length > 0);
|
||||
fixture.AssertProcessesExited();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Shutdown_cancels_active_process_and_rejects_new_work()
|
||||
{
|
||||
using var fixture = new Fixture();
|
||||
var active = fixture.Exporter.ExportAsync("a", Render("hold"), default);
|
||||
await fixture.WaitForStarts(1);
|
||||
fixture.Stopping.Cancel();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => active);
|
||||
fixture.AssertProcessesExited();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => fixture.Exporter.ExportAsync("b", Render("success"), default));
|
||||
Assert.Single(fixture.Started());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("MaxConcurrentRenders", "0")]
|
||||
[InlineData("MaxConcurrentRenders", "-1")]
|
||||
[InlineData("MaxConcurrentRenders", "9")]
|
||||
[InlineData("MaxConcurrentRenders", "not-a-number")]
|
||||
[InlineData("RenderTimeoutSeconds", "0")]
|
||||
[InlineData("RenderTimeoutSeconds", "-1")]
|
||||
[InlineData("RenderTimeoutSeconds", "61")]
|
||||
public async Task Invalid_configuration_prevents_host_start(string name, string value)
|
||||
{
|
||||
var builder = Host.CreateApplicationBuilder();
|
||||
builder.Configuration[$"CvExports:{name}"] = value;
|
||||
builder.Services.AddOptions<PdfRenderOptions>().BindConfiguration("CvExports").ValidateDataAnnotations().ValidateOnStart();
|
||||
using var host = builder.Build();
|
||||
var error = await Record.ExceptionAsync(() => host.StartAsync());
|
||||
Assert.True(error is OptionsValidationException or InvalidOperationException, error?.ToString() ?? "Host unexpectedly started");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unconfirmed_process_termination_disables_new_admission_even_after_lease_disposal()
|
||||
{
|
||||
var admission = new PdfRenderAdmission(Options.Create(new PdfRenderOptions()), NullLogger<PdfRenderAdmission>.Instance);
|
||||
using (admission.Enter("a", default)) admission.Disable();
|
||||
Assert.Throws<PdfCapacityException>(() => admission.Enter("b", default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failing_observability_cannot_strand_an_admission_slot()
|
||||
{
|
||||
var logger = new Mock<ILogger<PdfRenderAdmission>>();
|
||||
var broken = true;
|
||||
logger.Setup(x => x.Log(It.IsAny<LogLevel>(), It.IsAny<EventId>(), It.IsAny<It.IsAnyType>(),
|
||||
It.IsAny<Exception?>(), It.IsAny<Func<It.IsAnyType, Exception?, string>>()))
|
||||
.Callback(() => { if (broken) throw new InvalidOperationException("Fixture logging failure"); });
|
||||
var admission = new PdfRenderAdmission(Options.Create(new PdfRenderOptions()), logger.Object);
|
||||
for (var i = 0; i < 3; i++) Assert.Throws<InvalidOperationException>(() => admission.Enter("a", default));
|
||||
broken = false;
|
||||
using var lease = admission.Enter("a", default);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, 503, "pdf_capacity_exceeded")]
|
||||
[InlineData(false, 504, "pdf_render_timeout")]
|
||||
public async Task Api_errors_are_safe_deterministic_and_distinct(bool busy, int status, string code)
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Response.Body = new MemoryStream();
|
||||
Assert.True(await new PdfRenderExceptionHandler().TryHandleAsync(context, busy ? new PdfCapacityException() : new PdfRenderTimeoutException(), default));
|
||||
Assert.Equal(status, context.Response.StatusCode);
|
||||
Assert.Equal("no-store", context.Response.Headers.CacheControl);
|
||||
Assert.Equal(busy ? "3" : "", context.Response.Headers.RetryAfter.ToString());
|
||||
context.Response.Body.Position = 0;
|
||||
Assert.Contains(code, await new StreamReader(context.Response.Body).ReadToEndAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Profile_controller_uses_same_real_exporter_boundary_after_local_AI_preparation()
|
||||
{
|
||||
using var fixture = new Fixture();
|
||||
var active = fixture.Exporter.ExportAsync("other-owner", Render("hold"), default);
|
||||
await fixture.WaitForStarts(1);
|
||||
var user = new ApplicationUser { Id = "profile-owner", ProfileCvText = "## Summary\nSynthetic engineer" };
|
||||
var users = TestHostFactory.CreateUserManager(user);
|
||||
users.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
var ai = new Mock<ISummarizerService>();
|
||||
ai.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>())).ReturnsAsync("## Summary\nSynthetic engineer");
|
||||
await using var db = TestHostFactory.CreateInMemoryDb(user.Id);
|
||||
var controller = new ProfileCvController(users.Object, ai.Object, db, fixture.Paths,
|
||||
cvAiClassifier: NoOpCvAiClassifier.Instance, cvAiNormalizer: NoOpCvAiNormalizer.Instance,
|
||||
cvTemplateRenderer: new CvTemplateRenderer(), cvPdfExporter: fixture.Exporter)
|
||||
{ ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } };
|
||||
try
|
||||
{
|
||||
await Assert.ThrowsAsync<PdfCapacityException>(() => controller.ExportProfileCvPdf(new ProfileCvController.RewriteSectionRequest { SourceText = user.ProfileCvText }, default));
|
||||
Assert.Single(fixture.Started());
|
||||
}
|
||||
finally { fixture.Release(); await active; }
|
||||
Assert.IsType<FileContentResult>(await controller.ExportProfileCvPdf(new ProfileCvController.RewriteSectionRequest { SourceText = user.ProfileCvText }, default));
|
||||
}
|
||||
|
||||
private static TailoredCvRenderResult Render(string mode) => new("synthetic", "synthetic.pdf", mode);
|
||||
|
||||
private sealed class Fixture : IDisposable
|
||||
{
|
||||
public string Root { get; } = Path.Combine(Path.GetTempPath(), $"jobtracker-b03-{Guid.NewGuid():N}");
|
||||
public PlaywrightCvPdfExporter Exporter { get; }
|
||||
public AppPaths Paths { get; }
|
||||
public CancellationTokenSource Stopping { get; } = new();
|
||||
private readonly IConfiguration _config;
|
||||
private readonly PdfRenderAdmission _admission;
|
||||
private readonly string? _oldBrowser = Environment.GetEnvironmentVariable("CV_PDF_BROWSER_PATH");
|
||||
private readonly string? _oldRoot = Environment.GetEnvironmentVariable("PDF_STUB_ROOT");
|
||||
|
||||
public Fixture(int limit = 1, int timeout = 60)
|
||||
{
|
||||
Directory.CreateDirectory(Root);
|
||||
Environment.SetEnvironmentVariable("PDF_STUB_ROOT", Root);
|
||||
Environment.SetEnvironmentVariable("CV_PDF_BROWSER_PATH", Path.Combine(AppContext.BaseDirectory,
|
||||
"PdfBrowserStub", OperatingSystem.IsWindows() ? "PdfBrowserStub.exe" : "PdfBrowserStub"));
|
||||
_config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["Data:Root"] = Root }).Build();
|
||||
var environment = new Mock<IHostEnvironment>();
|
||||
environment.SetupGet(x => x.ContentRootPath).Returns(Root);
|
||||
Paths = new AppPaths(_config, environment.Object);
|
||||
_admission = new PdfRenderAdmission(Options.Create(new PdfRenderOptions { MaxConcurrentRenders = limit, RenderTimeoutSeconds = timeout }), NullLogger<PdfRenderAdmission>.Instance);
|
||||
Exporter = CreateExporter();
|
||||
}
|
||||
|
||||
public PlaywrightCvPdfExporter CreateExporter()
|
||||
{
|
||||
var lifetime = new Mock<IHostApplicationLifetime>();
|
||||
lifetime.SetupGet(x => x.ApplicationStopping).Returns(Stopping.Token);
|
||||
return new PlaywrightCvPdfExporter(Paths, NullLogger<PlaywrightCvPdfExporter>.Instance, _config, _admission, lifetime.Object);
|
||||
}
|
||||
|
||||
public void AssertProcessesExited()
|
||||
{
|
||||
foreach (var file in Started())
|
||||
{
|
||||
try
|
||||
{
|
||||
using var process = Process.GetProcessById(int.Parse(Path.GetFileNameWithoutExtension(file)));
|
||||
Assert.True(process.HasExited, "Capacity must not be returned before the child exits.");
|
||||
}
|
||||
catch (ArgumentException) { }
|
||||
}
|
||||
}
|
||||
|
||||
public string[] Started() => Directory.GetFiles(Root, "*.started");
|
||||
public async Task WaitForStarts(int count)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
while (Started().Length < count) await Task.Delay(20, timeout.Token);
|
||||
}
|
||||
public void Release() => File.WriteAllText(Path.Combine(Root, "release"), "release");
|
||||
public void Dispose()
|
||||
{
|
||||
Release();
|
||||
// Only stop fixture-owned process IDs, after checking their executable identity.
|
||||
foreach (var file in Started())
|
||||
{
|
||||
try
|
||||
{
|
||||
using var process = Process.GetProcessById(int.Parse(Path.GetFileNameWithoutExtension(file)));
|
||||
if (!process.HasExited && process.ProcessName == "PdfBrowserStub")
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
process.WaitForExit(5000);
|
||||
}
|
||||
}
|
||||
catch (ArgumentException) { }
|
||||
}
|
||||
Environment.SetEnvironmentVariable("CV_PDF_BROWSER_PATH", _oldBrowser);
|
||||
Environment.SetEnvironmentVariable("PDF_STUB_ROOT", _oldRoot);
|
||||
Stopping.Dispose();
|
||||
Directory.Delete(Root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Xunit;
|
||||
@@ -7,6 +8,23 @@ namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class RateLimitPartitionKeysTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("account-data")]
|
||||
[InlineData("email-send")]
|
||||
public void Account_requires_authenticated_identity_and_keeps_anonymous_addresses_separate(string policy)
|
||||
{
|
||||
var anonymous = Context("203.0.113.10", "irrelevant");
|
||||
anonymous.User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "unvalidated") }));
|
||||
var otherIp = Context("203.0.113.11", "irrelevant");
|
||||
Assert.Equal($"{policy}:ip:203.0.113.10", RateLimitPartitionKeys.Account(anonymous, policy));
|
||||
Assert.NotEqual(RateLimitPartitionKeys.Account(anonymous, policy), RateLimitPartitionKeys.Account(otherIp, policy));
|
||||
// Even an authenticated identifier shaped like an IP cannot collide with anonymous traffic.
|
||||
var owner = Context("203.0.113.11", "irrelevant");
|
||||
owner.User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("sub", "203.0.113.10") }, "validated"));
|
||||
Assert.Equal($"{policy}:user:203.0.113.10", RateLimitPartitionKeys.Account(owner, policy));
|
||||
Assert.NotEqual(RateLimitPartitionKeys.Account(owner, policy), RateLimitPartitionKeys.Account(anonymous, policy));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Public_pdf_partition_isolated_by_client_and_slug()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class SessionContextMiddlewareTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("POST")]
|
||||
[InlineData("PUT")]
|
||||
[InlineData("PATCH")]
|
||||
[InlineData("DELETE")]
|
||||
public async Task Missing_old_and_forged_contexts_never_reach_an_authenticated_mutation(string method)
|
||||
{
|
||||
foreach (var supplied in new[] { null, "", "session-a", "user-a", "session-b,session-a" })
|
||||
{
|
||||
var context = Context(method, supplied);
|
||||
var invoked = false;
|
||||
await new SessionContextMiddleware(_ => { invoked = true; return Task.CompletedTask; }).InvokeAsync(context);
|
||||
Assert.False(invoked);
|
||||
Assert.Equal(string.IsNullOrEmpty(supplied) ? 428 : 409, context.Response.StatusCode);
|
||||
context.Response.Body.Position = 0;
|
||||
var body = await new StreamReader(context.Response.Body).ReadToEndAsync();
|
||||
Assert.DoesNotContain("user-a", body);
|
||||
Assert.DoesNotContain("session-b", body);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("GET")]
|
||||
[InlineData("POST")]
|
||||
[InlineData("PUT")]
|
||||
[InlineData("PATCH")]
|
||||
[InlineData("DELETE")]
|
||||
public async Task Matching_context_preserves_the_authenticated_owner_for_downstream_code(string method)
|
||||
{
|
||||
var context = Context(method, "session-b");
|
||||
// No cookie is required for the guard: supplying a bearer token cannot bypass it.
|
||||
Assert.Empty(context.Request.Cookies);
|
||||
var invoked = false;
|
||||
await new SessionContextMiddleware(http =>
|
||||
{
|
||||
invoked = true;
|
||||
Assert.Equal("user-b", http.User.FindFirstValue(ClaimTypes.NameIdentifier));
|
||||
return Task.CompletedTask;
|
||||
}).InvokeAsync(context);
|
||||
Assert.True(invoked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stale_context_also_rejects_reads_but_unbound_bootstrap_is_allowed()
|
||||
{
|
||||
var calls = 0;
|
||||
var middleware = new SessionContextMiddleware(_ => { calls++; return Task.CompletedTask; });
|
||||
var stale = Context("GET", "session-a");
|
||||
await middleware.InvokeAsync(stale);
|
||||
Assert.Equal(409, stale.Response.StatusCode);
|
||||
Assert.Equal(0, calls);
|
||||
await middleware.InvokeAsync(Context("GET", null));
|
||||
Assert.Equal(1, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_and_missing_sid_cannot_bypass_the_precondition()
|
||||
{
|
||||
foreach (var sid in new[] { "session-b", null })
|
||||
{
|
||||
var context = Context("PUT", "session-a", sid);
|
||||
((ClaimsIdentity)context.User.Identity!).AddClaim(new Claim(ClaimTypes.Role, "Admin"));
|
||||
var invoked = false;
|
||||
await new SessionContextMiddleware(_ => { invoked = true; return Task.CompletedTask; }).InvokeAsync(context);
|
||||
Assert.False(invoked);
|
||||
Assert.Equal(409, context.Response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Multiple_headers_fail_closed()
|
||||
{
|
||||
var context = Context("PUT", "session-b");
|
||||
context.Request.Headers.Append(SessionContextMiddleware.HeaderName, "session-a");
|
||||
await new SessionContextMiddleware(_ => throw new InvalidOperationException("Unexpected mutation")).InvokeAsync(context);
|
||||
Assert.Equal(409, context.Response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Anonymous_authentication_flows_remain_available()
|
||||
{
|
||||
var context = Context("POST", null);
|
||||
context.SetEndpoint(new Endpoint(_ => Task.CompletedTask, new EndpointMetadataCollection(new AllowAnonymousAttribute()), "sign-in"));
|
||||
var invoked = false;
|
||||
await new SessionContextMiddleware(_ => { invoked = true; return Task.CompletedTask; }).InvokeAsync(context);
|
||||
Assert.True(invoked);
|
||||
}
|
||||
|
||||
private static DefaultHttpContext Context(string method, string? expected, string? sid = "session-b")
|
||||
{
|
||||
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, "user-b") };
|
||||
if (sid is not null) claims.Add(new Claim("sid", sid));
|
||||
var context = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(claims, "local")) };
|
||||
context.Request.Method = method;
|
||||
if (expected is not null) context.Request.Headers[SessionContextMiddleware.HeaderName] = expected;
|
||||
context.Response.Body = new MemoryStream();
|
||||
return context;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user