using System.Net; using System.Net.Http; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging.Abstractions; using w4c_workflows.Controllers; using w4c_workflows.Data; using w4c_workflows.Services.Credentials; using w4c_workflows.Services.Security; using Xunit; namespace w4c_workflows.Tests; /// /// P0-4: POST /api/credentials/{id}/test takes a caller-supplied probe URL /// and attaches the decrypted credential, so the URL must be vetted by /// before any HTTP call, and the named /// credential-test client (registered with AllowAutoRedirect=false) /// must be the one used. These tests pin both halves. /// [Collection("WorkflowsPostgres")] public class CredentialsControllerTests { private readonly WorkflowsPostgresFixture _fixture; public CredentialsControllerTests(WorkflowsPostgresFixture fixture) => _fixture = fixture; private static string Tenant() => "cred" + Guid.NewGuid().ToString("N")[..12]; private static CredentialsController Controller( WorkflowsDbContext db, string tenantId, EgressGuard egress, IHttpClientFactory http) { var controller = new CredentialsController( db, new CredentialVault(new ReversibleTestCipher(), new CredentialTypeCatalog()), new CredentialTypeCatalog(), http, egress, NullLogger.Instance) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }, }; controller.HttpContext.Items["TenantId"] = tenantId; return controller; } private static async Task CreateCredentialAsync(WorkflowsDbContext db, string tenantId) { var vault = new CredentialVault(new ReversibleTestCipher(), new CredentialTypeCatalog()); var created = await vault.CreateAsync( db, tenantId, "my-api", "httpHeaderAuth", new JsonObject { ["name"] = "X-API-Key", ["value"] = "secret-value" }, default); return created.Id; } [Fact] public async Task Test_probe_is_blocked_by_egress_and_never_reaches_http() { var tenantId = Tenant(); await using var db = _fixture.CreateContext(); var credentialId = await CreateCredentialAsync(db, tenantId); var http = new RecordingHttpClientFactory(); // The host resolves to loopback, which the default (fail-closed) policy denies. var egress = EgressTestData.Guard(resolver: StubHostAddressResolver.Returning("127.0.0.1")); var controller = Controller(db, tenantId, egress, http); var result = Assert.IsType( await controller.Test(credentialId, new CredentialTestRequest("https://internal.example/"), default)); var body = Assert.IsType(result.Value); Assert.False(body.Ok); Assert.Contains("egress", body.Message); Assert.False(http.Called); } [Fact] public async Task Test_probe_uses_the_credential_test_client_for_an_allowed_url() { var tenantId = Tenant(); await using var db = _fixture.CreateContext(); var credentialId = await CreateCredentialAsync(db, tenantId); var http = new RecordingHttpClientFactory(HttpStatusCode.OK); var egress = EgressTestData.Guard(); var controller = Controller(db, tenantId, egress, http); var result = Assert.IsType( await controller.Test(credentialId, new CredentialTestRequest("https://api.example.com/"), default)); var body = Assert.IsType(result.Value); Assert.True(body.Ok); Assert.Equal(200, body.Status); Assert.Equal("credential-test", http.ClientName); } /// Records the named client used and returns a configurable status. private sealed class RecordingHttpClientFactory : IHttpClientFactory { private readonly HttpStatusCode _status; public RecordingHttpClientFactory(HttpStatusCode status = HttpStatusCode.OK) => _status = status; public bool Called { get; private set; } public string? ClientName { get; private set; } public HttpClient CreateClient(string name) { Called = true; ClientName = name; return new HttpClient(new StubHandler(_status)); } private sealed class StubHandler : HttpMessageHandler { private readonly HttpStatusCode _status; public StubHandler(HttpStatusCode status) => _status = status; protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) => Task.FromResult(new HttpResponseMessage(_status) { RequestMessage = request }); } } }