249 lines
12 KiB
C#
249 lines
12 KiB
C#
using System.Net.Http.Json;
|
|
using ItWorx.KnowledgeHub.Contracts.Auth;
|
|
using ItWorx.KnowledgeHub.Contracts.Infrastructure;
|
|
using ItWorx.KnowledgeHub.Contracts.Projects;
|
|
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
|
|
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Npgsql;
|
|
|
|
namespace ItWorx.KnowledgeHub.IntegrationTests.InfrastructureKnowledge;
|
|
|
|
/// <summary>
|
|
/// V2-R5 (docs/adr/ADR-029-v2-r5-final-hardening.md, brief Phase 4): `GET
|
|
/// /infrastructure/storage` was a fully unbounded, unpaginated listing, and
|
|
/// `GET /infrastructure/services` had no server-side project filter — the
|
|
/// Project workspace fetched up to 200 services and filtered by project client-side.
|
|
/// Proves the fix: real server-side cursor pagination (no duplicates, no gaps, stable
|
|
/// across pages), a server-side text search, and a server-side project filter.
|
|
/// </summary>
|
|
[Collection(PostgresTestGroup.Name)]
|
|
public sealed class InfrastructurePaginationTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
|
|
{
|
|
private const int VolumeCount = 130;
|
|
|
|
[Fact]
|
|
public async Task Storage_pages_through_every_volume_exactly_once_with_no_duplicates_or_gaps()
|
|
{
|
|
using var client = Factory.CreateClient();
|
|
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
|
await SeedVolumesDirectlyAsync();
|
|
|
|
var seenIds = new HashSet<Guid>();
|
|
string? cursor = null;
|
|
var pageCount = 0;
|
|
do
|
|
{
|
|
var url = "/api/v1/infrastructure/storage?limit=20" + (cursor is null ? "" : $"&cursor={Uri.EscapeDataString(cursor)}");
|
|
var page = await client.GetFromJsonAsync<InfrastructureStorageListResponse>(url);
|
|
Assert.NotNull(page);
|
|
Assert.True(page.Items.Count <= 20);
|
|
foreach (var item in page.Items)
|
|
{
|
|
Assert.True(seenIds.Add(item.Id), $"Volume {item.Id} appeared on more than one page.");
|
|
}
|
|
|
|
cursor = page.NextCursor;
|
|
pageCount++;
|
|
Assert.True(pageCount < 50, "Too many pages — likely a pagination bug (cursor never advances / never terminates).");
|
|
}
|
|
while (cursor is not null);
|
|
|
|
Assert.Equal(VolumeCount, seenIds.Count);
|
|
Assert.True(pageCount > 1, "Expected more than one page at this seeded volume count.");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Storage_search_filters_server_side_not_only_within_the_first_loaded_page()
|
|
{
|
|
using var client = Factory.CreateClient();
|
|
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
|
await SeedVolumesDirectlyAsync();
|
|
|
|
// "schaal-service-00099" (a service near the end of the seeded range, unlikely
|
|
// to appear on a single default-sized first page) must still be found — proving
|
|
// the search runs server-side across the whole table, not client-side over
|
|
// whatever page happened to load first.
|
|
var result = await client.GetFromJsonAsync<InfrastructureStorageListResponse>(
|
|
"/api/v1/infrastructure/storage?search=schaal-service-00099&limit=20");
|
|
Assert.NotNull(result);
|
|
Assert.Single(result.Items);
|
|
Assert.Contains("schaal-service-00099", result.Items[0].ServiceName);
|
|
Assert.Null(result.NextCursor);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Services_search_filters_server_side_not_only_within_the_first_loaded_page()
|
|
{
|
|
using var client = Factory.CreateClient();
|
|
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
|
await SeedVolumesDirectlyAsync();
|
|
|
|
var result = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
|
"/api/v1/infrastructure/services?includeHidden=true&search=schaal-service-00099&limit=20");
|
|
Assert.NotNull(result);
|
|
Assert.Single(result.Items);
|
|
Assert.Contains("schaal-service-00099", result.Items[0].Name);
|
|
Assert.Null(result.NextCursor);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Services_can_be_filtered_by_project_entirely_server_side()
|
|
{
|
|
using var client = Factory.CreateClient();
|
|
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
|
var (workspaceId, allServiceIds) = await SeedVolumesDirectlyAsync();
|
|
|
|
var session = await client.GetFromJsonAsync<SessionResponse>("/api/v1/auth/session");
|
|
Assert.NotNull(session?.UserId);
|
|
|
|
var projectResponse = await SetupHelper.PostWithCsrfAsync(
|
|
client, "/api/v1/projects",
|
|
new CreateProjectRequest(
|
|
$"Paginatie-project {Guid.NewGuid():N}", "Testproject voor paginatiedekking.", "Medium", "# Status", session.UserId.Value));
|
|
Assert.Equal(System.Net.HttpStatusCode.OK, projectResponse.StatusCode);
|
|
var project = await projectResponse.Content.ReadFromJsonAsync<ProjectDetail>();
|
|
Assert.NotNull(project);
|
|
var projectId = project.Id;
|
|
|
|
// Link exactly 3 of the 130 seeded services to this project through the real
|
|
// editorial-profile endpoint — proving the filter is a real server-side WHERE
|
|
// against genuine domain state, not a client-side slice of an unfiltered page.
|
|
var linkedIds = allServiceIds.Take(3).ToArray();
|
|
foreach (var serviceId in linkedIds)
|
|
{
|
|
var response = await SetupHelper.PutWithCsrfAsync(
|
|
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile",
|
|
new { isFavorite = false, isHidden = false, isArchived = false, projectId }, "0");
|
|
Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
|
|
}
|
|
|
|
_ = workspaceId;
|
|
|
|
var filtered = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
|
$"/api/v1/infrastructure/services?includeHidden=true&projectId={projectId}&limit=200");
|
|
Assert.NotNull(filtered);
|
|
Assert.Equal(3, filtered.Items.Count);
|
|
Assert.Equal(linkedIds.ToHashSet(), filtered.Items.Select(item => item.Id).ToHashSet());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Services_status_filter_is_applied_before_cursor_pagination()
|
|
{
|
|
using var client = Factory.CreateClient();
|
|
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
|
var (_, serviceIds) = await SeedVolumesDirectlyAsync();
|
|
|
|
using (var scope = Factory.Services.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
|
await db.Database.ExecuteSqlInterpolatedAsync(
|
|
$"UPDATE infrastructure_services SET health = {(short)ItWorx.KnowledgeHub.Domain.Infrastructure.HealthState.Unhealthy} WHERE id = {serviceIds[^1]}");
|
|
}
|
|
|
|
// The unhealthy service is deliberately the final seeded row. A server-side
|
|
// WHERE is required: a local filter over a first page could never find it.
|
|
var result = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
|
"/api/v1/infrastructure/services?includeHidden=true&status=attention&limit=20");
|
|
Assert.NotNull(result);
|
|
Assert.Single(result.Items);
|
|
Assert.Equal(serviceIds[^1], result.Items[0].Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Services_report_the_filtered_total_not_the_size_of_the_returned_page()
|
|
{
|
|
using var client = Factory.CreateClient();
|
|
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
|
var (_, serviceIds) = await SeedVolumesDirectlyAsync();
|
|
|
|
using (var scope = Factory.Services.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
|
await db.Database.ExecuteSqlInterpolatedAsync(
|
|
$"UPDATE infrastructure_services SET health = {(short)ItWorx.KnowledgeHub.Domain.Infrastructure.HealthState.Unhealthy} WHERE id = {serviceIds[^1]}");
|
|
}
|
|
|
|
// A deliberately small page over a deliberately large catalog: the operator
|
|
// must be told how many services exist behind the filter, not how many fitted
|
|
// on the first page.
|
|
var firstPage = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
|
"/api/v1/infrastructure/services?includeHidden=true&limit=10");
|
|
Assert.NotNull(firstPage);
|
|
Assert.Equal(10, firstPage.Items.Count);
|
|
Assert.NotNull(firstPage.NextCursor);
|
|
Assert.Equal(serviceIds.Count, firstPage.TotalCount);
|
|
|
|
// The total tracks the filter, and stays stable while paging through it.
|
|
var attention = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
|
"/api/v1/infrastructure/services?includeHidden=true&status=attention&limit=10");
|
|
Assert.NotNull(attention);
|
|
Assert.Equal(1, attention.TotalCount);
|
|
|
|
var secondPage = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
|
$"/api/v1/infrastructure/services?includeHidden=true&limit=10&cursor={Uri.EscapeDataString(firstPage.NextCursor!)}");
|
|
Assert.NotNull(secondPage);
|
|
Assert.Equal(serviceIds.Count, secondPage.TotalCount);
|
|
}
|
|
|
|
private async Task<(Guid WorkspaceId, IReadOnlyList<Guid> ServiceIds)> SeedVolumesDirectlyAsync()
|
|
{
|
|
using var scope = Factory.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
|
var workspace = await db.Workspaces.AsNoTracking().FirstAsync();
|
|
var workspaceId = workspace.Id;
|
|
|
|
var connectionOptions = scope.ServiceProvider.GetRequiredService<DatabaseConnectionOptions>();
|
|
await using var connection = new NpgsqlConnection(connectionOptions.ConnectionString);
|
|
await connection.OpenAsync();
|
|
|
|
var snapshotId = Guid.NewGuid();
|
|
|
|
const string servicesSql = """
|
|
INSERT INTO infrastructure_services (
|
|
id, workspace_id, host_id, source_key, detected_name, runtime_kind, runtime_status,
|
|
health, image_reference, image_digest, runtime_user, read_only_root_filesystem,
|
|
no_new_privileges, privileged, presence, confidence, last_snapshot_id, first_seen_at, last_seen_at)
|
|
SELECT
|
|
gen_random_uuid(), @workspaceId, NULL,
|
|
'docker:schaal-service-' || n, 'schaal-service-' || lpad(n::text, 5, '0'),
|
|
'container', 'running', 1, 'itworx/schaal:' || n, '', '', false, false, false,
|
|
1, 2, @snapshotId, now(), now()
|
|
FROM generate_series(1, @count) AS n
|
|
RETURNING id
|
|
""";
|
|
var serviceIds = new List<Guid>();
|
|
await using (var command = new NpgsqlCommand(servicesSql, connection) { CommandTimeout = 60 })
|
|
{
|
|
command.Parameters.AddWithValue("workspaceId", workspaceId);
|
|
command.Parameters.AddWithValue("snapshotId", snapshotId);
|
|
command.Parameters.AddWithValue("count", VolumeCount);
|
|
await using var reader = await command.ExecuteReaderAsync();
|
|
while (await reader.ReadAsync())
|
|
{
|
|
serviceIds.Add(reader.GetGuid(0));
|
|
}
|
|
}
|
|
|
|
const string volumesSql = """
|
|
INSERT INTO infrastructure_volumes (
|
|
id, workspace_id, service_id, source_key, confidence, last_snapshot_id, observed_at,
|
|
name, mount_path, storage_class, read_only, size_bytes)
|
|
SELECT
|
|
gen_random_uuid(), @workspaceId, s.id, 'volume:main', 2, @snapshotId, now(),
|
|
'data', '/data', '', false, NULL
|
|
FROM infrastructure_services s
|
|
WHERE s.workspace_id = @workspaceId
|
|
""";
|
|
await using (var command = new NpgsqlCommand(volumesSql, connection) { CommandTimeout = 60 })
|
|
{
|
|
command.Parameters.AddWithValue("workspaceId", workspaceId);
|
|
command.Parameters.AddWithValue("snapshotId", snapshotId);
|
|
await command.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
return (workspaceId, serviceIds);
|
|
}
|
|
}
|