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 });
}
}
}