340 lines
19 KiB
C#
340 lines
19 KiB
C#
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<KnowledgeHubDbContext>();
|
|
var workspaces = scope.ServiceProvider.GetRequiredService<IWorkspaceRepository>();
|
|
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<UnifiedSearchResponse>("/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<UnifiedSearchResponse>("/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<KnowledgeHubDbContext>();
|
|
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<UnifiedSearchResponse>("/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<UnifiedSearchResponse>("/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<CreatedServiceAccountToken>();
|
|
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<HttpClient> LoginOwnerAsync()
|
|
{
|
|
var owner = Factory.CreateClient();
|
|
await SetupHelper.LoginAsync(owner, OwnerEmail, OwnerPassword);
|
|
return owner;
|
|
}
|
|
|
|
private async Task<HttpClient> 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<SearchCorpusIds> SeedSearchCorpusAsync()
|
|
{
|
|
using var scope = Factory.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
|
var workspaceId = await scope.ServiceProvider.GetRequiredService<IWorkspaceRepository>()
|
|
.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<Guid> AddTagFilterDecoyAsync(Guid workspaceId)
|
|
{
|
|
using var scope = Factory.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
|
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);
|
|
}
|