using AIStudio.Provider; using AIStudio.Settings; using AIStudio.Settings.DataModel; using AIStudio.Tools; using AIStudio.Tools.Databases.IndexStore; using AIStudio.Tools.Databases.VectorStore; using AIStudio.Tools.Security; using AIStudio.Tools.Services; using AIStudio.Tools.Services.Indexing; using Microsoft.Extensions.Logging.Abstractions; namespace AIStudio.Tests.Tools; /// /// Checks that a local data source keeps its index while its file or one of its folders cannot be reached. /// /// /// A drive which is not connected, or a network share while the VPN is down, looks like an empty /// folder to anyone who only lists files. Read that way, every embedding of the data source was /// removed, and once the folder was back, all of it was embedded again, which takes hours for a /// large folder. The vector store of these tests refuses every removal, so a run which removes /// anything fails right away. /// [TestFixture] public sealed class UnreachableDataSourceTests { private const string DATA_SOURCE_ID = "3c9e2f1a-7b4d-4e8a-9f6c-1d2e3f4a5b60"; private const string EMBEDDING_ID = "8a7b6c5d-4e3f-4a1b-9c8d-7e6f5a4b3c21"; private const string STORED_SOURCE_HASH = "STORED-SOURCE-HASH"; private string testRoot = string.Empty; private readonly List lockedDirectories = []; [SetUp] public void CreateTestRoot() { this.testRoot = Path.Combine(Path.GetTempPath(), $"ai-studio-unreachable-{Guid.NewGuid():N}"); this.lockedDirectories.Clear(); } [TearDown] public void DeleteTestRoot() { if (!OperatingSystem.IsWindows()) foreach (var directory in this.lockedDirectories) File.SetUnixFileMode(directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); if (Directory.Exists(this.testRoot)) Directory.Delete(this.testRoot, true); } [Test] public async Task AMissingFolderKeepsItsIndex() { var folder = Path.Combine(this.testRoot, "documents"); var (outcome, statuses, manifest) = await RunAsync(Folder(folder), Path.Combine(folder, "report.pdf"), Path.Combine(folder, "notes.docx")); AssertTheIndexWasKept(outcome, statuses, manifest, 2, folder); } [Test] public async Task AMissingFileKeepsItsIndex() { var file = Path.Combine(this.testRoot, "report.pdf"); var dataSource = new DataSourceLocalFile { Num = 1, Id = DATA_SOURCE_ID, Name = "Test file", Type = DataSourceType.LOCAL_FILE, EmbeddingId = EMBEDDING_ID, ConfidenceLevel = ConfidenceLevel.LOW, FilePath = file, }; var (outcome, statuses, manifest) = await RunAsync(dataSource, file); AssertTheIndexWasKept(outcome, statuses, manifest, 1, file); } [Test] public async Task AFolderWhichCannotBeOpenedKeepsItsIndex() { var folder = Path.Combine(this.testRoot, "documents"); Directory.CreateDirectory(folder); this.TakeAwayTheRightToRead(folder); var (outcome, statuses, manifest) = await RunAsync(Folder(folder), Path.Combine(folder, "report.pdf")); AssertTheIndexWasKept(outcome, statuses, manifest, 1, folder); } [Test] public async Task TheFilesOfASubfolderWhichCannotBeOpenedKeepTheirIndex() { var folder = Path.Combine(this.testRoot, "documents"); var subfolder = Path.Combine(folder, "contracts"); var fileInSubfolder = Path.Combine(subfolder, "contract.pdf"); Directory.CreateDirectory(subfolder); this.TakeAwayTheRightToRead(subfolder); var (outcome, statuses, manifest) = await RunAsync(Folder(folder), fileInSubfolder); Assert.Multiple(() => { Assert.That(outcome, Is.EqualTo(IndexedRunOutcome.DONE)); Assert.That(manifest.Files.Keys, Is.EquivalentTo([fileInSubfolder]), "The file is out of reach, not gone. Once the subfolder can be read again, it is still indexed."); Assert.That(statuses[^1].Failures.Select(failure => failure.DocumentKey), Has.Member(subfolder), "The user has to learn which subfolder could not be read."); }); } [Test] public void OnlyFilesWhichAreGoneAreRemoved() { var folder = Path.Combine(this.testRoot, "documents"); var found = Path.Combine(folder, "found.pdf"); var deleted = Path.Combine(folder, "deleted.pdf"); var subfolder = Path.Combine(folder, "contracts"); var inSubfolder = Path.Combine(subfolder, "2026", "contract.pdf"); var besideSubfolder = Path.Combine(folder, "contracts-old", "contract.pdf"); var goneFiles = FileSourceIndexer.GetGoneFiles([found, deleted, inSubfolder, besideSubfolder], [new FileInfo(found)], [subfolder]); Assert.That(goneFiles, Is.EquivalentTo([deleted, besideSubfolder]), "A folder whose name only begins like the one which could not be read is no part of it."); } private static DataSourceLocalDirectory Folder(string path) => new() { Num = 1, Id = DATA_SOURCE_ID, Name = "Test folder", Type = DataSourceType.LOCAL_DIRECTORY, EmbeddingId = EMBEDDING_ID, ConfidenceLevel = ConfidenceLevel.LOW, Path = path, }; private static void AssertTheIndexWasKept(IndexedRunOutcome outcome, List statuses, DataSourceEmbeddingManifest manifest, int indexedFiles, string unreachablePath) { Assert.Multiple(() => { Assert.That(outcome, Is.EqualTo(IndexedRunOutcome.DONE), "Nothing can be done until the file or the folder is back."); Assert.That(manifest.Files, Has.Count.EqualTo(indexedFiles), "Embedding everything again once the file or the folder is back costs hours."); Assert.That(manifest.PermanentFailures, Has.Count.EqualTo(1), "A file skipped for good would otherwise be read again, only to fail the same way."); Assert.That(manifest.SourceHash, Is.EqualTo(STORED_SOURCE_HASH), "The stored hash tells which state of the data source the index holds."); Assert.That(statuses[^1].State, Is.EqualTo(DataSourceEmbeddingState.FAILED)); Assert.That(statuses[^1].IndexedDocuments, Is.EqualTo(indexedFiles), "The index still holds these files, and the page has to say so."); Assert.That(statuses[^1].LastError, Does.Contain(unreachablePath)); }); } private void TakeAwayTheRightToRead(string directory) { if (OperatingSystem.IsWindows()) Assert.Ignore("Taking away the right to read a folder needs an access control list on Windows."); else File.SetUnixFileMode(directory, UnixFileMode.None); this.lockedDirectories.Add(directory); try { _ = Directory.EnumerateFiles(directory).Any(); Assert.Ignore("This account may read every folder, e.g. as root, so no folder can be locked."); } catch (UnauthorizedAccessException) { // The folder is locked, as intended. } } private static async Task RunAsync(TDataSource dataSource, params string[] indexedFiles) where TDataSource : IDataSource, IIndexedDataSource { var statuses = new List(); var manifest = new DataSourceEmbeddingManifest { SourceHash = STORED_SOURCE_HASH }; foreach (var file in indexedFiles) manifest.Files[file] = new EmbeddedFileRecord("FINGERPRINT", 1024, DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch, 4); var scannedFile = Path.Combine(Path.GetDirectoryName(indexedFiles[0])!, "scan.pdf"); manifest.PermanentFailures[scannedFile] = new PermanentIndexingFailureRecord("FINGERPRINT", FileExtractionErrorCode.NO_TEXT_EXTRACTED, "No text could be read.", DateTimeOffset.UnixEpoch); var embeddingProvider = new EmbeddingProvider(1, EMBEDDING_ID, "Test embeddings", LLMProviders.OPEN_AI, new("text-embedding-3-small", "text-embedding-3-small")); var settingsManager = new SettingsManager(NullLogger.Instance, null!); var context = new IndexedRunContext( dataSource, embeddingProvider, embeddingProvider.CreateProvider(), new NoVectorStoreClient("Test vector store", "Not available in tests."), new NoIndexStoreClient("Test index store", "Not available in tests."), manifest, new RunShare(RunShare.DEFAULT_DURATION), new IndexingPass(new DataSourceRunSignature("TEST-EMBEDDING-SIGNATURE")), new PromptInjectionGuardService(null!, settingsManager, NullLogger.Instance, NullLoggerFactory.Instance).BeginAction(), settingsManager, statuses.Add, NullLogger.Instance); // A run which reaches no file reads none, so it needs neither the runtime nor the chunker: var indexer = new FileSourceIndexer(settingsManager, null!, null!, NullLogger.Instance); var outcome = await indexer.ProcessAsync(context, DataSourceEmbeddingRefreshMode.HASH_CHECK, CancellationToken.None); return new(outcome, statuses, manifest); } private sealed record RunResult(IndexedRunOutcome Outcome, List Statuses, DataSourceEmbeddingManifest Manifest); }