Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817
This commit is contained in:
commit
ad8ad252a9
825 files changed
+124216
No files matched your search
@@ -0,0 +1,102 @@
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Proves the reverse-proxy trust boundary documented in
|
||||
/// docs/23-deployment-unraid.md "Reverse proxy" and EVIDENCE.md's "Production
|
||||
/// configuration" item: forwarded headers (X-Forwarded-Proto) are only honored when
|
||||
/// they arrive from an explicitly configured KnowledgeHub:TrustedProxies entry, never
|
||||
/// by default and never from an untrusted source. The CSRF cookie's
|
||||
/// CookieSecurePolicy.SameAsRequest makes the resulting "Secure" attribute a direct,
|
||||
/// observable proxy for whether ASP.NET Core actually treated the request as HTTPS.
|
||||
/// </summary>
|
||||
public sealed class ForwardedHeadersTrustTests
|
||||
{
|
||||
private const string SimulatedRemoteIp = "203.0.113.7";
|
||||
|
||||
// WebApplicationFactory.CreateClient() defaults its base address to https://localhost,
|
||||
// which alone already makes TestServer report Request.IsHttps == true — independent of
|
||||
// any forwarded header. Every test below forces an http:// base address instead, so the
|
||||
// "Secure" cookie attribute genuinely reflects whether the ForwardedHeaders middleware
|
||||
// honored (or correctly ignored) X-Forwarded-Proto, not this client default.
|
||||
private static readonly WebApplicationFactoryClientOptions HttpClientOptions = new()
|
||||
{
|
||||
BaseAddress = new Uri("http://localhost"),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task An_untrusted_X_Forwarded_Proto_header_is_ignored_by_default()
|
||||
{
|
||||
using var factory = new KnowledgeHubApiFactory();
|
||||
using var client = factory.CreateClient(HttpClientOptions);
|
||||
|
||||
var csrfCookie = await GetCsrfCookieAsync(client, SimulatedRemoteIp);
|
||||
|
||||
Assert.DoesNotContain("Secure", csrfCookie, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_X_Forwarded_Proto_header_from_a_configured_trusted_proxy_is_honored()
|
||||
{
|
||||
using var factory = new KnowledgeHubApiFactory().WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["KnowledgeHub:TrustedProxies"] = $"{SimulatedRemoteIp};127.0.0.1",
|
||||
});
|
||||
});
|
||||
});
|
||||
using var client = factory.CreateClient(HttpClientOptions);
|
||||
|
||||
var csrfCookie = await GetCsrfCookieAsync(client, SimulatedRemoteIp);
|
||||
|
||||
Assert.Contains("Secure", csrfCookie, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_trusted_proxy_list_that_does_not_include_the_caller_still_ignores_the_header()
|
||||
{
|
||||
using var factory = new KnowledgeHubApiFactory().WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
// A real trusted proxy is configured, but it is not the address this
|
||||
// request actually arrives from — the header must still be ignored.
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["KnowledgeHub:TrustedProxies"] = "198.51.100.9",
|
||||
});
|
||||
});
|
||||
});
|
||||
using var client = factory.CreateClient(HttpClientOptions);
|
||||
|
||||
var csrfCookie = await GetCsrfCookieAsync(client, SimulatedRemoteIp);
|
||||
|
||||
Assert.DoesNotContain("Secure", csrfCookie, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static async Task<string> GetCsrfCookieAsync(HttpClient client, string simulatedRemoteIp)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/auth/csrf");
|
||||
// TestServer never simulates a real socket, so Connection.RemoteIpAddress is null
|
||||
// unless something sets it. Program.cs sets it from this header in the Test
|
||||
// environment only (never reachable in Production), giving the KnownProxies
|
||||
// trust check a deterministic "direct caller" address — the same role a real
|
||||
// kernel-level TCP accept plays in production.
|
||||
request.Headers.Add("X-Test-Remote-Ip", simulatedRemoteIp);
|
||||
request.Headers.Add("X-Forwarded-Proto", "https");
|
||||
request.Headers.Add("X-Forwarded-For", "198.51.100.50");
|
||||
|
||||
using var response = await client.SendAsync(request);
|
||||
|
||||
var csrfCookie = response.Headers.TryGetValues("Set-Cookie", out var values)
|
||||
? values.FirstOrDefault(v => v.StartsWith("ikh_csrf=", StringComparison.Ordinal))
|
||||
: null;
|
||||
Assert.NotNull(csrfCookie);
|
||||
return csrfCookie;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Tests;
|
||||
|
||||
public sealed class HealthEndpointTests : IClassFixture<KnowledgeHubApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public HealthEndpointTests(KnowledgeHubApiFactory factory)
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Liveness_returns_healthy_product_response()
|
||||
{
|
||||
using var response = await _client.GetAsync("/health/live");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<LivenessResponse>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal("healthy", payload.Status);
|
||||
Assert.Equal("ItWorx Knowledge Hub", payload.Product);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task System_info_requires_administrator_authentication()
|
||||
{
|
||||
// docs/19-observability.md: "/api/v1/system/info" is "Permissioned
|
||||
// information", distinct from the two anonymous-by-design health endpoints
|
||||
// above (docs/23-deployment-unraid.md "Operational checks" lists
|
||||
// "authenticated system info" separately from /health/live and
|
||||
// /health/ready). An anonymous caller must never see product/schema/version
|
||||
// detail. Full authenticated-payload coverage (schemaVersion etc.) lives in
|
||||
// the real-PostgreSQL integration tests, since this lightweight factory has
|
||||
// no working login flow.
|
||||
using var response = await _client.GetAsync("/api/v1/system/info");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
private sealed record LivenessResponse(string Status, string Product);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../src/backend/ItWorx.KnowledgeHub.Api/ItWorx.KnowledgeHub.Api.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,38 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Tests;
|
||||
|
||||
public sealed class KnowledgeHubApiFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseContentRoot(
|
||||
Path.Combine(
|
||||
FindRepositoryRoot().FullName,
|
||||
"src",
|
||||
"backend",
|
||||
"ItWorx.KnowledgeHub.Api"));
|
||||
|
||||
builder.UseEnvironment("Test");
|
||||
}
|
||||
|
||||
private static DirectoryInfo FindRepositoryRoot()
|
||||
{
|
||||
DirectoryInfo? current = new(AppContext.BaseDirectory);
|
||||
|
||||
while (current is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(current.FullName, "global.json"))
|
||||
&& (File.Exists(Path.Combine(current.FullName, "CLAUDE.md"))
|
||||
|| File.Exists(Path.Combine(current.FullName, "PUBLIC_SOURCE_EXPORT.md"))))
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate the repository root.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using ItWorx.KnowledgeHub.Api.Operations;
|
||||
using ItWorx.KnowledgeHub.Application.Operations;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="RestoreModeRunner"/> is deliberately pure/testable without booting a real
|
||||
/// web host or database — no <c>Environment.Exit</c> call inside the tested unit
|
||||
/// itself (only Program.cs's thin top-level wrapper performs the real process exit),
|
||||
/// so a fake <see cref="IRestoreService"/> is enough to prove the CLI contract.
|
||||
/// </summary>
|
||||
public sealed class RestoreModeRunnerTests
|
||||
{
|
||||
[Fact]
|
||||
public void IsRestoreMode_recognizes_the_flag_alone()
|
||||
{
|
||||
Assert.True(RestoreModeRunner.IsRestoreMode(["--restore"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRestoreMode_recognizes_the_flag_alongside_other_arguments()
|
||||
{
|
||||
Assert.True(RestoreModeRunner.IsRestoreMode(["--restore", "--backup-id=x"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRestoreMode_is_false_for_ordinary_startup_args()
|
||||
{
|
||||
Assert.False(RestoreModeRunner.IsRestoreMode([]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Missing_required_arguments_returns_usage_exit_code_without_touching_the_restore_service()
|
||||
{
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<IRestoreService>(new FakeRestoreService(shouldSucceed: true))
|
||||
.BuildServiceProvider();
|
||||
using var output = new StringWriter();
|
||||
|
||||
var exitCode = await RestoreModeRunner.RunAsync(["--restore"], services, output, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, exitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_successful_restore_returns_exit_code_zero()
|
||||
{
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<IRestoreService>(new FakeRestoreService(shouldSucceed: true))
|
||||
.BuildServiceProvider();
|
||||
using var output = new StringWriter();
|
||||
|
||||
var exitCode = await RestoreModeRunner.RunAsync(
|
||||
["--restore", "--backup-id=abc", "--target-connection=conn", "--target-attachments=/tmp/x"],
|
||||
services, output, CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.Contains("GESLAAGD", output.ToString(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failed_restore_returns_a_non_zero_exit_code_and_reports_the_mismatch()
|
||||
{
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<IRestoreService>(new FakeRestoreService(shouldSucceed: false))
|
||||
.BuildServiceProvider();
|
||||
using var output = new StringWriter();
|
||||
|
||||
var exitCode = await RestoreModeRunner.RunAsync(
|
||||
["--restore", "--backup-id=abc", "--target-connection=conn", "--target-attachments=/tmp/x"],
|
||||
services, output, CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, exitCode);
|
||||
Assert.Contains("MISLUKT", output.ToString(), StringComparison.Ordinal);
|
||||
Assert.Contains("AFWIJKING", output.ToString(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private sealed class FakeRestoreService(bool shouldSucceed) : IRestoreService
|
||||
{
|
||||
public Task<RestoreReport> RestoreAsync(string backupId, RestoreTarget target, CancellationToken cancellationToken)
|
||||
{
|
||||
var tables = shouldSucceed
|
||||
? new List<RestoreTableReport> { new("pages", 3, 3) }
|
||||
: new List<RestoreTableReport> { new("pages", 2, 3) };
|
||||
|
||||
return Task.FromResult(new RestoreReport(backupId, shouldSucceed, tables, shouldSucceed ? 1 : 0, 1, []));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace ItWorx.KnowledgeHub.Api.Tests;
|
||||
|
||||
public sealed class SecurityHeaderTests : IClassFixture<KnowledgeHubApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SecurityHeaderTests(KnowledgeHubApiFactory factory)
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Responses_carry_the_documented_baseline_security_headers()
|
||||
{
|
||||
using var response = await _client.GetAsync("/health/live");
|
||||
|
||||
Assert.Equal("nosniff", GetHeader(response, "X-Content-Type-Options"));
|
||||
Assert.Equal("DENY", GetHeader(response, "X-Frame-Options"));
|
||||
Assert.Equal("same-origin", GetHeader(response, "Referrer-Policy"));
|
||||
Assert.Contains("frame-ancestors 'none'", GetHeader(response, "Content-Security-Policy"));
|
||||
Assert.Contains("object-src 'none'", GetHeader(response, "Content-Security-Policy"));
|
||||
Assert.DoesNotContain("script-src 'self' 'unsafe-inline'", GetHeader(response, "Content-Security-Policy"));
|
||||
Assert.NotNull(GetHeader(response, "Permissions-Policy"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Every_response_carries_a_correlation_id_header()
|
||||
{
|
||||
using var response = await _client.GetAsync("/health/live");
|
||||
|
||||
var correlationId = GetHeader(response, "X-Correlation-ID");
|
||||
Assert.False(string.IsNullOrWhiteSpace(correlationId));
|
||||
Assert.True(Guid.TryParse(correlationId, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_supplied_correlation_id_is_echoed_back_unchanged()
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/health/live");
|
||||
request.Headers.Add("X-Correlation-ID", "caller-supplied-id-123");
|
||||
|
||||
using var response = await _client.SendAsync(request);
|
||||
|
||||
Assert.Equal("caller-supplied-id-123", GetHeader(response, "X-Correlation-ID"));
|
||||
}
|
||||
|
||||
private static string? GetHeader(HttpResponseMessage response, string name) =>
|
||||
response.Headers.TryGetValues(name, out var values) ? string.Join(", ", values) : null;
|
||||
}
|
||||
Reference in new issue
Block a user