using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using ItWorx.KnowledgeHub.Application.Identity; using ItWorx.KnowledgeHub.Contracts.Admin; using ItWorx.KnowledgeHub.Contracts.Automation; using ItWorx.KnowledgeHub.Contracts.UnifiedSearch; using ItWorx.KnowledgeHub.Domain.Automation; using ItWorx.KnowledgeHub.Domain.Decisions; using ItWorx.KnowledgeHub.Domain.Identity; using ItWorx.KnowledgeHub.Domain.Infrastructure; using ItWorx.KnowledgeHub.Domain.Knowledge; using ItWorx.KnowledgeHub.Domain.Projects; using ItWorx.KnowledgeHub.Domain.Runbooks; using ItWorx.KnowledgeHub.Infrastructure.Persistence; using ItWorx.KnowledgeHub.Infrastructure.UnifiedSearch; using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace ItWorx.KnowledgeHub.IntegrationTests.UnifiedSearch; [Collection(PostgresTestGroup.Name)] public sealed class UnifiedSearchTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres) { [Fact] public async Task Search_returns_workspace_scoped_visible_items_with_full_counts_snippets_and_global_pagination() { using var owner = await LoginOwnerAsync(); var expected = await SeedSearchCorpusAsync(); using var scope = Factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var workspaces = scope.ServiceProvider.GetRequiredService(); var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(CancellationToken.None); Assert.NotNull(workspaceId); var repository = new UnifiedSearchRepository(db); var firstPage = await repository.SearchAsync(workspaceId.Value, "needle", null, null, null, 2, CancellationToken.None); Assert.Equal(new UnifiedSearchCountsResultExpected(1, 1, 1, 1, 1), new UnifiedSearchCountsResultExpected( firstPage.Counts.Knowledge, firstPage.Counts.Projects, firstPage.Counts.Services, firstPage.Counts.Runbooks, firstPage.Counts.Decisions)); Assert.Equal(2, firstPage.Items.Count); Assert.NotNull(firstPage.NextCursor); var allHits = firstPage.Items.ToList(); var cursor = firstPage.NextCursor; while (cursor is not null) { var next = await repository.SearchAsync(workspaceId.Value, "needle", null, null, cursor, 2, CancellationToken.None); Assert.Equal(firstPage.Counts, next.Counts); allHits.AddRange(next.Items); cursor = next.NextCursor; } Assert.Equal(5, allHits.Count); Assert.Equal(5, allHits.Select(item => (item.Kind, item.Id)).Distinct().Count()); Assert.Equal(new[] { "decision", "knowledge", "project", "runbook", "service" }, allHits.Select(item => item.Kind).Order(StringComparer.Ordinal).ToArray()); Assert.All(allHits, item => Assert.Contains("**needle**", item.Snippet, StringComparison.OrdinalIgnoreCase)); Assert.Contains(allHits, item => item.Id == expected.KnowledgePageId && item.Href == $"/knowledge/{expected.KnowledgePageId}/needle-knowledge-guide"); Assert.Contains(allHits, item => item.Id == expected.ProjectId && item.Href == $"/projects/{expected.ProjectId}"); Assert.Contains(allHits, item => item.Id == expected.ServiceId && item.Href == $"/infrastructure/services/{expected.ServiceId}"); Assert.Contains(allHits, item => item.Id == expected.RunbookId && item.Href == $"/runbooks/{expected.RunbookId}"); Assert.Contains(allHits, item => item.Id == expected.DecisionId && item.Href == $"/projects/{expected.ProjectId}/decisions/{expected.DecisionId}"); using var reader = await CreateReaderClientAsync(owner); var firstHttpPage = await reader.GetFromJsonAsync("/api/v1/unified-search?q=needle&limit=2"); Assert.NotNull(firstHttpPage); Assert.Equal(2, firstHttpPage.Items.Count); Assert.Equal(1, firstHttpPage.Counts.Knowledge); Assert.Equal(1, firstHttpPage.Counts.Projects); Assert.Equal(1, firstHttpPage.Counts.Services); Assert.Equal(1, firstHttpPage.Counts.Runbooks); Assert.Equal(1, firstHttpPage.Counts.Decisions); Assert.NotNull(firstHttpPage.NextCursor); var serviceHttpPage = await reader.GetFromJsonAsync("/api/v1/unified-search?q=needle&kind=services&limit=2"); Assert.NotNull(serviceHttpPage); Assert.Single(serviceHttpPage.Items); Assert.Equal("services", serviceHttpPage.Items[0].Kind); Assert.Equal(5, serviceHttpPage.Counts.Total); using var invalidKind = await reader.GetAsync("/api/v1/unified-search?q=needle&kind=service"); Assert.Equal(HttpStatusCode.UnprocessableEntity, invalidKind.StatusCode); Assert.DoesNotContain(allHits, item => item.Title.Contains("hidden", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(allHits, item => item.Title.Contains("archived", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(allHits, item => item.Title.Contains("proposed", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(allHits, item => item.Title.Contains("trashed", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(allHits, item => item.Title.Contains("foreign", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(allHits, item => item.Snippet.Contains("hidden secret", StringComparison.OrdinalIgnoreCase)); } [Fact] public async Task Tag_only_search_filters_visible_page_kinds_and_binds_cursors_to_the_normalized_tag() { using var owner = await LoginOwnerAsync(); var expected = await SeedSearchCorpusAsync(); var decoyPageId = await AddTagFilterDecoyAsync(expected.WorkspaceId); using var scope = Factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var repository = new UnifiedSearchRepository(db); var firstPage = await repository.SearchAsync( expected.WorkspaceId, string.Empty, " TOPIC ", null, null, 1, CancellationToken.None); Assert.Equal(1, firstPage.Counts.Knowledge); Assert.Equal(0, firstPage.Counts.Projects); Assert.Equal(0, firstPage.Counts.Services); Assert.Equal(1, firstPage.Counts.Runbooks); Assert.Equal(1, firstPage.Counts.Decisions); Assert.Single(firstPage.Items); Assert.Equal(expected.DecisionId, firstPage.Items[0].Id); Assert.NotNull(firstPage.NextCursor); // Casing and surrounding whitespace normalize to the same tag, so this cursor // continues the original page instead of repeating its first row. var nextPage = await repository.SearchAsync( expected.WorkspaceId, string.Empty, "topic", null, firstPage.NextCursor, 2, CancellationToken.None); Assert.Equal(2, nextPage.Items.Count); Assert.Null(nextPage.NextCursor); Assert.DoesNotContain(nextPage.Items, item => item.Id == firstPage.Items[0].Id); Assert.Contains(nextPage.Items, item => item.Id == expected.KnowledgePageId); Assert.Contains(nextPage.Items, item => item.Id == expected.RunbookId); Assert.Equal(firstPage.Counts, nextPage.Counts); // A cursor from the broader topic result must not skip the only item in a // different tag result, even when that item is the same decision page. var differentTag = await repository.SearchAsync( expected.WorkspaceId, string.Empty, "DECISION-ONLY", null, firstPage.NextCursor, 2, CancellationToken.None); Assert.Single(differentTag.Items); Assert.Equal(expected.DecisionId, differentTag.Items[0].Id); Assert.Equal(0, differentTag.Counts.Knowledge); Assert.Equal(0, differentTag.Counts.Projects); Assert.Equal(0, differentTag.Counts.Services); Assert.Equal(0, differentTag.Counts.Runbooks); Assert.Equal(1, differentTag.Counts.Decisions); var exactTagWithText = await repository.SearchAsync( expected.WorkspaceId, "needle", "TOPIC", null, null, 10, CancellationToken.None); Assert.Equal(1, exactTagWithText.Counts.Knowledge); Assert.Equal(0, exactTagWithText.Counts.Projects); Assert.Equal(0, exactTagWithText.Counts.Services); Assert.Equal(1, exactTagWithText.Counts.Runbooks); Assert.Equal(1, exactTagWithText.Counts.Decisions); Assert.DoesNotContain(exactTagWithText.Items, item => item.Id == decoyPageId); using var reader = await CreateReaderClientAsync(owner); var tagOnlyHttp = await reader.GetFromJsonAsync("/api/v1/unified-search?tag=TOPIC&limit=10"); Assert.NotNull(tagOnlyHttp); Assert.Equal(3, tagOnlyHttp.Items.Count); Assert.Equal(1, tagOnlyHttp.Counts.Knowledge); Assert.Equal(0, tagOnlyHttp.Counts.Projects); Assert.Equal(0, tagOnlyHttp.Counts.Services); Assert.Equal(1, tagOnlyHttp.Counts.Runbooks); Assert.Equal(1, tagOnlyHttp.Counts.Decisions); var textAndTagHttp = await reader.GetFromJsonAsync("/api/v1/unified-search?q=needle&tag=TOPIC&limit=10"); Assert.NotNull(textAndTagHttp); Assert.Equal(3, textAndTagHttp.Items.Count); Assert.DoesNotContain(textAndTagHttp.Items, item => item.Id == decoyPageId); } [Fact] public async Task Unified_search_requires_a_cookie_reader_and_does_not_grant_unified_access_to_knowledge_search_tokens() { using var owner = await LoginOwnerAsync(); using var reader = await CreateReaderClientAsync(owner); using var readerResponse = await reader.GetAsync("/api/v1/unified-search?q=scope-probe"); Assert.Equal(HttpStatusCode.OK, readerResponse.StatusCode); var tokenResponse = await SetupHelper.PostWithCsrfAsync( owner, "/api/v1/service-accounts", new CreateServiceAccountRequest($"unified-search-{Guid.NewGuid():N}", "Search authorization test.", [Scopes.KnowledgeSearch])); Assert.Equal(HttpStatusCode.OK, tokenResponse.StatusCode); var token = await tokenResponse.Content.ReadFromJsonAsync(); Assert.NotNull(token); using var bearer = Factory.CreateClient(); bearer.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.RawToken); using var existingKnowledgeSearch = await bearer.GetAsync("/api/v1/search?q=scope-probe"); Assert.Equal(HttpStatusCode.OK, existingKnowledgeSearch.StatusCode); using var unifiedSearch = await bearer.GetAsync("/api/v1/unified-search?q=scope-probe"); Assert.Equal(HttpStatusCode.Unauthorized, unifiedSearch.StatusCode); using var anonymous = Factory.CreateClient(); using var anonymousResponse = await anonymous.GetAsync("/api/v1/unified-search?q=scope-probe"); Assert.Equal(HttpStatusCode.Unauthorized, anonymousResponse.StatusCode); } [Fact] public async Task Unified_search_rate_limits_a_reader_after_sixty_requests_per_minute() { using var owner = await LoginOwnerAsync(); using var reader = await CreateReaderClientAsync(owner); for (var requestNumber = 1; requestNumber <= 61; requestNumber++) { using var response = await reader.GetAsync("/api/v1/unified-search?q="); var expected = requestNumber <= 60 ? HttpStatusCode.OK : HttpStatusCode.TooManyRequests; Assert.Equal(expected, response.StatusCode); } } private async Task LoginOwnerAsync() { var owner = Factory.CreateClient(); await SetupHelper.LoginAsync(owner, OwnerEmail, OwnerPassword); return owner; } private async Task CreateReaderClientAsync(HttpClient owner) { var email = $"reader-{Guid.NewGuid():N}@itworx.test"; var created = await SetupHelper.PostWithCsrfAsync( owner, "/api/v1/admin/users", new CreateUserRequest("Unified search Reader", email, "reader-strong-password", "Reader")); Assert.Equal(HttpStatusCode.OK, created.StatusCode); var reader = Factory.CreateClient(); await SetupHelper.LoginAsync(reader, email, "reader-strong-password"); return reader; } private async Task SeedSearchCorpusAsync() { using var scope = Factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var workspaceId = await scope.ServiceProvider.GetRequiredService() .GetDefaultWorkspaceIdAsync(CancellationToken.None); Assert.NotNull(workspaceId); var actorId = await db.Users.AsNoTracking() .Where(user => user.Email == OwnerEmail) .Select(user => user.Id) .SingleAsync(); var now = DateTimeOffset.UtcNow; var project = Project.Create(workspaceId.Value, "Needle project", "Project summary for needle search.", ProjectPriority.Medium, "Project state needle.", actorId, actorId, now); var knowledgePage = CreatePage(workspaceId.Value, actorId, now, "Needle knowledge guide", DocumentType.WikiPage, "Knowledge body with needle details."); var trashedPage = CreatePage(workspaceId.Value, actorId, now, "Needle trashed entry", DocumentType.WikiPage, "This trashed record must not appear."); trashedPage.Trash(actorId, now, TimeSpan.FromDays(30)); var runbookPage = CreatePage(workspaceId.Value, actorId, now, "Needle runbook procedure", DocumentType.Runbook, "Runbook body with needle procedure details."); var runbook = RunbookProfile.Create(runbookPage.Id, actorId, RunbookRiskLevel.Low, false, 20, null, now); var decisionPage = CreatePage(workspaceId.Value, actorId, now, "Needle decision record", DocumentType.ArchitectureDecision, "Decision body with needle context."); knowledgePage.ReplaceTags(["Topic"]); trashedPage.ReplaceTags(["TOPIC"]); runbookPage.ReplaceTags(["topic"]); decisionPage.ReplaceTags(["tOpIc", "Decision-Only"]); var decision = DecisionRecord.Create(workspaceId.Value, project.Id, decisionPage.Id, 1, DateOnly.FromDateTime(now.UtcDateTime), "Decision summary for needle search.", null, actorId, now); var foreignWorkspace = Workspace.Create(WorkspaceName.Create("Second search workspace"), now); var foreignPage = CreatePage(foreignWorkspace.Id, actorId, now, "Needle foreign page", DocumentType.WikiPage, "Foreign workspace content with needle."); foreignPage.ReplaceTags(["Topic"]); var snapshot = InventorySnapshot.Create(workspaceId.Value, $"unified-search-{Guid.NewGuid():N}", Guid.NewGuid().ToString("N"), now, now, 0, 4); var visibleService = CreateService(workspaceId.Value, snapshot.Id, "needle-service-visible", "Needle visible service", actorId, now, reviewed: true); var hiddenService = CreateService(workspaceId.Value, snapshot.Id, "needle-service-hidden", "Needle hidden service", actorId, now, reviewed: true); var archivedService = CreateService(workspaceId.Value, snapshot.Id, "needle-service-archived", "Needle archived service", actorId, now, reviewed: true); var proposedService = CreateService(workspaceId.Value, snapshot.Id, "needle-service-proposed", "Needle proposed service", actorId, now, reviewed: false); var visibleProfile = CreateServiceProfile(workspaceId.Value, visibleService.Id, actorId, now, isHidden: false, isArchived: false); var hiddenProfile = CreateServiceProfile(workspaceId.Value, hiddenService.Id, actorId, now, isHidden: true, isArchived: false); var archivedProfile = CreateServiceProfile(workspaceId.Value, archivedService.Id, actorId, now, isHidden: false, isArchived: true); db.Workspaces.Add(foreignWorkspace); db.Projects.Add(project); db.Pages.AddRange(knowledgePage, trashedPage, runbookPage, decisionPage, foreignPage); db.RunbookProfiles.Add(runbook); db.DecisionRecords.Add(decision); db.InventorySnapshots.Add(snapshot); db.InfrastructureServices.AddRange(visibleService, hiddenService, archivedService, proposedService); db.EditorialProfiles.AddRange(visibleProfile, hiddenProfile, archivedProfile); await db.SaveChangesAsync(); return new SearchCorpusIds(workspaceId.Value, knowledgePage.Id, project.Id, visibleService.Id, runbook.Id, decision.Id); } private async Task AddTagFilterDecoyAsync(Guid workspaceId) { using var scope = Factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var actorId = await db.Users.AsNoTracking() .Where(user => user.Email == OwnerEmail) .Select(user => user.Id) .SingleAsync(); var now = DateTimeOffset.UtcNow; var decoy = CreatePage(workspaceId, actorId, now, "Needle tag decoy", DocumentType.WikiPage, "A needle page tagged with a longer, non-matching label."); decoy.ReplaceTags(["topic-extra"]); db.Pages.Add(decoy); await db.SaveChangesAsync(); return decoy.Id; } private static Page CreatePage(Guid workspaceId, Guid actorId, DateTimeOffset now, string title, DocumentType type, string markdown) => Page.Create(workspaceId, null, SortKey.First(), PageTitle.Create(title), type, DocumentStatus.Active, markdown, actorId, now); private static InfrastructureService CreateService( Guid workspaceId, Guid snapshotId, string sourceKey, string name, Guid actorId, DateTimeOffset now, bool reviewed) { var service = InfrastructureService.Create(workspaceId, sourceKey, name, now, snapshotId); service.Refresh(null, name, "container", "running", HealthState.Healthy, "example/needle:latest", null, string.Empty, false, false, false, FactConfidence.High, snapshotId, now); if (reviewed) { service.Confirm(); } return service; } private static EditorialProfile CreateServiceProfile( Guid workspaceId, Guid serviceId, Guid actorId, DateTimeOffset now, bool isHidden, bool isArchived) { var profile = EditorialProfile.Create(workspaceId, InfrastructureObjectKind.Service, serviceId, actorId, now); profile.Update("Needle service profile", "Editorial needle summary.", "Infrastructure", "Purpose with needle context.", null, null, null, null, null, null, null, null, null, null, ["needle"], false, isHidden, isArchived, actorId, now); return profile; } private sealed record SearchCorpusIds(Guid WorkspaceId, Guid KnowledgePageId, Guid ProjectId, Guid ServiceId, Guid RunbookId, Guid DecisionId); private sealed record UnifiedSearchCountsResultExpected(int Knowledge, int Projects, int Services, int Runbooks, int Decisions); }