mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-17 16:37:59 +00:00
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:
1 parent
ab45580473
commit
936043319c
144 files changed
+10417
-424
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user