using System.Net; using System.Net.Http; using JobTrackerApi.Services.JobImport; using JobTrackerApi.Services.JobImport.Translation; using Moq; using Xunit; namespace JobTrackerApi.Tests; public sealed class JobImportServiceTests { [Fact] public async Task Preview_rejects_hostname_that_resolves_to_loopback() { var resolver = new Mock(); resolver .Setup(x => x.ResolveAsync("127.0.0.1.nip.io", It.IsAny())) .ReturnsAsync(new[] { IPAddress.Loopback }); var service = CreateService(resolver.Object); var result = await service.PreviewAsync("http://127.0.0.1.nip.io:5202/api/auth/config", CancellationToken.None); Assert.False(result.Success); Assert.Equal("none", result.Parser); Assert.Equal("Local or private network URLs are not allowed.", result.Error); } [Fact] public async Task Preview_rejects_hostname_that_resolves_to_private_ip() { var resolver = new Mock(); resolver .Setup(x => x.ResolveAsync("internal.example.test", It.IsAny())) .ReturnsAsync(new[] { IPAddress.Parse("10.10.1.5") }); var service = CreateService(resolver.Object); var result = await service.PreviewAsync("https://internal.example.test/job/123", CancellationToken.None); Assert.False(result.Success); Assert.Equal("Local or private network URLs are not allowed.", result.Error); } [Fact] public async Task Preview_allows_public_hostname_resolution_and_fetches_html() { var resolver = new Mock(); resolver .Setup(x => x.ResolveAsync("example.com", It.IsAny())) .ReturnsAsync(new[] { IPAddress.Parse("93.184.216.34") }); var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("no schema") }); var service = CreateService(resolver.Object, handler); var result = await service.PreviewAsync("https://example.com/job", CancellationToken.None); Assert.False(result.Success); Assert.Equal("universal", result.Parser); Assert.Equal("No JobPosting schema found.", result.Error); } [Fact] public async Task Preview_stops_reading_a_chunked_response_at_the_download_limit() { var resolver = new Mock(); resolver.Setup(x => x.ResolveAsync("example.com", It.IsAny())) .ReturnsAsync([IPAddress.Parse("93.184.216.34")]); var contentStream = new GeneratedStream(10_000_000); var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(contentStream) }); var service = CreateService(resolver.Object, handler); var result = await service.PreviewAsync("https://example.com/job", CancellationToken.None); Assert.False(result.Success); Assert.Equal("fetch", result.Parser); Assert.InRange(contentStream.BytesRead, 4_000_001, 4_065_536); } private static JobImportService CreateService(IHostAddressResolver resolver, HttpMessageHandler? handler = null) { handler ??= new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("") }); var httpClient = new HttpClient(handler, disposeHandler: true); var factory = new Mock(); factory.Setup(x => x.CreateClient("jobimport")).Returns(httpClient); return new JobImportService( factory.Object, new UniversalJobParser(), Array.Empty(), new NoOpTranslationService(), resolver); } private sealed class StubHttpMessageHandler : HttpMessageHandler { private readonly Func _handler; public StubHttpMessageHandler(Func handler) { _handler = handler; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => Task.FromResult(_handler(request)); } private sealed class GeneratedStream(long length) : Stream { private long _position; public long BytesRead { get; private set; } public override bool CanRead => true; public override bool CanSeek => false; public override bool CanWrite => false; public override long Length => length; public override long Position { get => _position; set => throw new NotSupportedException(); } public override void Flush() { } public override int Read(byte[] buffer, int offset, int count) { var read = (int)Math.Min(count, length - _position); if (read <= 0) return 0; Array.Fill(buffer, (byte)'x', offset, read); _position += read; BytesRead += read; return read; } public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { var read = (int)Math.Min(buffer.Length, length - _position); if (read <= 0) return ValueTask.FromResult(0); buffer.Span[..read].Fill((byte)'x'); _position += read; BytesRead += read; return ValueTask.FromResult(read); } public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException(); public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); } }