126 lines
4.9 KiB
C#
126 lines
4.9 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// P0-4: <c>POST /api/credentials/{id}/test</c> takes a caller-supplied probe URL
|
|
/// and attaches the decrypted credential, so the URL must be vetted by
|
|
/// <see cref="EgressGuard"/> before any HTTP call, and the named
|
|
/// <c>credential-test</c> client (registered with <c>AllowAutoRedirect=false</c>)
|
|
/// must be the one used. These tests pin both halves.
|
|
/// </summary>
|
|
[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<CredentialsController>.Instance)
|
|
{
|
|
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() },
|
|
};
|
|
controller.HttpContext.Items["TenantId"] = tenantId;
|
|
return controller;
|
|
}
|
|
|
|
private static async Task<Guid> 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<OkObjectResult>(
|
|
await controller.Test(credentialId, new CredentialTestRequest("https://internal.example/"), default));
|
|
var body = Assert.IsType<CredentialTestResult>(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<OkObjectResult>(
|
|
await controller.Test(credentialId, new CredentialTestRequest("https://api.example.com/"), default));
|
|
var body = Assert.IsType<CredentialTestResult>(result.Value);
|
|
|
|
Assert.True(body.Ok);
|
|
Assert.Equal(200, body.Status);
|
|
Assert.Equal("credential-test", http.ClientName);
|
|
}
|
|
|
|
/// <summary>Records the named client used and returns a configurable status.</summary>
|
|
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<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
|
=> Task.FromResult(new HttpResponseMessage(_status) { RequestMessage = request });
|
|
}
|
|
}
|
|
}
|