Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:37:37 +02:00
commit ad8ad252a9
825 files changed
+124216

No files matched your search

@@ -0,0 +1,157 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Npgsql;
namespace ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
/// <summary>
/// One isolated logical database per test class, on the shared
/// <see cref="PostgresContainerFixture"/> server. Real EF Core
/// migrations run at startup exactly as production does — this is not a mocked or
/// SQLite-backed substitute (docs/22-testing-strategy.md).
/// </summary>
public sealed class KnowledgeHubIntegrationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgresContainerFixture _postgres;
private readonly IReadOnlyDictionary<string, string?> _additionalConfiguration;
private readonly string _databaseName = $"test_{Guid.NewGuid():N}";
private readonly string _attachmentsDirectory =
Path.Combine(Path.GetTempPath(), "ikh-tests-attachments", Guid.NewGuid().ToString("N"));
private readonly string _importsDirectory =
Path.Combine(Path.GetTempPath(), "ikh-tests-imports", Guid.NewGuid().ToString("N"));
private readonly string _backupsDirectory =
Path.Combine(Path.GetTempPath(), "ikh-tests-backups", Guid.NewGuid().ToString("N"));
public KnowledgeHubIntegrationFactory(
PostgresContainerFixture postgres,
IReadOnlyDictionary<string, string?>? additionalConfiguration = null)
{
_postgres = postgres;
_additionalConfiguration = additionalConfiguration ?? new Dictionary<string, string?>();
}
public async Task InitializeAsync()
{
await using var connection = new NpgsqlConnection(_postgres.AdminConnectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = $"CREATE DATABASE \"{_databaseName}\"";
await command.ExecuteNonQueryAsync();
}
async Task IAsyncLifetime.DisposeAsync()
{
// Dispose the hosted TestServer/HttpClient first (base WebApplicationFactory
// behavior), then drop this test class's isolated database.
await base.DisposeAsync();
if (Directory.Exists(_attachmentsDirectory))
{
Directory.Delete(_attachmentsDirectory, recursive: true);
}
if (Directory.Exists(_importsDirectory))
{
Directory.Delete(_importsDirectory, recursive: true);
}
if (Directory.Exists(_backupsDirectory))
{
Directory.Delete(_backupsDirectory, recursive: true);
}
try
{
await using var connection = new NpgsqlConnection(_postgres.AdminConnectionString);
await connection.OpenAsync();
await using (var terminate = connection.CreateCommand())
{
terminate.CommandText =
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity " +
"WHERE datname = @name AND pid <> pg_backend_pid()";
terminate.Parameters.AddWithValue("name", _databaseName);
await terminate.ExecuteNonQueryAsync();
}
await using var drop = connection.CreateCommand();
drop.CommandText = $"DROP DATABASE IF EXISTS \"{_databaseName}\"";
await drop.ExecuteNonQueryAsync();
}
catch (NpgsqlException)
{
// Best-effort cleanup: the whole container is disposed at the end of the
// run regardless, so a failure to drop one logical database here is not a
// correctness problem for the test suite.
}
}
/// <summary>The isolated content-addressed attachment storage root for this test
/// class, so parallel test classes never share (or race on) blob files.</summary>
public string AttachmentsDirectory => _attachmentsDirectory;
/// <summary>The isolated LeafWiki imports mount root for this test class. Tests
/// write synthetic fixture files here directly (never a real snapshot).</summary>
public string ImportsDirectory => _importsDirectory;
/// <summary>The isolated backups root for this test class, so parallel/sequential
/// test classes never share (or have retention-prune) each other's backups.</summary>
public string BackupsDirectory => _backupsDirectory;
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseContentRoot(
Path.Combine(
FindRepositoryRoot().FullName,
"src",
"backend",
"ItWorx.KnowledgeHub.Api"));
builder.UseEnvironment("IntegrationTest");
builder.ConfigureAppConfiguration((_, configuration) =>
{
var values = new Dictionary<string, string?>
{
["KnowledgeHub:Database:Host"] = _postgres.Host,
["KnowledgeHub:Database:Port"] = _postgres.Port.ToString(System.Globalization.CultureInfo.InvariantCulture),
["KnowledgeHub:Database:Name"] = _databaseName,
["KnowledgeHub:Database:Username"] = PostgresContainerFixture.Username,
["KnowledgeHub:Database:Password"] = PostgresContainerFixture.Password,
["KnowledgeHub:Paths:Attachments"] = _attachmentsDirectory,
["KnowledgeHub:Paths:Imports"] = _importsDirectory,
["KnowledgeHub:Paths:Backups"] = _backupsDirectory
};
foreach (var (key, value) in _additionalConfiguration)
{
values[key] = value;
}
configuration.AddInMemoryCollection(values);
});
}
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,47 @@
namespace ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
/// <summary>
/// Base for test classes that need an already-completed first-run setup (an owner
/// account, in an isolated database, ready to log in). Each test class gets its own
/// database and its own owner — never a shared mutable fixture between test classes.
/// </summary>
public abstract class OwnerSetupTestBase : IAsyncLifetime
{
protected OwnerSetupTestBase(
PostgresContainerFixture postgres,
IReadOnlyDictionary<string, string?>? additionalConfiguration = null)
{
Factory = new KnowledgeHubIntegrationFactory(postgres, additionalConfiguration);
}
protected KnowledgeHubIntegrationFactory Factory { get; }
protected string OwnerEmail { get; } = "eigenaar@itworx.test";
protected string OwnerPassword { get; } = "correct-horse-battery-staple";
public async Task InitializeAsync()
{
await ((IAsyncLifetime)Factory).InitializeAsync();
using var client = Factory.CreateClient();
var response = await SetupHelper.CompleteFirstRunSetupAsync(
client,
"ItWorx Testomgeving",
"Eigenaar Testpersoon",
OwnerEmail,
OwnerPassword);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync();
throw new InvalidOperationException($"First-run setup failed ({response.StatusCode}): {body}");
}
}
public async Task DisposeAsync()
{
await ((IAsyncLifetime)Factory).DisposeAsync();
}
}
@@ -0,0 +1,59 @@
using Npgsql;
using Testcontainers.PostgreSql;
namespace ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
/// <summary>
/// One PostgreSQL Testcontainer for the whole assembly run (docs/22-testing-strategy.md
/// — "One PostgreSQL Testcontainer per test collection/suite"). Individual test classes
/// each get an isolated logical database on this shared server instance via
/// <see cref="KnowledgeHubIntegrationFactory"/>, so tests never share mutable rows.
/// </summary>
public sealed class PostgresContainerFixture : IAsyncLifetime
{
private const string SharedNetworkAlias = "knowledge-hub-postgres";
private static readonly string? SharedNetworkName =
Environment.GetEnvironmentVariable("TESTCONTAINERS_NETWORK");
// Pinned to the same PostgreSQL major/minor family as production
// (docker-compose.yml / docs/23-deployment-unraid.md).
private readonly PostgreSqlContainer _container = BuildContainer();
public string Host => UsesSharedNetwork ? SharedNetworkAlias : _container.Hostname;
public int Port => UsesSharedNetwork ? 5432 : _container.GetMappedPublicPort(5432);
public string AdminConnectionString => new NpgsqlConnectionStringBuilder(_container.GetConnectionString())
{
Host = Host,
Port = Port
}.ConnectionString;
public static string Username => "knowledge_hub";
public static string Password => "integration-test-only";
public Task InitializeAsync() => _container.StartAsync();
public Task DisposeAsync() => _container.DisposeAsync().AsTask();
private static bool UsesSharedNetwork => !string.IsNullOrWhiteSpace(SharedNetworkName);
private static PostgreSqlContainer BuildContainer()
{
var builder = new PostgreSqlBuilder(
"postgres:18.4-bookworm@sha256:882236b897e39051d2368c5ccc6cda944904723506b2dfc97f2a8f5bc9afa382")
.WithDatabase("knowledge_hub_admin")
.WithUsername("knowledge_hub")
.WithPassword("integration-test-only");
if (UsesSharedNetwork)
{
builder = builder
.WithNetwork(SharedNetworkName!)
.WithNetworkAliases(SharedNetworkAlias);
}
return builder.Build();
}
}
@@ -0,0 +1,7 @@
namespace ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
[CollectionDefinition(Name)]
public sealed class PostgresTestGroup : ICollectionFixture<PostgresContainerFixture>
{
public const string Name = "Postgres";
}
@@ -0,0 +1,156 @@
using System.Net.Http.Json;
using System.Text.Json;
using ItWorx.KnowledgeHub.Contracts.Auth;
using ItWorx.KnowledgeHub.Contracts.Setup;
namespace ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
public sealed record CsrfToken(string Value, string HeaderName);
/// <summary>Shared helpers so every test class does not re-implement the first-run
/// setup and CSRF handshake used to reach an authenticated state.</summary>
public static class SetupHelper
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public static async Task<CsrfToken> GetCsrfTokenAsync(HttpClient client)
{
var response = await client.GetFromJsonAsync<CsrfTokenResponse>("/api/v1/auth/csrf", JsonOptions)
?? throw new InvalidOperationException("No CSRF token returned.");
return new CsrfToken(response.Token, response.HeaderName);
}
public static async Task<HttpResponseMessage> CompleteFirstRunSetupAsync(
HttpClient client,
string workspaceName,
string displayName,
string email,
string password)
{
var csrf = await GetCsrfTokenAsync(client);
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/setup/complete")
{
Content = JsonContent.Create(
new CompleteSetupRequest(workspaceName, displayName, email, password),
options: JsonOptions)
};
request.Headers.Add(csrf.HeaderName, csrf.Value);
return await client.SendAsync(request);
}
public static async Task<HttpResponseMessage> LoginAsync(HttpClient client, string email, string password)
{
return await client.PostAsJsonAsync(
"/api/v1/auth/login",
new LoginRequest(email, password),
JsonOptions);
}
public static async Task<HttpResponseMessage> PostWithCsrfAsync<TBody>(
HttpClient client,
string path,
TBody body)
{
var csrf = await GetCsrfTokenAsync(client);
using var request = new HttpRequestMessage(HttpMethod.Post, path)
{
Content = JsonContent.Create(body, options: JsonOptions)
};
request.Headers.Add(csrf.HeaderName, csrf.Value);
return await client.SendAsync(request);
}
public static async Task<HttpResponseMessage> PatchWithCsrfAsync<TBody>(
HttpClient client,
string path,
TBody body)
{
var csrf = await GetCsrfTokenAsync(client);
using var request = new HttpRequestMessage(HttpMethod.Patch, path)
{
Content = JsonContent.Create(body, options: JsonOptions)
};
request.Headers.Add(csrf.HeaderName, csrf.Value);
return await client.SendAsync(request);
}
public static async Task<HttpResponseMessage> PutWithCsrfAsync<TBody>(
HttpClient client,
string path,
TBody body,
string? ifMatch = null)
{
var csrf = await GetCsrfTokenAsync(client);
using var request = new HttpRequestMessage(HttpMethod.Put, path)
{
Content = JsonContent.Create(body, options: JsonOptions)
};
request.Headers.Add(csrf.HeaderName, csrf.Value);
if (ifMatch is not null)
{
request.Headers.Add("If-Match", $"\"{ifMatch}\"");
}
return await client.SendAsync(request);
}
public static async Task<HttpResponseMessage> PostWithCsrfAndIfMatchAsync<TBody>(
HttpClient client,
string path,
TBody body,
string ifMatch)
{
var csrf = await GetCsrfTokenAsync(client);
using var request = new HttpRequestMessage(HttpMethod.Post, path)
{
Content = JsonContent.Create(body, options: JsonOptions)
};
request.Headers.Add(csrf.HeaderName, csrf.Value);
request.Headers.Add("If-Match", $"\"{ifMatch}\"");
return await client.SendAsync(request);
}
public static async Task<HttpResponseMessage> DeleteWithCsrfAsync(HttpClient client, string path)
{
var csrf = await GetCsrfTokenAsync(client);
using var request = new HttpRequestMessage(HttpMethod.Delete, path);
request.Headers.Add(csrf.HeaderName, csrf.Value);
return await client.SendAsync(request);
}
public static string? GetETag(HttpResponseMessage response) =>
response.Headers.ETag?.Tag.Trim('"');
public static async Task<HttpResponseMessage> PostMultipartWithCsrfAsync(
HttpClient client,
string path,
byte[] fileBytes,
string fileName,
string contentType)
{
var csrf = await GetCsrfTokenAsync(client);
using var content = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(fileBytes);
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
content.Add(fileContent, "file", fileName);
using var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = content };
request.Headers.Add(csrf.HeaderName, csrf.Value);
return await client.SendAsync(request);
}
}