using BuildFlow.Application.Obsidian.Commands; using BuildFlow.Application.Obsidian.Handlers; using BuildFlow.Domain.Entities.KnowledgeBase; using BuildFlow.Infrastructure.Obsidian; using BuildFlow.Infrastructure.Persistence; using BuildFlow.Rag.Tests.Stub; using BuildFlow.Shared.Domain.Errors; using BuildFlow.Shared.Domain.Exceptions; using BuildFlow.Shared.Infrastructure; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Xunit; namespace BuildFlow.Rag.Tests; /// /// Obsidian 同步的知识库所有权和增量行为测试。 /// public sealed class ObsidianSyncSecurityTests : IDisposable { private readonly string _allowedRoot = Directory.CreateDirectory(Path.Combine( Path.GetTempPath(), $"buildflow-obsidian-sync-{Guid.NewGuid():N}")).FullName; [Fact] public async Task NonOwnerShouldReceiveNotFoundWithoutScanningFiles() { var owner = new TestCurrentUserContext { UserId = Guid.NewGuid().ToString() }; await using var db = CreateDb(owner); var kb = new KnowledgeBaseEntity { Name = "private" }; db.KnowledgeBases.Add(kb); await db.SaveChangesAsync(); var caller = new TestCurrentUserContext { UserId = Guid.NewGuid().ToString() }; var handler = new SyncObsidianVaultCommandHandler(db, caller, CreateScanner()); var act = () => handler.Handle( new SyncObsidianVaultCommand(kb.Id, _allowedRoot), CancellationToken.None); await act.Should().ThrowAsync() .Where(x => x.Code == ErrorCode.NotFound); db.Documents.Should().BeEmpty(); } [Fact] public async Task OwnerSyncShouldAddThenSkipAnUnchangedFile() { var caller = new TestCurrentUserContext(); await using var db = CreateDb(caller); var kb = new KnowledgeBaseEntity { Name = "owned" }; db.KnowledgeBases.Add(kb); await db.SaveChangesAsync(); await File.WriteAllTextAsync(Path.Combine(_allowedRoot, "note.md"), "hello"); var handler = new SyncObsidianVaultCommandHandler(db, caller, CreateScanner()); var first = await handler.Handle( new SyncObsidianVaultCommand(kb.Id, _allowedRoot), CancellationToken.None); var second = await handler.Handle( new SyncObsidianVaultCommand(kb.Id, _allowedRoot), CancellationToken.None); first.Added.Should().Be(1); second.Skipped.Should().Be(1); db.Documents.Should().ContainSingle(); } private static RagDbContext CreateDb(TestCurrentUserContext userContext) { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase($"obsidian-sync-{Guid.NewGuid():N}") .AddInterceptors(new AuditInterceptor(userContext)) .Options; return new RagDbContext(options); } private ObsidianVaultScanner CreateScanner() { return new ObsidianVaultScanner( Options.Create(new ObsidianOptions { AllowedRoots = [_allowedRoot], MaxFiles = 100, MaxFileBytes = 1024 * 1024, MaxTotalBytes = 10 * 1024 * 1024, }), NullLogger.Instance); } public void Dispose() { if (Directory.Exists(_allowedRoot)) Directory.Delete(_allowedRoot, recursive: true); } }