Added backups of all data, restorable on another computer (#1008)

Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
j-erlerandThorsten Sommer authored and GitHub committed 2026-10-15 16:42:02 +02:00
1 parent ab45580473
commit 936043319c
144 files changed
+10417 -424

No files matched your search

+106
View File
@@ -0,0 +1,106 @@
using AIStudio.Chat;
namespace AIStudio.Tests.Chat;
/// <summary>
/// Checks that rewriting the file paths of a chat reaches every one of them, and nothing else.
/// </summary>
/// <remarks>
/// Moving a chat, copying it, and writing it into a backup or reading it back all change the paths
/// a chat stores. A path left out points at a place where the file is no longer, and the file drops
/// out of the chat without a word: a transcript of the message which is still being written, or an
/// image the model no longer gets to see.
/// </remarks>
[TestFixture]
public sealed class ChatThreadFilePathTests
{
private static readonly DateTimeOffset START = new(2026, 10, 14, 9, 0, 0, TimeSpan.Zero);
private static readonly string OLD_DIRECTORY = Path.Combine(Path.GetTempPath(), "old-chat");
private static readonly string NEW_DIRECTORY = Path.Combine(Path.GetTempPath(), "new-chat");
[Test]
public void EveryPathOfTheChatIsRewritten()
{
var document = new FileAttachment(FileAttachmentType.DOCUMENT, "report.pdf", Old("report.pdf"), 1024);
var transcript = new ManagedTranscriptAttachment("meeting.md", Old("meeting.md"), 512, "meeting.mp3", false);
var picture = new FileAttachmentImage("chart.png", Old("chart.png"), 2048);
var photo = new ContentImage { SourceType = ContentImageSource.LOCAL_PATH, Source = Old("photo.jpg") };
var chat = new ChatThread
{
ChatId = Guid.NewGuid(),
Name = "Quarterly report",
Blocks = [Block(new ContentText { Text = "Summarize the report", FileAttachments = [document, transcript, picture] }), Block(photo)],
};
chat.Draft.AddAttachments([new ManagedTranscriptAttachment("call.md", Old("call.md"), 256, "call.m4a", false)]);
chat.RewriteFilePaths(Move);
var message = (ContentText)chat.Blocks[0].Content!;
Assert.Multiple(() =>
{
Assert.That(message.FileAttachments.Select(attachment => attachment.FilePath), Is.EqualTo(new[] { New("report.pdf"), New("meeting.md"), New("chart.png") }));
Assert.That(photo.Source, Is.EqualTo(New("photo.jpg")), "The model gets such an image from its path.");
Assert.That(chat.Draft.FileAttachments.Select(attachment => attachment.FilePath), Is.EqualTo(new[] { New("call.md") }), "The draft is the one place every path rewrite once forgot.");
});
}
[Test]
public void WhatTheRewriteLeavesAloneStaysAsItIs()
{
var elsewhere = new FileAttachment(FileAttachmentType.DOCUMENT, "notes.txt", Path.Combine(Path.GetTempPath(), "elsewhere", "notes.txt"), 64);
var linked = new ContentImage { SourceType = ContentImageSource.URL, Source = "https://example.org/chart.png" };
var chat = new ChatThread
{
ChatId = Guid.NewGuid(),
Name = "Notes",
Blocks = [Block(new ContentText { Text = "Read my notes", FileAttachments = [elsewhere] }), Block(linked)],
};
chat.RewriteFilePaths(Move);
Assert.Multiple(() =>
{
Assert.That(((ContentText)chat.Blocks[0].Content!).FileAttachments.Single(), Is.SameAs(elsewhere));
Assert.That(linked.Source, Is.EqualTo("https://example.org/chart.png"), "Only an image held by a local path has a path to rewrite.");
});
}
[Test]
public void AnAttachmentKeepsItsKindAtItsNewPlace()
{
var transcript = new ManagedTranscriptAttachment("meeting.md", Old("meeting.md"), 512, "meeting.mp3", true);
var picture = new FileAttachmentImage("chart.png", Old("chart.png"), 2048);
var document = new FileAttachment(FileAttachmentType.DOCUMENT, "report.pdf", Old("report.pdf"), 1024);
var movedTranscript = transcript.WithFilePath(New("meeting.md"));
var movedPicture = picture.WithFilePath(New("chart.png"));
var movedDocument = document.WithFilePath(New("report.pdf"));
Assert.Multiple(() =>
{
Assert.That(movedTranscript, Is.EqualTo(new ManagedTranscriptAttachment("meeting.md", New("meeting.md"), 512, "meeting.mp3", true)), "A transcript has to stay a transcript, or deleting the chat leaves its file behind.");
Assert.That(movedPicture, Is.TypeOf<FileAttachmentImage>());
Assert.That(((FileAttachmentImage)movedPicture).Source, Is.EqualTo(New("chart.png")), "The model gets the image from its source, not from its path.");
Assert.That(movedPicture.FilePathAsUrl, Is.EqualTo(new FileAttachmentImage("chart.png", New("chart.png"), 2048).FilePathAsUrl), "The preview of the image loads it from there.");
Assert.That(movedDocument, Is.EqualTo(new FileAttachment(FileAttachmentType.DOCUMENT, "report.pdf", New("report.pdf"), 1024)));
});
}
private static string Old(string fileName) => Path.Combine(OLD_DIRECTORY, fileName);
private static string New(string fileName) => Path.Combine(NEW_DIRECTORY, fileName);
private static string Move(string path) => path.StartsWith(OLD_DIRECTORY, StringComparison.Ordinal)
? Path.Combine(NEW_DIRECTORY, Path.GetRelativePath(OLD_DIRECTORY, path))
: path;
private static ContentBlock Block(IContent content) => new()
{
Time = START,
ContentType = content is ContentImage ? ContentType.IMAGE : ContentType.TEXT,
Content = content,
Role = ChatRole.USER,
};
}
@@ -0,0 +1,104 @@
using AIStudio.Chat;
using AIStudio.Settings;
namespace AIStudio.Tests.Chat;
/// <summary>
/// Checks which transcript files AI Studio deletes as its own.
/// </summary>
/// <remarks>
/// A transcript carries a full path, which a chat file edited outside the app may point anywhere. AI
/// Studio therefore deletes a file only where it keeps transcripts itself: in the transcript folder of a
/// chat, or in the staging area.
/// </remarks>
[TestFixture]
[NonParallelizable]
public sealed class ManagedTranscriptAttachmentTests
{
private string? previousDataDirectory;
private string testDirectory = string.Empty;
[SetUp]
public void PrepareDataDirectory()
{
// The data directory is static state of the whole application, which is why this fixture does not run alongside others:
this.previousDataDirectory = SettingsManager.DataDirectory;
this.testDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-transcripts-{Guid.NewGuid():N}");
Directory.CreateDirectory(this.testDirectory);
SettingsManager.DataDirectory = this.testDirectory;
}
[TearDown]
public void RemoveDataDirectory()
{
SettingsManager.DataDirectory = this.previousDataDirectory;
try
{
Directory.Delete(this.testDirectory, true);
}
catch (IOException)
{
// A temporary directory we could not remove says nothing about the code under test.
}
}
[Test]
public void TheTranscriptOfAWorkspaceChatIsDeleted()
{
var chatDirectory = Path.Combine(this.testDirectory, "workspaces", Guid.NewGuid().ToString(), Guid.NewGuid().ToString());
var transcript = WriteTranscript(ManagedTranscriptAttachment.GetTranscriptDirectory(chatDirectory));
Assert.Multiple(() =>
{
Assert.That(ManagedTranscriptAttachment.TryDeleteOwnedFile(transcript), Is.True);
Assert.That(File.Exists(transcript.FilePath), Is.False);
});
}
[Test]
public void AFileInAnotherFolderOfTheChatStays()
{
var chatDirectory = Path.Combine(this.testDirectory, "workspaces", Guid.NewGuid().ToString(), Guid.NewGuid().ToString());
var file = WriteTranscript(Path.Combine(chatDirectory, "attachments", "images"));
Assert.Multiple(() =>
{
Assert.That(ManagedTranscriptAttachment.TryDeleteOwnedFile(file), Is.False, "Only the transcript folder belongs to the transcripts.");
Assert.That(File.Exists(file.FilePath), Is.True);
});
}
[Test]
public void AFileOutsideTheDataDirectoryStays()
{
var outsideDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-elsewhere-{Guid.NewGuid():N}");
try
{
var file = WriteTranscript(ManagedTranscriptAttachment.GetTranscriptDirectory(Path.Combine(outsideDirectory, "tempChats", Guid.NewGuid().ToString())));
Assert.Multiple(() =>
{
Assert.That(ManagedTranscriptAttachment.TryDeleteOwnedFile(file), Is.False, "The same folders elsewhere belong to somebody else.");
Assert.That(File.Exists(file.FilePath), Is.True);
});
}
finally
{
Directory.Delete(outsideDirectory, true);
}
}
/// <summary>
/// Writes a transcript into a folder.
/// </summary>
/// <param name="directory">Where the transcript goes.</param>
/// <returns>The transcript, as a chat attaches it to a message.</returns>
private static ManagedTranscriptAttachment WriteTranscript(string directory)
{
Directory.CreateDirectory(directory);
var path = Path.Combine(directory, "meeting-transcript-0001.md");
File.WriteAllText(path, "# Transcription: meeting.mp4");
return new ManagedTranscriptAttachment(Path.GetFileName(path), path, new FileInfo(path).Length, "meeting.mp4", false);
}
}
@@ -0,0 +1,200 @@
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using AIStudio.Chat;
using AIStudio.Settings;
using AIStudio.Tools;
using AIStudio.Tools.ChatArchive;
using AIStudio.Tools.DataBackup;
namespace AIStudio.Tests.Tools.DataBackup;
/// <summary>
/// Checks adding the chats of a backup to the chats of another computer.
/// </summary>
/// <remarks>
/// A backup is written on one computer and read on another, whose data folder lies elsewhere. Its
/// chats have to arrive with their transcripts, which the chats name by a path relative to the data
/// folder, and a chat which exists already must not turn into a second chat with the same ID.
/// </remarks>
[TestFixture]
[NonParallelizable]
public sealed class BackupChatImportTests
{
private string? previousDataDirectory;
private string testDirectory = string.Empty;
private string SourceDataDirectory => Path.Combine(this.testDirectory, "source");
private string TargetDataDirectory => Path.Combine(this.testDirectory, "target");
private string ArchivePath => Path.Combine(this.testDirectory, "backup.mwbackup");
[SetUp]
public void RememberDataDirectory()
{
this.previousDataDirectory = SettingsManager.DataDirectory;
this.testDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-backup-chat-import-{Guid.NewGuid():N}");
Directory.CreateDirectory(this.testDirectory);
}
[TearDown]
public void RemoveDataDirectories()
{
SettingsManager.DataDirectory = this.previousDataDirectory;
WorkspaceBehaviour.TestDataDirectory = null;
// Whoever reads the cache next must not get the workspaces of this test:
WorkspaceBehaviour.InvalidateWorkspaceTreeCache();
try
{
Directory.Delete(this.testDirectory, true);
}
catch (IOException)
{
// A temporary directory we could not remove says nothing about the code under test.
}
}
[Test]
public async Task TheChatsOfABackupArriveWithTheirTranscripts()
{
var storedChat = await this.WriteBackupWithAChatAsync();
await UseDataDirectoryAsync(this.TargetDataDirectory);
var preview = await ChatArchiveImporter.ReadPreviewAsync(this.ArchivePath, CancellationToken.None);
var result = await ChatArchiveImporter.ImportAsync(this.ArchivePath, ChatArchiveCollisionBehavior.SKIP, null, CancellationToken.None);
var chat = await WorkspaceBehaviour.LoadChatAsync(new(storedChat.WorkspaceId, storedChat.ChatId));
var transcript = ((ContentText)chat!.Blocks.Single().Content!).FileAttachments.Single();
Assert.Multiple(() =>
{
Assert.That(preview.Success, Is.True, preview.Issue);
Assert.That(preview.Manifest.Workspaces.Select(workspace => workspace.Name), Is.EqualTo(new[] { "Projects" }));
Assert.That(preview.Manifest.Workspaces.Single().Chats.Select(listedChat => listedChat.Name), Is.EqualTo(new[] { "Meeting notes" }));
Assert.That(result.ImportedChats, Is.EqualTo(1));
Assert.That(result.ImportedWorkspaces, Is.EqualTo(1));
Assert.That(transcript.FilePath, Does.StartWith(WorkspaceBehaviour.GetChatDirectory(storedChat.WorkspaceId, storedChat.ChatId)),"The transcript belongs to the chat on this computer, not to the data folder of the other one.");
Assert.That(File.ReadAllText(transcript.FilePath), Does.Contain("Hello and welcome."));
});
}
[Test]
public async Task AChatWhichExistsAlreadyIsKept()
{
await this.WriteBackupWithAChatAsync();
await UseDataDirectoryAsync(this.TargetDataDirectory);
await ChatArchiveImporter.ImportAsync(this.ArchivePath, ChatArchiveCollisionBehavior.SKIP, null, CancellationToken.None);
var secondResult = await ChatArchiveImporter.ImportAsync(this.ArchivePath, ChatArchiveCollisionBehavior.SKIP, null, CancellationToken.None);
Assert.Multiple(() =>
{
Assert.That(secondResult.ImportedChats, Is.Zero);
Assert.That(secondResult.SkippedChats, Is.EqualTo(1), "Two chats with one ID would leave the app unable to tell them apart.");
});
}
[Test]
public async Task ABackupOfANewerFormatIsRefused()
{
var manifest = new DataBackupManifest(DataBackupFormat.FORMAT_VERSION + 1, "99.0.0", "V9", DateTimeOffset.UtcNow, "/data", "/config", false, []);
await using (var archiveStream = File.Create(this.ArchivePath))
using (var archive = new ZipArchive(archiveStream, ZipArchiveMode.Create))
{
await using var manifestStream = archive.CreateEntry(DataBackupFormat.MANIFEST_ENTRY_NAME).Open();
await JsonSerializer.SerializeAsync(manifestStream, manifest, DataBackupFormat.JSON_OPTIONS);
}
var preview = await ChatArchiveImporter.ReadPreviewAsync(this.ArchivePath, CancellationToken.None);
Assert.Multiple(() =>
{
Assert.That(preview.Success, Is.False, "This version cannot know what a later format holds.");
Assert.That(preview.Issue, Is.Not.Empty);
});
}
[Test]
public async Task AZipFileWithoutManifestIsNoBackup()
{
await using (var archiveStream = File.Create(this.ArchivePath))
using (var archive = new ZipArchive(archiveStream, ZipArchiveMode.Create))
{
await using var entryStream = archive.CreateEntry("notes.txt").Open();
await entryStream.WriteAsync(Encoding.UTF8.GetBytes("Not a backup."));
}
var preview = await ChatArchiveImporter.ReadPreviewAsync(this.ArchivePath, CancellationToken.None);
Assert.That(preview.Success, Is.False);
}
/// <summary>
/// Stores a chat with a transcript in a workspace of the source computer, and backs up its data folder.
/// </summary>
private async Task<StoredChat> WriteBackupWithAChatAsync()
{
await UseDataDirectoryAsync(this.SourceDataDirectory);
var workspace = await WorkspaceBehaviour.TryCreateWorkspaceAsync("Projects");
Assert.That(workspace.Success, Is.True, "The workspace could not be created.");
var workspaceId = workspace.Workspace.WorkspaceId;
var chatId = Guid.NewGuid();
var transcriptDirectory = Path.Combine(WorkspaceBehaviour.GetChatDirectory(workspaceId, chatId), "attachments", "transcripts");
Directory.CreateDirectory(transcriptDirectory);
var transcriptPath = Path.Combine(transcriptDirectory, "meeting-transcript-0001.md");
await File.WriteAllTextAsync(transcriptPath, "# Transcription: meeting.mp4\n\nHello and welcome.");
var chat = new ChatThread
{
WorkspaceId = workspaceId,
ChatId = chatId,
Name = "Meeting notes",
Blocks =
[
new ContentBlock
{
Time = DateTimeOffset.UtcNow,
ContentType = ContentType.TEXT,
Content = new ContentText { Text = "Summarize the meeting", FileAttachments = [new ManagedTranscriptAttachment("meeting-transcript-0001.md", transcriptPath, new FileInfo(transcriptPath).Length, "meeting.mp4", false)] },
Role = ChatRole.USER,
},
],
};
Assert.That(await WorkspaceBehaviour.StoreChatAsync(chat), Is.True, "The chat could not be stored.");
var sources = new DataBackupSources(this.SourceDataDirectory, Path.Combine(this.testDirectory, "config"), "{}", string.Empty, []);
var manifest = new DataBackupManifest(DataBackupFormat.FORMAT_VERSION, "26.10.1", "V6", DateTimeOffset.UtcNow, this.SourceDataDirectory, string.Empty, false, []);
await using (var archiveStream = File.Create(this.ArchivePath))
await DataBackupWriter.WriteAsync(archiveStream, sources, manifest, null, CancellationToken.None);
return new(workspaceId, chatId);
}
/// <summary>
/// Makes the given folder the data folder, as another computer would have it.
/// </summary>
/// <remarks>
/// The data directory is static state of the whole application, which is why this fixture does
/// not run alongside others. The workspace behaviour keeps the roots of the workspaces and of the
/// temporary chats fixed, so it gets told about it separately, and its cache starts over.
/// </remarks>
private static async Task UseDataDirectoryAsync(string dataDirectory)
{
Directory.CreateDirectory(dataDirectory);
SettingsManager.DataDirectory = dataDirectory;
WorkspaceBehaviour.TestDataDirectory = dataDirectory;
await WorkspaceBehaviour.ForceReloadWorkspaceTreeAsync();
}
/// <summary>
/// A chat as the source computer stored it.
/// </summary>
private sealed record StoredChat(Guid WorkspaceId, Guid ChatId);
}
@@ -0,0 +1,110 @@
using AIStudio.Tools.DataBackup;
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Tests.Tools.DataBackup;
/// <summary>
/// Checks the list of what AI Studio keeps in its folders, and which of it belongs into a backup.
/// </summary>
/// <remarks>
/// A backup takes along only what the list includes, and a restore replaces only that. An entry
/// listed twice, or a pattern among the included entries, would leave both open to guessing. The
/// plugins an organization deployed among the user's own ones do not show by their folder, so the
/// list has to find them by what they declare about themselves.
/// </remarks>
[TestFixture]
public sealed class DataBackupContentsTests
{
private static readonly string DATA_DIRECTORY = Path.Combine(Path.GetTempPath(), "ai-studio-backup-contents");
[Test]
public void EveryEntryNamesItsPlaceOnce()
{
var places = DataBackupContents.ENTRIES.Select(entry => $"{entry.Location}:{entry.RelativePath}").ToList();
Assert.That(places, Is.Unique);
}
[Test]
public void AnIncludedEntryNamesOnePlaceExactly()
{
var paths = DataBackupContents.ENTRIES.Where(entry => entry.IsIncluded).Select(entry => entry.RelativePath).ToList();
Assert.Multiple(() =>
{
Assert.That(paths.Where(path => path.Contains('*')), Is.Empty, "A restore replaces exactly what the backup holds, so it has to know the names.");
Assert.That(paths.Where(path => path.Contains('\\')), Is.Empty, "Paths use forward slashes on every OS.");
Assert.That(paths.Where(path => path.StartsWith(DataBackupContents.SEPARATOR)), Is.Empty, "Paths are relative to their folder.");
});
}
[Test]
public void EveryKindAppearsInTheList()
{
var listedKinds = DataBackupContents.ENTRIES.Select(entry => entry.Kind).ToHashSet();
Assert.That(Enum.GetValues<DataBackupEntryKind>(), Is.SubsetOf(listedKinds), "A kind without an entry means an entry was dropped from the list.");
}
[Test]
public void APluginTheOrganizationDeployedAmongTheUsersPluginsIsFound()
{
var plugins = new[]
{
new AvailablePlugin(PluginFolder("company-assistant"), isManagedByConfigServer: true),
new AvailablePlugin(PluginFolder("my-assistant"), isManagedByConfigServer: false),
new AvailablePlugin(PluginFolder(".config", Guid.NewGuid().ToString(), "assistant"), isManagedByConfigServer: true),
new AvailablePlugin(Path.Combine(Path.GetTempPath(), "ai-studio-plugins-elsewhere", "assistant"), isManagedByConfigServer: true),
};
var found = DataBackupContents.FindOrganizationPluginsAmongUserPlugins(plugins, DATA_DIRECTORY);
Assert.That(found, Is.EqualTo(new[] { "plugins/company-assistant/" }), "The folder of the configuration server is left out as a whole already, and a plugin outside of the data folder is no part of a backup anyway.");
}
private static string PluginFolder(params string[] parts) => Path.Combine(DATA_DIRECTORY, "plugins", Path.Combine(parts));
/// <summary>
/// A plugin as the plugin factory lists it after loading it.
/// </summary>
private sealed class AvailablePlugin(string localPath, bool isManagedByConfigServer) : IAvailablePlugin
{
public string IconDataUrl => string.Empty;
public PluginType Type => PluginType.ASSISTANT;
public Guid Id { get; } = Guid.NewGuid();
public string Name => "Assistant";
public string Description => string.Empty;
public PluginVersion Version => new(1, 0, 0);
public DateTimeOffset? LastChanged => null;
public string[] Authors => [];
public string SupportContact => string.Empty;
public string SourceURL => string.Empty;
public PluginCategory[] Categories => [];
public PluginTargetGroup[] TargetGroups => [];
public bool IsMaintained => true;
public string DeprecationMessage => string.Empty;
public bool IsInternal => false;
public string LocalPath => localPath;
public bool IsManagedByConfigServer => isManagedByConfigServer;
public Guid? ManagedConfigurationId => null;
public int ConfigurationPriority => 0;
}
}
@@ -0,0 +1,100 @@
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.DataBackup;
namespace AIStudio.Tests.Tools.DataBackup;
/// <summary>
/// Checks the settings as a backup takes them along.
/// </summary>
/// <remarks>
/// A configuration plugin brings its objects back on another computer, along with their API keys.
/// Restored from a backup as well, they would compete with what the plugin brings, and without the
/// plugin they would stay without their keys, since secrets never go into a backup. What the user
/// configured themselves has to come along, though, and the settings AI Studio runs with must not
/// lose anything while a backup is made.
/// </remarks>
[TestFixture]
public sealed class DataBackupSettingsTests
{
private static readonly Guid PLUGIN_ID = Guid.Parse("7b2d4f6a-8c1e-4a3b-9d5f-2e4a6c8b0d1f");
[Test]
public void EveryObjectOfAConfigurationPluginIsLeftOut()
{
var settings = CreateSettingsWithObjectsOfTheUserAndOfAPlugin();
var copy = DataBackupSettings.CopyWithoutPluginObjects(settings);
Assert.Multiple(() =>
{
Assert.That(copy.Providers.Select(provider => provider.Id), Is.EqualTo(new[] { "user-provider" }));
Assert.That(copy.TranscriptionProviders.Select(provider => provider.Id), Is.EqualTo(new[] { "user-transcription" }));
Assert.That(copy.EmbeddingProviders.Select(provider => provider.Id), Is.EqualTo(new[] { "user-embedding" }));
Assert.That(copy.ChatTemplates.Select(template => template.Id), Is.EqualTo(new[] { "user-template" }));
Assert.That(copy.DataSources.Select(dataSource => dataSource.Id), Is.EqualTo(new[] { "user-data-source" }));
Assert.That(copy.Profiles.Select(profile => profile.Id), Is.EqualTo(new[] { "user-profile" }));
Assert.That(copy.DocumentAnalysis.Policies.Select(policy => policy.Id), Is.EqualTo(new[] { "user-policy" }));
});
}
[Test]
public void TheSettingsAIStudioRunsWithStayAsTheyAre()
{
var settings = CreateSettingsWithObjectsOfTheUserAndOfAPlugin();
DataBackupSettings.CopyWithoutPluginObjects(settings);
Assert.Multiple(() =>
{
Assert.That(settings.Providers, Has.Count.EqualTo(2));
Assert.That(settings.DataSources, Has.Count.EqualTo(2));
Assert.That(settings.DocumentAnalysis.Policies, Has.Count.EqualTo(2));
});
}
[Test]
public void ALockedSettingStaysWithItsPlugin()
{
var settings = CreateSettingsWithObjectsOfTheUserAndOfAPlugin();
settings.ManagedLockedConfigurations["App.UpdateInterval"] = PLUGIN_ID;
settings.ManagedUserValueSnapshots["App.UpdateInterval"] = "\"CHECK_EVERY_DAY\"";
var copy = DataBackupSettings.CopyWithoutPluginObjects(settings);
Assert.Multiple(() =>
{
Assert.That(copy.ManagedLockedConfigurations["App.UpdateInterval"], Is.EqualTo(PLUGIN_ID), "After a restore, the clean-up hands the setting back to the user unless the plugin locks it again.");
Assert.That(copy.ManagedUserValueSnapshots["App.UpdateInterval"], Is.EqualTo("\"CHECK_EVERY_DAY\""), "That is the value the user gets back.");
});
}
private static Data CreateSettingsWithObjectsOfTheUserAndOfAPlugin()
{
var settings = new Data();
settings.Providers.Add(new AIStudio.Settings.Provider(1, "user-provider", "My provider", LLMProviders.OPEN_AI, new("gpt-6", "gpt-6")));
settings.Providers.Add(new AIStudio.Settings.Provider(2, "plugin-provider", "Provider of the organization", LLMProviders.SELF_HOSTED, new("llama", "llama"), IsSelfHosted: true, IsEnterpriseConfiguration: true, EnterpriseConfigurationPluginId: PLUGIN_ID));
settings.TranscriptionProviders.Add(new TranscriptionProvider(1, "user-transcription", "My transcription", LLMProviders.OPEN_AI, new("whisper-1", "whisper-1")));
settings.TranscriptionProviders.Add(new TranscriptionProvider(2, "plugin-transcription", "Transcription of the organization", LLMProviders.OPEN_AI, new("whisper-1", "whisper-1"), IsEnterpriseConfiguration: true, EnterpriseConfigurationPluginId: PLUGIN_ID));
settings.EmbeddingProviders.Add(new EmbeddingProvider(1, "user-embedding", "My embeddings", LLMProviders.OPEN_AI, new("text-embedding-3-small", "text-embedding-3-small")));
settings.EmbeddingProviders.Add(new EmbeddingProvider(2, "plugin-embedding", "Embeddings of the organization", LLMProviders.OPEN_AI, new("text-embedding-3-small", "text-embedding-3-small"), IsEnterpriseConfiguration: true, EnterpriseConfigurationPluginId: PLUGIN_ID));
settings.ChatTemplates.Add(new ChatTemplate { Id = "user-template", Name = "My template" });
settings.ChatTemplates.Add(new ChatTemplate { Id = "plugin-template", Name = "Template of the organization", IsEnterpriseConfiguration = true, EnterpriseConfigurationPluginId = PLUGIN_ID });
settings.DataSources.Add(new DataSourceERI_V1 { Id = "user-data-source", Name = "My server" });
settings.DataSources.Add(new DataSourceERI_V1 { Id = "plugin-data-source", Name = "Server of the organization", IsEnterpriseConfiguration = true, EnterpriseConfigurationPluginId = PLUGIN_ID });
settings.Profiles.Add(new Profile { Id = "user-profile", Name = "My profile" });
settings.Profiles.Add(new Profile { Id = "plugin-profile", Name = "Profile of the organization", IsEnterpriseConfiguration = true, EnterpriseConfigurationPluginId = PLUGIN_ID });
settings.DocumentAnalysis.Policies.Add(new DataDocumentAnalysisPolicy { Id = "user-policy", Name = "My policy" });
settings.DocumentAnalysis.Policies.Add(new DataDocumentAnalysisPolicy { Id = "plugin-policy", Name = "Policy of the organization", IsEnterpriseConfiguration = true, EnterpriseConfigurationPluginId = PLUGIN_ID });
return settings;
}
}
@@ -0,0 +1,212 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using AIStudio.Tools.DataBackup;
namespace AIStudio.Tests.Tools.DataBackup;
/// <summary>
/// Checks what a backup holds.
/// </summary>
/// <remarks>
/// A restore unpacks the backup and replaces what the list of the backup contents names. A file
/// missing from it is lost on the next computer, and a file of the organization or of AI Studio
/// itself which got in would compete with the one the organization or AI Studio brings. The indexes
/// come from their copies only: read while AI Studio writes to them, they would not fit together.
/// </remarks>
[TestFixture]
public sealed class DataBackupWriterTests
{
private const string SETTINGS_JSON = """{ "Version": "V6" }""";
private static readonly Guid WORKSPACE_ID = Guid.Parse("3f2b7c1e-9a4d-4e6b-8c2f-1d5e7a9b0c3d");
private static readonly Guid CHAT_ID = Guid.Parse("8a1c4e7f-2b5d-4f9a-b3c6-0e2d4f6a8b1c");
private string testDirectory = string.Empty;
private string DataDirectory => Path.Combine(this.testDirectory, "data");
private string ConfigDirectory => Path.Combine(this.testDirectory, "config");
private string IndexCopiesDirectory => Path.Combine(this.testDirectory, "index-copies");
[SetUp]
public void CreateFolders()
{
this.testDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-backup-writer-{Guid.NewGuid():N}");
this.WriteDataFile($"workspaces/{WORKSPACE_ID}/name", "Projects");
this.WriteDataFile($"workspaces/{WORKSPACE_ID}/{CHAT_ID}/thread.json", "{}");
this.WriteDataFile($"workspaces/{WORKSPACE_ID}/{CHAT_ID}/attachments/transcripts/meeting-transcript-0001.md", "Hello and welcome.");
this.WriteDataFile($"tempChats/{Guid.Empty}/thread.json", "{}");
this.WriteDataFile("plugins/my-assistant/plugin.lua", "-- mine");
this.WriteDataFile("plugins/my-assistant/.hidden.lua", "-- hidden, but mine as well");
this.WriteDataFile("plugins/company-assistant/plugin.lua", "-- deployed by the organization");
this.WriteDataFile("plugins/.internal/language/plugin.lua", "-- comes with AI Studio");
this.WriteDataFile("plugins/.config/0b5f1e8a-3c2d-4e6f-9a1b-7c8d9e0f1a2b/plugin.lua", "-- configuration of the organization");
this.WriteDataFile("plugins/.lock", string.Empty);
this.WriteDataFile("tokenizers/openai-gpt-6/tokenizer.json", "{}");
this.WriteDataFile("visualBriefings/briefing/manifest.json", "{}");
this.WriteDataFile("media-staging/operation/transcript.md", "temporary");
this.WriteDataFile("databases/sqlite/rag-index.sqlite3", "the index while AI Studio writes to it");
this.WriteDataFile("databases/vector_database/stores/store_1/segments/segment", "the store while AI Studio writes to it");
WriteFile(Path.Combine(this.ConfigDirectory, "settings.json"), "the settings on the disk, with the objects of the organization");
WriteFile(Path.Combine(this.IndexCopiesDirectory, "databases", "sqlite", "rag-index.sqlite3"), "the copy of the index");
WriteFile(Path.Combine(this.IndexCopiesDirectory, "databases", "vector_database", "stores", "store_1", "segments", "segment"), "the copy of the store");
}
[TearDown]
public void RemoveFolders()
{
if (Directory.Exists(this.testDirectory))
Directory.Delete(this.testDirectory, true);
}
[Test]
public async Task ABackupHoldsWhatBelongsIntoItAndNothingElse()
{
var archive = await this.WriteBackupAsync(this.IndexCopiesDirectory);
Assert.That(archive.Keys, Is.EquivalentTo(new[]
{
"config/settings.json",
$"data/workspaces/{WORKSPACE_ID}/name",
$"data/workspaces/{WORKSPACE_ID}/{CHAT_ID}/thread.json",
$"data/workspaces/{WORKSPACE_ID}/{CHAT_ID}/attachments/transcripts/meeting-transcript-0001.md",
$"data/tempChats/{Guid.Empty}/thread.json",
"data/plugins/my-assistant/plugin.lua",
"data/plugins/my-assistant/.hidden.lua",
"data/tokenizers/openai-gpt-6/tokenizer.json",
"data/visualBriefings/briefing/manifest.json",
"data/databases/sqlite/rag-index.sqlite3",
"data/databases/vector_database/stores/store_1/segments/segment",
DataBackupFormat.MANIFEST_ENTRY_NAME,
}));
Assert.Multiple(() =>
{
Assert.That(archive["config/settings.json"], Is.EqualTo(SETTINGS_JSON), "The settings come from memory, without the objects of configuration plugins.");
Assert.That(archive["data/databases/sqlite/rag-index.sqlite3"], Is.EqualTo("the copy of the index"));
Assert.That(archive["data/databases/vector_database/stores/store_1/segments/segment"], Is.EqualTo("the copy of the store"));
});
}
[Test]
public async Task WithoutCopiesTheBackupLeavesTheIndexesOut()
{
var archive = await this.WriteBackupAsync(string.Empty);
Assert.That(archive.Keys.Where(name => name.StartsWith("data/databases/", StringComparison.Ordinal)), Is.Empty, "The indexes in the data folder change while AI Studio runs.");
}
[Test]
public async Task TheManifestComesLastAndListsEveryOtherFile()
{
await using var target = new MemoryStream();
await DataBackupWriter.WriteAsync(target, this.CreateSources(this.IndexCopiesDirectory), CreateManifest(), null, CancellationToken.None);
target.Position = 0;
using var zip = new ZipArchive(target, ZipArchiveMode.Read);
var manifestEntry = zip.Entries[^1];
var manifest = await ReadManifestAsync(manifestEntry);
var otherEntries = zip.Entries.Take(zip.Entries.Count - 1).ToList();
Assert.That(manifestEntry.FullName, Is.EqualTo(DataBackupFormat.MANIFEST_ENTRY_NAME), "Without the manifest, a backup was not written completely.");
Assert.That(manifest.Entries.Select(entry => entry.Name), Is.EqualTo(otherEntries.Select(entry => entry.FullName)));
foreach (var entry in otherEntries)
{
await using var stream = entry.Open();
var hash = Convert.ToHexStringLower(await SHA256.HashDataAsync(stream));
var listed = manifest.Entries.Single(listedEntry => listedEntry.Name == entry.FullName);
Assert.Multiple(() =>
{
Assert.That(listed.Sha256, Is.EqualTo(hash), $"The hash of '{entry.FullName}' lets a restore notice a damaged backup.");
Assert.That(listed.Size, Is.EqualTo(entry.Length));
});
}
}
/// <remarks>
/// The classic ZIP format stores sizes and positions in 32 bits, which ends at 4 GiB. The index
/// of large data sources gets there, so the backup has to switch to ZIP64 on its own: for the
/// size of the entry, and for the position of every entry behind it, the manifest among them. The file is sparse, so it takes no space, but the backup takes about
/// 4.5 GiB until the test ends.
/// </remarks>
[Test]
[Explicit("Writes a backup of about 4.5 GiB, which takes about a minute and as much free space.")]
public async Task ABackupBeyondFourGibibytesStaysReadable()
{
const long LARGE_FILE_SIZE = 4_608L * 1024 * 1024;
const long FOUR_GIBIBYTES = 4L * 1024 * 1024 * 1024;
// Stored without compression, like every video, so the backup grows beyond 4 GiB as well:
var largeFilePath = Path.Combine(this.DataDirectory, "visualBriefings", "large", "recording.mp4");
Directory.CreateDirectory(Path.GetDirectoryName(largeFilePath)!);
await using (var largeFile = new FileStream(largeFilePath, FileMode.Create, FileAccess.Write))
largeFile.SetLength(LARGE_FILE_SIZE);
var archivePath = Path.Combine(this.testDirectory, "backup.mwbackup");
await using (var target = new FileStream(archivePath, FileMode.Create, FileAccess.Write, FileShare.None, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan))
await DataBackupWriter.WriteAsync(target, this.CreateSources(string.Empty), CreateManifest(), null, CancellationToken.None);
await using var archiveStream = File.OpenRead(archivePath);
using var zip = new ZipArchive(archiveStream, ZipArchiveMode.Read);
var largeEntry = zip.GetEntry("data/visualBriefings/large/recording.mp4")!;
var manifest = await ReadManifestAsync(zip.Entries[^1]);
var listed = manifest.Entries.Single(entry => entry.Name == largeEntry.FullName);
string hash;
await using (var entryStream = largeEntry.Open())
hash = Convert.ToHexStringLower(await SHA256.HashDataAsync(entryStream));
Assert.Multiple(() =>
{
Assert.That(new FileInfo(archivePath).Length, Is.GreaterThan(FOUR_GIBIBYTES), "Only then do positions need ZIP64 as well.");
Assert.That(largeEntry.Length, Is.EqualTo(LARGE_FILE_SIZE));
Assert.That(listed.Size, Is.EqualTo(LARGE_FILE_SIZE));
Assert.That(listed.Sha256, Is.EqualTo(hash), "The large file comes back as it was.");
Assert.That(manifest.Entries, Has.Count.EqualTo(zip.Entries.Count - 1), "The manifest lies behind the large file, beyond 4 GiB, and is still found.");
});
}
private async Task<Dictionary<string, string>> WriteBackupAsync(string indexCopiesDirectory)
{
await using var target = new MemoryStream();
await DataBackupWriter.WriteAsync(target, this.CreateSources(indexCopiesDirectory), CreateManifest(), null, CancellationToken.None);
target.Position = 0;
using var zip = new ZipArchive(target, ZipArchiveMode.Read);
var contents = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var entry in zip.Entries)
{
using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
contents[entry.FullName] = await reader.ReadToEndAsync();
}
return contents;
}
private DataBackupSources CreateSources(string indexCopiesDirectory) => new(this.DataDirectory, this.ConfigDirectory, SETTINGS_JSON, indexCopiesDirectory, ["plugins/company-assistant/"]);
private static DataBackupManifest CreateManifest() => new(DataBackupFormat.FORMAT_VERSION, "26.10.1", "V6", DateTimeOffset.UtcNow, "/data", "/config", true, []);
private static async Task<DataBackupManifest> ReadManifestAsync(ZipArchiveEntry manifestEntry)
{
await using var stream = manifestEntry.Open();
return (await JsonSerializer.DeserializeAsync<DataBackupManifest>(stream, DataBackupFormat.JSON_OPTIONS))!;
}
private void WriteDataFile(string relativePath, string content) => WriteFile(Path.Combine(this.DataDirectory, relativePath.Replace('/', Path.DirectorySeparatorChar)), content);
private static void WriteFile(string path, string content)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, content);
}
}
@@ -0,0 +1,155 @@
using System.Text.Json;
using AIStudio.Tools.DataBackup;
using SettingsVersion = AIStudio.Settings.Version;
namespace AIStudio.Tests.Tools.DataBackup;
/// <summary>
/// Checks what the first start after a restore, a crashed backup, or a crashed preparation removes,
/// and what it must keep.
/// </summary>
/// <remarks>
/// While the runtime has not finished a restore, its journal and the data it replaced are the only way
/// back. Removing them would lose the data of the user for good.
/// </remarks>
[TestFixture]
public sealed class DataRestoreFollowUpTests
{
private string testDirectory = string.Empty;
private string DataDirectory => Path.Combine(this.testDirectory, "data");
private string ConfigDirectory => Path.Combine(this.testDirectory, "config");
[SetUp]
public void CreateFolders() => this.testDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-restore-follow-up-{Guid.NewGuid():N}");
[TearDown]
public void RemoveFolders()
{
if (Directory.Exists(this.testDirectory))
Directory.Delete(this.testDirectory, true);
}
[Test]
public void WhatABackupOrAPreparationLeftHalfwayIsRemoved()
{
WriteFile(Path.Combine(this.DataDirectory, ".backup-staging", "0123456789abcdef", "store"), "copy");
WriteFile(Path.Combine(this.DataDirectory, ".restore-pending", "workspaces", "chat", "thread.json"), "{}");
WriteFile(Path.Combine(this.ConfigDirectory, ".restore-pending", "settings.json"), "{}");
DataRestoreFollowUp.RemoveLeftovers(this.DataDirectory, this.ConfigDirectory);
Assert.Multiple(() =>
{
Assert.That(Directory.Exists(Path.Combine(this.DataDirectory, ".backup-staging")), Is.False);
Assert.That(Directory.Exists(Path.Combine(this.DataDirectory, ".restore-pending")), Is.False);
Assert.That(Directory.Exists(Path.Combine(this.ConfigDirectory, ".restore-pending")), Is.False);
});
}
[Test]
public void ARestoreWhichWaitsForTheRestartIsKept()
{
WriteFile(Path.Combine(this.DataDirectory, ".restore-pending", "workspaces", "chat", "thread.json"), "{}");
WriteFile(Path.Combine(this.DataDirectory, ".restore-pending", DataRestoreFormat.READY_FILE_NAME), "now");
DataRestoreFollowUp.RemoveLeftovers(this.DataDirectory, this.ConfigDirectory);
Assert.Multiple(() =>
{
Assert.That(DataRestoreFollowUp.IsRestorePending(this.DataDirectory), Is.True);
Assert.That(DataRestoreFollowUp.IsRestoreUnfinished(this.DataDirectory), Is.False);
Assert.That(File.Exists(Path.Combine(this.DataDirectory, ".restore-pending", "workspaces", "chat", "thread.json")), Is.True);
});
}
[Test]
public void ARestoreWhichTheRuntimeHasNotFinishedKeepsEverything()
{
WriteFile(Path.Combine(this.DataDirectory, ".restore-pending", DataRestoreFormat.READY_FILE_NAME), "now");
WriteFile(Path.Combine(this.DataDirectory, ".restore-pending", DataRestoreFormat.JOURNAL_FILE_NAME), """{ "moves": [], "started": 0 }""");
WriteFile(Path.Combine(this.DataDirectory, ".restore-previous", "workspaces", "chat", "thread.json"), "{}");
var target = new DataRestoreTarget(this.DataDirectory, this.ConfigDirectory, "26.10.1", SettingsVersion.V6, []);
DataRestoreFollowUp.RemoveLeftovers(this.DataDirectory, this.ConfigDirectory);
var removedReplacedData = DataRestoreFollowUp.RemoveReplacedData(this.DataDirectory, this.ConfigDirectory);
Assert.Multiple(() =>
{
Assert.That(DataRestoreFollowUp.IsRestoreUnfinished(this.DataDirectory), Is.True);
Assert.That(DataRestoreFollowUp.IsRestorePending(this.DataDirectory), Is.False, "The runtime moves it back rather than applying it.");
Assert.That(removedReplacedData, Is.False);
Assert.That(File.Exists(Path.Combine(this.DataDirectory, ".restore-previous", "workspaces", "chat", "thread.json")), Is.True, "These are the data of the user.");
Assert.That(() => DataRestorePreparer.Discard(target), Throws.InvalidOperationException);
Assert.That(File.Exists(Path.Combine(this.DataDirectory, ".restore-pending", DataRestoreFormat.JOURNAL_FILE_NAME)), Is.True);
});
}
[Test]
public void TheReplacedDataAreRemovedOnceNoRestoreWaits()
{
WriteFile(Path.Combine(this.DataDirectory, ".restore-previous", "workspaces", "chat", "thread.json"), "{}");
WriteFile(Path.Combine(this.ConfigDirectory, ".restore-previous", "settings.json"), "{}");
var removedReplacedData = DataRestoreFollowUp.RemoveReplacedData(this.DataDirectory, this.ConfigDirectory);
Assert.Multiple(() =>
{
Assert.That(removedReplacedData, Is.True);
Assert.That(Directory.Exists(Path.Combine(this.DataDirectory, ".restore-previous")), Is.False);
Assert.That(Directory.Exists(Path.Combine(this.ConfigDirectory, ".restore-previous")), Is.False);
});
}
[Test]
public async Task TheReportOfTheRuntimeIsRead()
{
WriteFile(Path.Combine(this.DataDirectory, DataRestoreFormat.RESULT_FILE_NAME), """
{
"outcome": "ROLLED_BACK",
"detail": "Could not move '.restore-pending/plugins' to 'plugins': access denied",
"prepared_at_utc": "2026-10-15T08:00:00.0000000+00:00",
"backup_created_at_utc": "2026-10-01T17:30:00.0000000+00:00",
"backup_app_version": "26.10.1",
"finished_at_utc": "2026-10-15T08:01:00.123456+00:00"
}
""");
var report = await DataRestoreFollowUp.ReadReportAsync(this.DataDirectory, CancellationToken.None);
Assert.Multiple(() =>
{
Assert.That(report, Is.Not.Null);
Assert.That(report!.Outcome, Is.EqualTo(DataRestoreOutcome.ROLLED_BACK));
Assert.That(report.BackupAppVersion, Is.EqualTo("26.10.1"));
Assert.That(report.Detail, Does.Contain("access denied"));
});
}
[Test]
public async Task WithoutARestoreThereIsNoReport()
{
Directory.CreateDirectory(this.DataDirectory);
var report = await DataRestoreFollowUp.ReadReportAsync(this.DataDirectory, CancellationToken.None);
Assert.That(report, Is.Null);
}
[Test]
public void AReportWithAnUnknownOutcomeCannotBeRead()
{
WriteFile(Path.Combine(this.DataDirectory, DataRestoreFormat.RESULT_FILE_NAME), """{ "outcome": "SOMETHING_NEW", "detail": "", "prepared_at_utc": "", "backup_created_at_utc": "", "backup_app_version": "", "finished_at_utc": "" }""");
Assert.That(async () => await DataRestoreFollowUp.ReadReportAsync(this.DataDirectory, CancellationToken.None), Throws.InstanceOf<JsonException>());
}
private static void WriteFile(string path, string content)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, content);
}
}
@@ -0,0 +1,132 @@
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools;
using AIStudio.Tools.DataBackup;
using AIStudio.Tools.ERIClient.DataModel;
using ProviderSettings = AIStudio.Settings.Provider;
namespace AIStudio.Tests.Tools.DataBackup;
/// <summary>
/// Checks what the user hears to do after a restore.
/// </summary>
/// <remarks>
/// A backup holds no secrets, so whatever of the user needs one lacks it on the new computer. The
/// objects of the organization come with their secrets from the configuration plugin, and naming them
/// would send the user hunting for keys they never had.
/// </remarks>
[TestFixture]
public sealed class DataRestoreOpenPointsTests
{
private static readonly Guid PLUGIN_ID = Guid.Parse("4f1c2b7a-8d3e-4a6b-9c5d-2e7f1a3b5c8d");
private string testDirectory = string.Empty;
[SetUp]
public void CreateFolder()
{
this.testDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-restore-open-points-{Guid.NewGuid():N}");
Directory.CreateDirectory(this.testDirectory);
}
[TearDown]
public void RemoveFolder()
{
if (Directory.Exists(this.testDirectory))
Directory.Delete(this.testDirectory, true);
}
[Test]
public async Task TheMissingSecretsOfTheUserAreOpenPoints()
{
var settings = new Data();
settings.Providers.Add(new ProviderSettings(1, "user-provider", "My OpenAI", LLMProviders.OPEN_AI, new("gpt-5", "gpt-5")));
settings.Providers.Add(new ProviderSettings(2, "stored-provider", "My Anthropic", LLMProviders.ANTHROPIC, new("claude", "claude")));
settings.Providers.Add(new ProviderSettings(3, "plugin-provider", "OpenAI of the organization", LLMProviders.OPEN_AI, new("gpt-5", "gpt-5"), IsEnterpriseConfiguration: true, EnterpriseConfigurationPluginId: PLUGIN_ID));
settings.EmbeddingProviders.Add(new EmbeddingProvider(1, "user-embedding", "My embeddings", LLMProviders.OPEN_AI, new("text-embedding-3-small", "text-embedding-3-small")));
settings.TranscriptionProviders.Add(new TranscriptionProvider(1, "user-transcription", "My transcription", LLMProviders.OPEN_AI, new("whisper-1", "whisper-1")));
settings.DataSources.Add(new DataSourceERI_V1 { Id = "token-server", Name = "My ERI server", AuthMethod = AuthMethod.TOKEN });
settings.DataSources.Add(new DataSourceERI_V1 { Id = "open-server", Name = "Open ERI server", AuthMethod = AuthMethod.NONE });
settings.DataSources.Add(new DataSourceERI_V1 { Id = "plugin-server", Name = "ERI server of the organization", AuthMethod = AuthMethod.TOKEN, IsEnterpriseConfiguration = true, EnterpriseConfigurationPluginId = PLUGIN_ID });
settings.Mailboxes.Add(new DataSourceMailbox { Id = "user-mailbox", Name = "My mailbox", AuthMethod = MailboxAuthMethod.PASSWORD });
var openPoints = await DataRestoreOpenPoints.CollectAsync(settings, new StoredSecrets("My Anthropic"), []);
Assert.That(openPoints, Is.EqualTo(new DataRestoreOpenPoint[]
{
new(DataRestoreOpenPointKind.PROVIDER_WITHOUT_API_KEY, "My OpenAI"),
new(DataRestoreOpenPointKind.EMBEDDING_PROVIDER_WITHOUT_API_KEY, "My embeddings"),
new(DataRestoreOpenPointKind.TRANSCRIPTION_PROVIDER_WITHOUT_API_KEY, "My transcription"),
new(DataRestoreOpenPointKind.DATA_SOURCE_WITHOUT_SECRET, "My ERI server"),
new(DataRestoreOpenPointKind.MAILBOX_WITHOUT_PASSWORD, "My mailbox"),
}));
}
[Test]
public async Task TheFilesAndFoldersMissingOnThisComputerAreOpenPoints()
{
var missingFolder = Path.Combine(this.testDirectory, "missing");
var missingFile = Path.Combine(this.testDirectory, "missing.txt");
var settings = new Data();
settings.DataSources.Add(new DataSourceLocalDirectory { Id = "missing-directory", Name = "Projects", Path = missingFolder });
settings.DataSources.Add(new DataSourceLocalDirectory { Id = "existing-directory", Name = "Notes", Path = this.testDirectory });
settings.DataSources.Add(new DataSourceLocalFile { Id = "missing-file", Name = "Handbook", FilePath = missingFile });
settings.BatchProcessing.InputDirectory = missingFolder;
settings.BatchProcessing.OutputDirectory = this.testDirectory;
settings.BatchProcessing.PromptFilePath = missingFile;
settings.App.ExternalHttpCustomRootCertificatesEnabled = true;
settings.App.ExternalHttpCustomRootCertificateBundlePath = missingFile;
settings.ERI.ERIServers.Add(new DataERIServer { ServerName = "Wiki server", WriteToFilesystem = true, BaseDirectory = missingFolder });
settings.ERI.ERIServers.Add(new DataERIServer { ServerName = "Draft server", WriteToFilesystem = false, BaseDirectory = missingFolder });
var openPoints = await DataRestoreOpenPoints.CollectAsync(settings, new StoredSecrets(), []);
Assert.That(openPoints, Is.EqualTo(new DataRestoreOpenPoint[]
{
new(DataRestoreOpenPointKind.DATA_SOURCE_NOT_FOUND, "Projects"),
new(DataRestoreOpenPointKind.DATA_SOURCE_NOT_FOUND, "Handbook"),
new(DataRestoreOpenPointKind.BATCH_PROCESSING_PATH_NOT_FOUND, missingFolder),
new(DataRestoreOpenPointKind.BATCH_PROCESSING_PATH_NOT_FOUND, missingFile),
new(DataRestoreOpenPointKind.CERTIFICATE_BUNDLE_NOT_FOUND, missingFile),
new(DataRestoreOpenPointKind.ERI_SERVER_DIRECTORY_NOT_FOUND, "Wiki server"),
}));
}
[Test]
public async Task AnUnusedCertificateBundleIsNoOpenPoint()
{
var settings = new Data();
settings.App.ExternalHttpCustomRootCertificatesEnabled = false;
settings.App.ExternalHttpCustomRootCertificateBundlePath = Path.Combine(this.testDirectory, "missing.pem");
var openPoints = await DataRestoreOpenPoints.CollectAsync(settings, new StoredSecrets(), []);
Assert.That(openPoints, Is.Empty);
}
[Test]
public async Task TheMissingSecretsOfTheToolsAreOpenPoints()
{
DataRestoreToolSecret[] toolSecrets =
[
new("web_search", "tavily.apiKey", "Web search", "Tavily API Key"),
new("web_search", "staan.apiKey", "Web search", "Staan API Key"),
];
var openPoints = await DataRestoreOpenPoints.CollectAsync(new Data(), new StoredSecrets("staan.apiKey"), toolSecrets);
Assert.That(openPoints, Is.EqualTo(new DataRestoreOpenPoint[] { new(DataRestoreOpenPointKind.TOOL_WITHOUT_SECRET, "Web search", "Tavily API Key") }));
}
/// <summary>
/// A keyring which holds the secrets of the given names.
/// </summary>
private sealed class StoredSecrets(params string[] storedSecretNames) : IStoredSecrets
{
public Task<bool> HasAPIKeyAsync(ISecretId secretId, SecretStoreType storeType) => Task.FromResult(storedSecretNames.Contains(secretId.SecretName));
public Task<bool> HasSecretAsync(ISecretId secretId, SecretStoreType storeType) => Task.FromResult(storedSecretNames.Contains(secretId.SecretName));
}
}
@@ -0,0 +1,171 @@
using System.IO.Compression;
using System.Text.Json;
using AIStudio.Tools.DataBackup;
using SettingsVersion = AIStudio.Settings.Version;
namespace AIStudio.Tests.Tools.DataBackup;
/// <summary>
/// Checks how a restore is prepared for the next start.
/// </summary>
/// <remarks>
/// The runtime replaces the data at the next start with whatever the preparation left behind, as soon
/// as it finds it marked as ready. A damaged backup or one this version cannot read must therefore
/// never get that far, and the plan has to name exactly what to replace and what to keep.
/// </remarks>
[TestFixture]
public sealed class DataRestorePreparerTests
{
private static readonly Guid WORKSPACE_ID = Guid.Parse("3f2b7c1e-9a4d-4e6b-8c2f-1d5e7a9b0c3d");
private static readonly Guid CHAT_ID = Guid.Parse("8a1c4e7f-2b5d-4f9a-b3c6-0e2d4f6a8b1c");
private static readonly DateTime CHAT_LAST_CHANGED = new(2026, 9, 1, 8, 30, 0, DateTimeKind.Local);
private string testDirectory = string.Empty;
private string ArchivePath => Path.Combine(this.testDirectory, "backup.mwbackup");
private DataRestoreTarget Target => new(Path.Combine(this.testDirectory, "target-data"), Path.Combine(this.testDirectory, "target-config"), "26.10.1", SettingsVersion.V6, ["plugins/company-assistant/"]);
[SetUp]
public void CreateFolders() => this.testDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-restore-preparer-{Guid.NewGuid():N}");
[TearDown]
public void RemoveFolders()
{
if (Directory.Exists(this.testDirectory))
Directory.Delete(this.testDirectory, true);
}
[Test]
public async Task ARestoreIsUnpackedNextToTheFoldersAndMarkedReadyLast()
{
await this.WriteBackupAsync("26.10.1", "V6");
var result = await DataRestorePreparer.PrepareAsync(this.ArchivePath, this.Target, null, CancellationToken.None);
var dataPending = Path.Combine(this.Target.DataDirectory, ".restore-pending");
var threadPath = Path.Combine(dataPending, "workspaces", WORKSPACE_ID.ToString(), CHAT_ID.ToString(), "thread.json");
var plan = JsonSerializer.Deserialize<DataRestorePlan>(await File.ReadAllTextAsync(Path.Combine(dataPending, DataRestoreFormat.PLAN_FILE_NAME)), DataBackupFormat.JSON_OPTIONS)!;
Assert.Multiple(() =>
{
Assert.That(result.Success, Is.True, result.Issue);
Assert.That(DataRestorePreparer.IsPrepared(this.Target), Is.True);
Assert.That(File.ReadAllText(threadPath), Is.EqualTo("{}"));
Assert.That(File.GetLastWriteTime(threadPath), Is.EqualTo(CHAT_LAST_CHANGED).Within(TimeSpan.FromSeconds(2)), "The workspaces sort their chats by it.");
Assert.That(File.ReadAllText(Path.Combine(dataPending, "plugins", "my-assistant", "plugin.lua")), Is.EqualTo("-- mine"));
Assert.That(File.ReadAllText(Path.Combine(this.Target.ConfigDirectory, ".restore-pending", "settings.json")), Does.Contain("V6"), "The settings go next to the configuration folder.");
Assert.That(plan.Elements.Select(element => $"{element.Location}:{element.RelativePath}"), Is.EquivalentTo((string[])["CONFIG_DIRECTORY:settings.json", "DATA_DIRECTORY:workspaces/", "DATA_DIRECTORY:plugins/"]), "An element the backup holds nothing for, such as the tokenizers here, stays as it is.");
Assert.That(plan.Elements.Single(element => element.RelativePath == "plugins/").Keep, Is.SupersetOf((string[])["plugins/.internal/", "plugins/.config/", "plugins/.config-tests/", "plugins/company-assistant/"]), "AI Studio and the organization bring their plugins themselves.");
});
}
[Test]
public async Task ADamagedBackupLeavesNothingBehind()
{
await this.WriteBackupAsync("26.10.1", "V6", damageThread: true);
var result = await DataRestorePreparer.PrepareAsync(this.ArchivePath, this.Target, null, CancellationToken.None);
Assert.Multiple(() =>
{
Assert.That(result.Success, Is.False);
Assert.That(result.Issue, Does.Contain("thread.json"));
Assert.That(Directory.Exists(Path.Combine(this.Target.DataDirectory, ".restore-pending")), Is.False, "The next start must find nothing to apply.");
Assert.That(Directory.Exists(Path.Combine(this.Target.ConfigDirectory, ".restore-pending")), Is.False);
});
}
[TestCase("27.1.0", "V6")]
[TestCase("26.10.1", "V7")]
public async Task ABackupOfANewerVersionIsRefused(string appVersion, string settingsVersion)
{
await this.WriteBackupAsync(appVersion, settingsVersion);
var result = await DataRestorePreparer.PrepareAsync(this.ArchivePath, this.Target, null, CancellationToken.None);
Assert.Multiple(() =>
{
Assert.That(result.Success, Is.False, "Its settings or its index may have a form this version cannot read.");
Assert.That(DataRestorePreparer.IsPrepared(this.Target), Is.False);
});
}
[Test]
public async Task ADiscardedRestoreLeavesNothingBehind()
{
await this.WriteBackupAsync("26.10.1", "V6");
await DataRestorePreparer.PrepareAsync(this.ArchivePath, this.Target, null, CancellationToken.None);
DataRestorePreparer.Discard(this.Target);
Assert.Multiple(() =>
{
Assert.That(DataRestorePreparer.IsPrepared(this.Target), Is.False);
Assert.That(Directory.Exists(Path.Combine(this.Target.ConfigDirectory, ".restore-pending")), Is.False);
});
}
[Test]
public void DiscardingWithoutAPreparedRestoreChangesNothing()
{
Assert.That(() => DataRestorePreparer.Discard(this.Target), Throws.Nothing, "A preparation discards whatever an earlier one left, and mostly, there is nothing.");
}
/// <summary>
/// Writes a backup of a data folder with a chat and a plugin of the user.
/// </summary>
/// <param name="appVersion">The version of AI Studio the manifest names.</param>
/// <param name="settingsVersion">The version of the settings the manifest names.</param>
/// <param name="damageThread">Whether to change the chat thread after the manifest noted its hash.</param>
private async Task WriteBackupAsync(string appVersion, string settingsVersion, bool damageThread = false)
{
var sourceDataDirectory = Path.Combine(this.testDirectory, "source-data");
var threadPath = Path.Combine(sourceDataDirectory, "workspaces", WORKSPACE_ID.ToString(), CHAT_ID.ToString(), "thread.json");
WriteFile(Path.Combine(sourceDataDirectory, "workspaces", WORKSPACE_ID.ToString(), "name"), "Projects");
WriteFile(threadPath, "{}");
File.SetLastWriteTime(threadPath, CHAT_LAST_CHANGED);
WriteFile(Path.Combine(sourceDataDirectory, "plugins", "my-assistant", "plugin.lua"), "-- mine");
var sources = new DataBackupSources(sourceDataDirectory, Path.Combine(this.testDirectory, "source-config"), """{ "Version": "V6" }""", string.Empty, []);
var manifest = new DataBackupManifest(DataBackupFormat.FORMAT_VERSION, appVersion, settingsVersion, DateTimeOffset.UtcNow, sourceDataDirectory, string.Empty, false, []);
await using (var archiveStream = File.Create(this.ArchivePath))
await DataBackupWriter.WriteAsync(archiveStream, sources, manifest, null, CancellationToken.None);
if (!damageThread)
return;
// Rewrite the backup with another chat thread, but the manifest of the original one:
var damagedArchivePath = $"{this.ArchivePath}.damaged";
await using (var originalStream = File.OpenRead(this.ArchivePath))
using (var original = new ZipArchive(originalStream, ZipArchiveMode.Read))
await using (var damagedStream = File.Create(damagedArchivePath))
using (var damaged = new ZipArchive(damagedStream, ZipArchiveMode.Create))
{
foreach (var entry in original.Entries)
{
await using var target = damaged.CreateEntry(entry.FullName).Open();
if (entry.FullName.EndsWith("thread.json", StringComparison.Ordinal))
{
await target.WriteAsync("{ }"u8.ToArray());
continue;
}
await using var source = entry.Open();
await source.CopyToAsync(target);
}
}
File.Move(damagedArchivePath, this.ArchivePath, overwrite: true);
}
private static void WriteFile(string path, string content)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, content);
}
}
@@ -0,0 +1,138 @@
using System.Text.Json;
using AIStudio.Chat;
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Tools;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks that the paths of files below the data directory are stored relative to it, and every other path as it is.
/// </summary>
/// <remarks>
/// A backup of the data directory brings the stored paths to another computer, where the user name,
/// the OS, or the data directory itself may differ. An absolute path to a transcript or a tokenizer
/// copy leads nowhere there, so the transcript drops out of its chat, and the token count turns into
/// an estimate. Relative to the data directory, the path still names the same file.
/// </remarks>
[TestFixture]
[NonParallelizable]
public sealed class DataDirectoryRelativePathTests
{
private static readonly DateTimeOffset START = new(2026, 10, 14, 9, 0, 0, TimeSpan.Zero);
private static readonly Guid WORKSPACE_ID = Guid.Parse("3f2b7c1e-9a4d-4e6b-8c2f-1d5e7a9b0c3d");
private static readonly Guid CHAT_ID = Guid.Parse("8a1c4e7f-2b5d-4f9a-b3c6-0e2d4f6a8b1c");
private static readonly string ELSEWHERE = Path.Combine(Path.GetTempPath(), "ai-studio-documents-elsewhere");
private string? previousDataDirectory;
private string dataDirectory = string.Empty;
[SetUp]
public void PrepareDataDirectory()
{
//
// The data directory is static state of the whole application, which is why this fixture
// does not run alongside others. Nothing is written to it: storing a path only asks where
// the path lies.
//
this.previousDataDirectory = SettingsManager.DataDirectory;
this.dataDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-relative-paths-{Guid.NewGuid():N}");
SettingsManager.DataDirectory = this.dataDirectory;
}
[TearDown]
public void RestoreDataDirectory() => SettingsManager.DataDirectory = this.previousDataDirectory;
[Test]
public void AFileBelowTheDataDirectoryIsStoredRelative()
{
var transcriptPath = Path.Combine(this.dataDirectory, "workspaces", WORKSPACE_ID.ToString(), CHAT_ID.ToString(), "attachments", "transcripts", "meeting-transcript-0001.md");
var picturePath = Path.Combine(this.dataDirectory, "plugins", "templates", "chart.png");
var documentPath = Path.Combine(ELSEWHERE, "report.pdf");
var message = new ContentText
{
Text = "Summarize the meeting",
FileAttachments =
[
new ManagedTranscriptAttachment("meeting-transcript-0001.md", transcriptPath, 512, "meeting.mp3", false),
new FileAttachmentImage("chart.png", picturePath, 2048),
new FileAttachment(FileAttachmentType.DOCUMENT, "report.pdf", documentPath, 1024),
],
};
var chat = new ChatThread
{
WorkspaceId = WORKSPACE_ID,
ChatId = CHAT_ID,
Name = "Meeting notes",
Blocks = [new ContentBlock { Time = START, ContentType = ContentType.TEXT, Content = message, Role = ChatRole.USER }],
};
var json = JsonSerializer.Serialize(chat, WorkspaceBehaviour.JSON_OPTIONS);
var attachments = ((ContentText)JsonSerializer.Deserialize<ChatThread>(json, WorkspaceBehaviour.JSON_OPTIONS)!.Blocks.Single().Content!).FileAttachments;
Assert.Multiple(() =>
{
Assert.That(json, Does.Contain($"\"workspaces/{WORKSPACE_ID}/{CHAT_ID}/attachments/transcripts/meeting-transcript-0001.md\""), "Forward slashes, so that every OS reads the path.");
Assert.That(json, Does.Not.Contain(JsonEncodedText.Encode(this.dataDirectory).ToString()), "Not a single path may name the data directory of this computer.");
Assert.That(json, Does.Contain(JsonEncodedText.Encode(documentPath).ToString()), "A file of the user stays where it is, so its path stays absolute.");
Assert.That(attachments.Select(attachment => attachment.FilePath), Is.EqualTo(new[] { transcriptPath, picturePath, documentPath }), "In memory, every path is absolute again.");
Assert.That(attachments[0], Is.TypeOf<ManagedTranscriptAttachment>(), "A transcript has to stay a transcript, or deleting the chat leaves its file behind.");
Assert.That(((FileAttachmentImage)attachments[1]).Source, Is.EqualTo(picturePath), "The model gets the image from its source, which has to follow the path.");
});
}
[Test]
public void ATokenizerCopyIsStoredRelative()
{
var tokenizerPath = Path.Combine(this.dataDirectory, "tokenizers", "openai-gpt-6", "tokenizer.json");
var provider = new AIStudio.Settings.Provider(1, "provider", "Chat provider", LLMProviders.OPEN_AI, new("gpt-6", "gpt-6"), TokenizerPath: tokenizerPath);
var embeddingProvider = new EmbeddingProvider(1, "embedding", "Embedding provider", LLMProviders.OPEN_AI, new("text-embedding-3-small", "text-embedding-3-small"), TokenizerPath: tokenizerPath);
var providerJson = JsonSerializer.Serialize(provider, SettingsManager.JSON_OPTIONS);
var embeddingProviderJson = JsonSerializer.Serialize(embeddingProvider, SettingsManager.JSON_OPTIONS);
Assert.Multiple(() =>
{
Assert.That(providerJson, Does.Contain("\"tokenizers/openai-gpt-6/tokenizer.json\""));
Assert.That(embeddingProviderJson, Does.Contain("\"tokenizers/openai-gpt-6/tokenizer.json\""));
Assert.That(JsonSerializer.Deserialize<AIStudio.Settings.Provider>(providerJson, SettingsManager.JSON_OPTIONS)!.TokenizerPath, Is.EqualTo(tokenizerPath));
Assert.That(JsonSerializer.Deserialize<EmbeddingProvider>(embeddingProviderJson, SettingsManager.JSON_OPTIONS)!.TokenizerPath, Is.EqualTo(tokenizerPath));
});
}
[Test]
public void AnAbsolutePathIsReadAsItIs()
{
// Stored before AI Studio stored such paths relative:
var transcriptPath = Path.Combine(this.dataDirectory, "tempChats", CHAT_ID.ToString(), "attachments", "transcripts", "call-transcript-0001.md");
var documentPath = Path.Combine(ELSEWHERE, "notes.txt");
Assert.Multiple(() =>
{
Assert.That(DataDirectoryRelativePathConverter.ToAbsolute(transcriptPath, this.dataDirectory), Is.EqualTo(transcriptPath), "On this computer, the path still names the transcript.");
Assert.That(DataDirectoryRelativePathConverter.ToAbsolute(documentPath, this.dataDirectory), Is.EqualTo(documentPath));
});
}
[Test]
public void ARelativePathLeadingOutOfTheDataDirectoryIsNoPath()
{
Assert.That(DataDirectoryRelativePathConverter.ToAbsolute("workspaces/../../notes.txt", this.dataDirectory), Is.Empty, "AI Studio never writes such a path, so it must not name a file anywhere else.");
}
[Test]
public void WithoutADataDirectoryEveryPathStaysAsItIs()
{
var transcriptPath = Path.Combine(this.dataDirectory, "tempChats", CHAT_ID.ToString(), "attachments", "transcripts", "call-transcript-0001.md");
Assert.Multiple(() =>
{
Assert.That(DataDirectoryRelativePathConverter.ToStored(transcriptPath, null), Is.EqualTo(transcriptPath));
Assert.That(DataDirectoryRelativePathConverter.ToAbsolute("tokenizers/openai-gpt-6/tokenizer.json", null), Is.EqualTo("tokenizers/openai-gpt-6/tokenizer.json"));
});
}
}
@@ -0,0 +1,101 @@
using AIStudio.Tools.Databases.IndexStore;
using AIStudio.Tools.Services.Indexing;
using Microsoft.Data.Sqlite;
namespace AIStudio.Tests.Tools.Databases;
/// <summary>
/// Checks the copy of the index which a backup takes along.
/// </summary>
/// <remarks>
/// After a restore, the copy is the index: what it lacks, AI Studio has to embed anew, at the cost of
/// the user's time and, with a cloud provider, money. The full-text search has to work from it right
/// away as well, since it lives in a table of its own.
/// </remarks>
[TestFixture]
public sealed class IndexStoreBackupTests
{
private const string DATA_SOURCE_ID = "6f1d6a4e-6a5e-4c62-9a4f-0f2d2c8b7a11";
private const string FILE_PATH = "/tmp/test-data/report.md";
private static readonly CancellationToken TOKEN = CancellationToken.None;
[Test]
public async Task TheCopyHoldsTheIndexAndItsFullTextSearch()
{
await using var store = await TemporaryIndexStore.CreateAsync();
await StoreReportAsync(store);
var targetDirectory = CreateTargetDirectory();
try
{
var copyPath = await store.Client.BackupToDirectoryAsync(targetDirectory, TOKEN);
var copy = new SqliteIndexStoreClientImplementation("SQLite", copyPath, targetDirectory, string.Empty);
var manifest = await copy.GetManifestAsync(DATA_SOURCE_ID, TOKEN);
var found = await copy.SearchChunksAsync(DATA_SOURCE_ID, "quarterly", 10, TOKEN);
Assert.Multiple(() =>
{
Assert.That(Path.GetFileName(copyPath), Is.EqualTo("rag-index.sqlite3"), "A restore puts the copy where the index belongs, under the same name.");
Assert.That(manifest.Files.Keys, Is.EqualTo(new[] { FILE_PATH }), "Without the fingerprint, the file would be embedded anew.");
Assert.That(found.Select(result => result.ChunkText), Is.EqualTo(new[] { "The quarterly report is due." }));
});
}
finally
{
RemoveTargetDirectory(targetDirectory);
}
}
[Test]
public async Task ACopyWrittenBeforeIsReplaced()
{
await using var store = await TemporaryIndexStore.CreateAsync();
var targetDirectory = CreateTargetDirectory();
try
{
await store.Client.BackupToDirectoryAsync(targetDirectory, TOKEN);
await StoreReportAsync(store);
var copyPath = await store.Client.BackupToDirectoryAsync(targetDirectory, TOKEN);
var copy = new SqliteIndexStoreClientImplementation("SQLite", copyPath, targetDirectory, string.Empty);
Assert.That(await copy.GetTotalChunkCountAsync(TOKEN), Is.EqualTo(1));
}
finally
{
RemoveTargetDirectory(targetDirectory);
}
}
[Test]
public void WithoutAnIndexThereIsNothingToCopy()
{
var client = new NoIndexStoreClient("SQLite", "The native library could not be loaded.");
Assert.That(async () => await client.BackupToDirectoryAsync(CreateTargetDirectory(), TOKEN), Throws.InvalidOperationException, "A backup without the index would take along vector stores which belong to nothing.");
}
private static async Task StoreReportAsync(TemporaryIndexStore store)
{
var parentFileId = IndexedDocumentIds.CreateParentId(DATA_SOURCE_ID, FILE_PATH);
var now = DateTimeOffset.UtcNow;
await store.Client.UpsertDataSourceAsync(DATA_SOURCE_ID, "LOCAL_DIRECTORY", "b0a4c4d2-1f3e-4f0a-8c9d-5a6b7c8d9e01", "signature", string.Empty, 3, TOKEN);
await store.Client.UpsertFileAsync(DATA_SOURCE_ID, new EmbeddingStateFile(parentFileId, FILE_PATH, "report.md", "report.md", "md", FILE_PATH, 64, now, now, now, 1), TOKEN);
await store.Client.UpsertChunksAsync(DATA_SOURCE_ID, [new EmbeddingStateChunk(IndexedDocumentIds.CreateChunkId(DATA_SOURCE_ID, FILE_PATH, 0), parentFileId, null, 0, "The quarterly report is due.", now)], TOKEN);
}
private static string CreateTargetDirectory() => Path.Combine(Path.GetTempPath(), $"ai-studio-index-backup-{Guid.NewGuid():N}");
private static void RemoveTargetDirectory(string targetDirectory)
{
// The client which opened the copy pools its connections, which would keep the file open on Windows:
SqliteConnection.ClearAllPools();
if (Directory.Exists(targetDirectory))
Directory.Delete(targetDirectory, true);
}
}
+50
View File
@@ -0,0 +1,50 @@
using AIStudio.Tools.Services;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks that indexing waits for the plugins before its first run, but never for good.
/// </summary>
/// <remarks>
/// The embedding providers of an organization come with its configuration plugin. Right after a
/// backup was restored, the settings do not hold them yet, and a first run which starts before the
/// plugins were loaded finds the data sources of the user without their embedding provider. Should
/// loading fail, though, waiting for it must not keep every data source from being indexed.
/// </remarks>
[TestFixture]
public sealed class FirstPluginLoadTests
{
[Test]
public async Task TheFirstRunWaitsUntilThePluginsWereLoaded()
{
var firstLoad = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var wait = DataSourceEmbeddingService.WaitForFirstPluginLoadAsync(firstLoad.Task, TimeSpan.FromMinutes(1), CancellationToken.None);
await Task.Delay(50);
Assert.That(wait.IsCompleted, Is.False, "The plugins are still loading.");
firstLoad.SetResult();
Assert.That(await wait, Is.True);
}
[Test]
public async Task TheFirstRunStartsAnywayWhenLoadingThePluginsTakesTooLong()
{
var neverLoaded = new TaskCompletionSource();
var loadedInTime = await DataSourceEmbeddingService.WaitForFirstPluginLoadAsync(neverLoaded.Task, TimeSpan.FromMilliseconds(50), CancellationToken.None);
Assert.That(loadedInTime, Is.False, "Loading may fail for good, and the data sources must be indexed all the same.");
}
[Test]
public void StoppingAIStudioEndsTheWait()
{
var neverLoaded = new TaskCompletionSource();
using var stopping = new CancellationTokenSource();
stopping.Cancel();
var waiting = DataSourceEmbeddingService.WaitForFirstPluginLoadAsync(neverLoaded.Task, TimeSpan.FromMinutes(1), stopping.Token);
Assert.That(async () => await waiting, Throws.InstanceOf<OperationCanceledException>());
}
}
+108
View File
@@ -0,0 +1,108 @@
using AIStudio.Tools.Services;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks the pauses of the indexing, which a backup takes while it copies the indexes.
/// </summary>
/// <remarks>
/// A run which wrote to the stores while they are copied would leave a backup whose two stores do
/// not fit together. Once the backup is done, though, every data source whose run the pause stopped
/// has to carry on, for the same reason it ran: a retry the user asked for is the only run which
/// signs in to a mailbox again after its server refused a sign-in.
/// </remarks>
[TestFixture]
public sealed class IndexingPausesTests
{
[Test]
public async Task ARunWaitsUntilThePauseEnded()
{
var pauses = new IndexingPauses();
pauses.Begin();
var waiting = pauses.WaitWhilePausedAsync(CancellationToken.None);
await Task.Delay(50);
Assert.That(waiting.IsCompleted, Is.False, "The backup still copies the indexes.");
pauses.End();
await waiting;
}
[Test]
public void WithoutAPauseARunStartsRightAway()
{
var pauses = new IndexingPauses();
Assert.Multiple(() =>
{
Assert.That(pauses.IsPaused, Is.False);
Assert.That(pauses.WaitWhilePausedAsync(CancellationToken.None).IsCompleted, Is.True);
});
}
[Test]
public void TheIndexingCountsAsPausedUntilTheLastPauseEnded()
{
var pauses = new IndexingPauses();
pauses.Begin();
pauses.Begin();
pauses.End();
Assert.That(pauses.IsPaused, Is.True, "Deleting a data source would discard its index while a backup copies it.");
pauses.End();
Assert.That(pauses.IsPaused, Is.False);
}
[Test]
public async Task TheIndexingGoesOnOnceTheLastPauseEnded()
{
var pauses = new IndexingPauses();
pauses.Begin();
pauses.Begin();
pauses.RecordStoppedRun("mailbox", DataSourceEmbeddingRefreshMode.MANUAL_RETRY);
var stoppedRunsAfterTheFirstEnd = pauses.End();
var waiting = pauses.WaitWhilePausedAsync(CancellationToken.None);
await Task.Delay(50);
Assert.Multiple(() =>
{
Assert.That(stoppedRunsAfterTheFirstEnd, Is.Empty, "The other pause still holds the indexing back, so nothing may start yet.");
Assert.That(waiting.IsCompleted, Is.False);
});
var stoppedRuns = pauses.End();
await waiting;
Assert.That(stoppedRuns, Is.EqualTo(new[] { new DataSourceRunRequest("mailbox", DataSourceEmbeddingRefreshMode.MANUAL_RETRY) }), "The retry the user asked for stays a retry.");
}
[Test]
public void AStoppedRunIsQueuedAgainOnlyOnce()
{
var pauses = new IndexingPauses();
pauses.Begin();
pauses.RecordStoppedRun("folder", DataSourceEmbeddingRefreshMode.WATCHER_HASH_CHECK);
pauses.RecordStoppedRun("folder", DataSourceEmbeddingRefreshMode.HASH_CHECK);
Assert.Multiple(() =>
{
Assert.That(pauses.End(), Has.Count.EqualTo(1));
Assert.That(pauses.End(), Is.Empty, "Ending a pause which does not exist changes nothing.");
});
}
[Test]
public void AStoppedRunStopsWaiting()
{
var pauses = new IndexingPauses();
pauses.Begin();
using var stopRun = new CancellationTokenSource();
var waiting = pauses.WaitWhilePausedAsync(stopRun.Token);
stopRun.Cancel();
Assert.That(async () => await waiting, Throws.InstanceOf<OperationCanceledException>(), "Deleting a data source ends the wait of its run, as it does in front of the gate.");
}
}
@@ -60,7 +60,7 @@ public sealed class LeftOverConfigurationObjectsTests
[Test]
public async Task TheObjectsOfAPluginWhichDidNotStartStay()
{
var wasChanged = await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, [new AvailablePlugin()], new HashSet<Guid>(), new HashSet<Guid> { PLUGIN_ID }, []);
var wasChanged = await PluginConfigurationObjectDescriptors.CHAT_TEMPLATE.CleanLeftOverAsync([new AvailablePlugin()], new HashSet<Guid>(), new HashSet<Guid> { PLUGIN_ID }, []);
Assert.Multiple(() =>
{
@@ -72,7 +72,7 @@ public sealed class LeftOverConfigurationObjectsTests
[Test]
public async Task TheObjectsOfAPluginWhichStartedWithoutThemAreRemoved()
{
var wasChanged = await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, [new AvailablePlugin()], new HashSet<Guid>(), new HashSet<Guid>(), []);
var wasChanged = await PluginConfigurationObjectDescriptors.CHAT_TEMPLATE.CleanLeftOverAsync([new AvailablePlugin()], new HashSet<Guid>(), new HashSet<Guid>(), []);
Assert.Multiple(() =>
{
@@ -162,8 +162,8 @@ public sealed class ManagedTokenizerStateTests
throw new InvalidOperationException("The configuration of this test is not a Lua table.");
var configObjects = new List<PluginConfigurationObject>();
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, x => x.NextProviderNum, mainTable, PLUGIN_ID, ref configObjects, dryRun: false, PLUGIN_PATH);
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, x => x.NextEmbeddingNum, mainTable, PLUGIN_ID, ref configObjects, dryRun: false, PLUGIN_PATH);
PluginConfigurationObjectDescriptors.LLM_PROVIDER.TryParse(mainTable, PLUGIN_ID, configObjects, dryRun: false, PLUGIN_PATH);
PluginConfigurationObjectDescriptors.EMBEDDING_PROVIDER.TryParse(mainTable, PLUGIN_ID, configObjects, dryRun: false, PLUGIN_PATH);
return configObjects;
}
@@ -0,0 +1,28 @@
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Tests.Tools.PluginSystem;
/// <summary>
/// Checks that every type of configuration object has its descriptor.
/// </summary>
/// <remarks>
/// Reading the objects from a configuration plugin and removing the left-over ones go through the
/// descriptors. A type without one would be read by neither: a configuration plugin could define such
/// objects without effect, and the ones a removed plugin left behind would stay in the settings.
/// </remarks>
[TestFixture]
public sealed class PluginConfigurationObjectDescriptorsTests
{
[Test]
public void EveryTypeHasExactlyOneDescriptor()
{
var describedTypes = PluginConfigurationObjectDescriptors.ALL.Select(descriptor => descriptor.Type).ToList();
var configurableTypes = Enum.GetValues<PluginConfigurationObjectType>().Where(type => type is not PluginConfigurationObjectType.NONE and not PluginConfigurationObjectType.UNKNOWN);
Assert.Multiple(() =>
{
Assert.That(describedTypes, Is.EquivalentTo(configurableTypes));
Assert.That(describedTypes, Is.Unique, "A type described twice would be read and cleaned up twice.");
});
}
}
@@ -0,0 +1,125 @@
using System.Text.Json;
using AIStudio.Chat;
using AIStudio.Settings;
using AIStudio.Tools;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks that a chat stored before AI Studio stored paths relative gets them once, and nothing else changes.
/// </summary>
/// <remarks>
/// A chat which is never stored again would keep its absolute paths, and a backup of the data
/// directory would bring them to a computer where they lead nowhere. Writing the chat once more
/// must not look like a change, though: the workspaces sort their chats by the time of the last
/// change, and the maintenance of the temporary chats deletes them by it.
/// </remarks>
[TestFixture]
[NonParallelizable]
public sealed class RelativeDataPathsMigrationTests
{
private static readonly DateTime LAST_EDIT_TIME = new(2026, 9, 1, 8, 30, 0, DateTimeKind.Utc);
private string? previousDataDirectory;
private string testDirectory = string.Empty;
[SetUp]
public void PrepareDataDirectory()
{
//
// The data directory is static state of the whole application, which is why this fixture
// does not run alongside others. The workspace behaviour keeps the roots of the workspaces
// and of the temporary chats fixed, as the app never changes its data directory, so it gets
// told about the one of this test separately.
//
this.previousDataDirectory = SettingsManager.DataDirectory;
this.testDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-relative-paths-migration-{Guid.NewGuid():N}");
Directory.CreateDirectory(this.testDirectory);
SettingsManager.DataDirectory = this.testDirectory;
WorkspaceBehaviour.TestDataDirectory = this.testDirectory;
}
[TearDown]
public void RemoveDataDirectory()
{
SettingsManager.DataDirectory = this.previousDataDirectory;
WorkspaceBehaviour.TestDataDirectory = null;
try
{
Directory.Delete(this.testDirectory, true);
}
catch (IOException)
{
// A temporary directory we could not remove says nothing about the code under test.
}
}
[Test]
public async Task EveryStoredChatGetsRelativePathsAndKeepsItsTime()
{
var workspaceChat = this.StoreChatAsBefore(Guid.NewGuid(), Guid.NewGuid());
var temporaryChat = this.StoreChatAsBefore(Guid.Empty, Guid.NewGuid());
Assert.That(await WorkspaceBehaviour.StoreChatsWithRelativePathsAsync(CancellationToken.None), Is.True, "Nothing kept a chat busy, so every chat has to be done.");
foreach (var chat in new[] { workspaceChat, temporaryChat })
{
var json = await File.ReadAllTextAsync(chat.ThreadPath);
var stored = JsonSerializer.Deserialize<ChatThread>(json, WorkspaceBehaviour.JSON_OPTIONS)!;
Assert.Multiple(() =>
{
Assert.That(json, Does.Not.Contain(JsonEncodedText.Encode(this.testDirectory).ToString()), "Not a single path may name the data directory of this computer anymore.");
Assert.That(stored.Draft.MediaTranscripts.Single().FilePath, Is.EqualTo(chat.TranscriptPath), "The draft still finds its transcript.");
Assert.That(((ContentText)stored.Blocks.Single().Content!).FileAttachments.Single().FilePath, Is.EqualTo(chat.TranscriptPath), "The sent message still finds its transcript.");
Assert.That(File.GetLastWriteTimeUtc(chat.ThreadPath), Is.EqualTo(LAST_EDIT_TIME), "Writing the chat once more is no change of the chat.");
});
}
}
/// <summary>
/// Stores a chat with a transcript in a sent message and in its draft, with absolute paths as AI Studio stored them before.
/// </summary>
private StoredChat StoreChatAsBefore(Guid workspaceId, Guid chatId)
{
var chatDirectory = WorkspaceBehaviour.GetChatDirectory(workspaceId, chatId);
var transcriptDirectory = Path.Combine(chatDirectory, "attachments", "transcripts");
Directory.CreateDirectory(transcriptDirectory);
var transcriptPath = Path.Combine(transcriptDirectory, "meeting-transcript-0001.md");
File.WriteAllText(transcriptPath, $"# Transcription: meeting.mp4{Environment.NewLine}{Environment.NewLine}Hello and welcome.");
var transcript = new ManagedTranscriptAttachment("meeting-transcript-0001.md", transcriptPath, new FileInfo(transcriptPath).Length, "meeting.mp4", false);
var chat = new ChatThread
{
WorkspaceId = workspaceId,
ChatId = chatId,
Name = "Meeting notes",
Blocks =
[
new ContentBlock
{
Time = LAST_EDIT_TIME,
ContentType = ContentType.TEXT,
Content = new ContentText { Text = "Summarize the meeting", FileAttachments = [transcript] },
Role = ChatRole.USER,
},
],
};
chat.Draft.AddAttachments([transcript]);
// Without a known data directory, every path is stored as it is, i.e., absolute:
SettingsManager.DataDirectory = null;
var json = JsonSerializer.Serialize(chat, WorkspaceBehaviour.JSON_OPTIONS);
SettingsManager.DataDirectory = this.testDirectory;
var threadPath = Path.Combine(chatDirectory, "thread.json");
File.WriteAllText(threadPath, json);
File.SetLastWriteTimeUtc(threadPath, LAST_EDIT_TIME);
return new(threadPath, transcriptPath);
}
private sealed record StoredChat(string ThreadPath, string TranscriptPath);
}
@@ -0,0 +1,209 @@
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;
/// <summary>
/// Checks that a local data source keeps its index while its file or one of its folders cannot be reached.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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<string> 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<DataSourceEmbeddingStatus> 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<RunResult> RunAsync<TDataSource>(TDataSource dataSource, params string[] indexedFiles) where TDataSource : IDataSource, IIndexedDataSource
{
var statuses = new List<DataSourceEmbeddingStatus>();
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<SettingsManager>.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<PromptInjectionGuardService>.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<DataSourceEmbeddingStatus> Statuses, DataSourceEmbeddingManifest Manifest);
}
+8 -13
View File
@@ -94,20 +94,18 @@ public sealed class WorkspaceChatMoveTests
}
[Test]
public async Task ATranscriptOutsideTheChatDirectoryKeepsItsPath()
public async Task AFileOutsideTheChatDirectoryKeepsItsPath()
{
var chatId = Guid.NewGuid();
Directory.CreateDirectory(Path.Combine(this.testDirectory, "tempChats", chatId.ToString()));
// A transcript which is still waiting in the staging area, because its chat was not stored yet when it was made:
var stagingDirectory = Path.Combine(this.testDirectory, "media-staging", Guid.NewGuid().ToString("N"));
var staged = WriteTranscript(stagingDirectory, $"{Guid.NewGuid():N}.md", "meeting", isStaged: true);
var document = FileAttachment.FromPath(Path.Combine(this.testDirectory, "documents", "agenda.pdf"));
var chat = new ChatThread { ChatId = chatId, Name = "Meeting notes" };
chat.Draft.AddAttachments([staged]);
chat.Draft.AddAttachments([document]);
await WorkspaceBehaviour.MoveChatAsync(chat, Guid.NewGuid());
Assert.That(chat.Draft.MediaTranscripts.Single().FilePath, Is.EqualTo(staged.FilePath), "The move takes only the chat directory along, so a path elsewhere is still right.");
Assert.That(chat.Draft.FileAttachments.Single(), Is.SameAs(document), "The move takes only the chat directory along, so a path elsewhere is still right.");
}
[Test]
@@ -145,14 +143,11 @@ public sealed class WorkspaceChatMoveTests
private ManagedTranscriptAttachment CreateTranscript(Guid chatId, string stem, int number)
{
var transcriptDirectory = Path.Combine(this.testDirectory, "tempChats", chatId.ToString(), "attachments", "transcripts");
return WriteTranscript(transcriptDirectory, $"{stem}-transcript-{number:D4}.md", stem, isStaged: false);
}
Directory.CreateDirectory(transcriptDirectory);
private static ManagedTranscriptAttachment WriteTranscript(string directory, string fileName, string stem, bool isStaged)
{
Directory.CreateDirectory(directory);
var path = Path.Combine(directory, fileName);
var fileName = $"{stem}-transcript-{number:D4}.md";
var path = Path.Combine(transcriptDirectory, fileName);
File.WriteAllText(path, $"# Transcription: {stem}.mp4{Environment.NewLine}{Environment.NewLine}Hello and welcome.");
return new ManagedTranscriptAttachment(fileName, path, new FileInfo(path).Length, $"{stem}.mp4", isStaged);
return new ManagedTranscriptAttachment(fileName, path, new FileInfo(path).Length, $"{stem}.mp4", false);
}
}
@@ -0,0 +1,90 @@
using System.Text.Json;
using AIStudio.Chat;
using AIStudio.Settings;
using AIStudio.Tools;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks where the transcripts of a chat end up when the chat is stored.
/// </summary>
/// <remarks>
/// A transcript made before its chat was stored waits in the staging area, which every start of AI
/// Studio empties. Storing the chat therefore takes each such transcript into the chat directory: the
/// ones of the sent messages, and the ones of the message the user is still writing. Leaving a new
/// chat stores it together with that message, so a transcript left behind would be gone after the next
/// start, while the draft still pointed at it.
/// </remarks>
[TestFixture]
[NonParallelizable]
public sealed class WorkspaceChatStoreTests
{
private string? previousDataDirectory;
private string testDirectory = string.Empty;
[SetUp]
public async Task PrepareDataDirectory()
{
//
// The data directory is static state of the whole application, which is why this fixture
// does not run alongside others. The workspace behaviour keeps the roots of the workspaces
// and of the temporary chats fixed, as the app never changes its data directory, so it gets
// told about the one of this test separately. The cache is static as well, so it starts
// over from what the new data directory holds: nothing.
//
this.previousDataDirectory = SettingsManager.DataDirectory;
this.testDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-chat-store-{Guid.NewGuid():N}");
Directory.CreateDirectory(this.testDirectory);
SettingsManager.DataDirectory = this.testDirectory;
WorkspaceBehaviour.TestDataDirectory = this.testDirectory;
await WorkspaceBehaviour.ForceReloadWorkspaceTreeAsync();
}
[TearDown]
public void RemoveDataDirectory()
{
SettingsManager.DataDirectory = this.previousDataDirectory;
WorkspaceBehaviour.TestDataDirectory = null;
// Whoever reads the cache next must not get the workspaces of this test:
WorkspaceBehaviour.InvalidateWorkspaceTreeCache();
try
{
Directory.Delete(this.testDirectory, true);
}
catch (IOException)
{
// A temporary directory we could not remove says nothing about the code under test.
}
}
[Test]
public async Task TheStagedTranscriptOfTheDraftMovesIntoTheChat()
{
var workspace = await WorkspaceBehaviour.TryCreateWorkspaceAsync("Projects");
Assert.That(workspace.Success, Is.True, "The workspace could not be created.");
// A new chat, which was not stored yet when the transcript of its draft was made:
var chat = new ChatThread { WorkspaceId = workspace.Workspace.WorkspaceId, ChatId = Guid.NewGuid(), Name = "Meeting notes" };
var staged = await ManagedTranscriptAttachment.CreateStagedAsync(Path.Combine(this.testDirectory, "meeting.mp4"), "Hello and welcome.");
chat.Draft.AddAttachments([staged]);
Assert.That(await WorkspaceBehaviour.StoreChatAsync(chat), Is.True, "The chat could not be stored.");
var chatDirectory = WorkspaceBehaviour.GetChatDirectory(chat.WorkspaceId, chat.ChatId);
var transcript = chat.Draft.MediaTranscripts.Single();
var stored = JsonSerializer.Deserialize<ChatThread>(await File.ReadAllTextAsync(Path.Combine(chatDirectory, "thread.json")), WorkspaceBehaviour.JSON_OPTIONS)!;
Assert.Multiple(() =>
{
Assert.That(transcript.FilePath, Does.StartWith(chatDirectory), "The next start empties the staging area, but leaves the chat directory alone.");
Assert.That(File.Exists(transcript.FilePath), Is.True, "Otherwise the transcript drops out of the message which is still being written.");
Assert.That(transcript.IsStaged, Is.False, "The chat owns the transcript now, so deleting the chat deletes its file as well.");
Assert.That(File.Exists(staged.FilePath), Is.False, "The transcript moves, so there is only one file to own.");
Assert.That(stored.Draft.MediaTranscripts.Single().FilePath, Is.EqualTo(transcript.FilePath), "Opening the chat again brings the draft back from the stored chat, so that has to know the new path as well.");
});
}
}
+52
View File
@@ -0,0 +1,52 @@
using AIStudio.Tools;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks the names of ZIP entries before anything is extracted.
/// </summary>
/// <remarks>
/// A plugin archive, the configuration of an organization, or a backup comes from somewhere else. A
/// name which leads outside of the target directory would let it overwrite any file the user may
/// write, the settings among them.
/// </remarks>
[TestFixture]
public sealed class ZipEntryNamesTests
{
[TestCase("plugin/plugin.lua", "plugin/plugin.lua")]
[TestCase("plugin\\plugin.lua", "plugin/plugin.lua")]
[TestCase("data/workspaces/", "data/workspaces/")]
public void ASafeNameIsKeptWithForwardSlashes(string entryName, string expected)
{
Assert.That(ZipEntryNames.Normalize(entryName), Is.EqualTo(expected));
}
[TestCase("")]
[TestCase("/etc/passwd")]
[TestCase("\\Windows\\win.ini")]
[TestCase("C:/Users/settings.json")]
[TestCase("../settings.json")]
[TestCase("data/../../settings.json")]
[TestCase("data\\..\\..\\settings.json")]
[TestCase("./plugin.lua")]
[TestCase("plugin\0.lua")]
public void ANameWhichCouldLeadElsewhereIsRejected(string entryName)
{
Assert.Multiple(() =>
{
Assert.That(() => ZipEntryNames.Normalize(entryName), Throws.InstanceOf<InvalidDataException>());
Assert.That(ZipEntryNames.TryNormalize(entryName, out var normalizedEntryName), Is.False);
Assert.That(normalizedEntryName, Is.Empty);
});
}
[Test]
public void ASafeNameLiesBelowTheTargetDirectory()
{
var targetDirectory = Path.Combine(Path.GetTempPath(), "ai-studio-zip-target");
var destinationPath = ZipEntryNames.GetDestinationPath(targetDirectory, ZipEntryNames.Normalize("attachments/transcripts/meeting.md"));
Assert.That(destinationPath, Is.EqualTo(Path.Combine(Path.GetFullPath(targetDirectory), "attachments", "transcripts", "meeting.md")));
}
}