w4c-workflows-api/w4c-workflows-api.Tests/CredentialsControllerTests.cs
Vitali sharp8n 42ffcb9adc workflows
2026-09-13 19:28:47 +03:00

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