Files
ClawdDotNet/tests/ClawdDotNet.Tools.Tests/FileRW/ReversibleDeleteTests.cs
T
RichardandClaude Opus 5.5 5de60c3b21 FileRW: Loeschen ueber den Dateiverlauf umkehrbar, ohne Freigabe; Ordner und Unaufhebbares bleiben beim Menschen (#195)
Der Dateiverlauf (#719) haelt die Vorfassung jeder geloeschten Datei - ein
eigener Papierkorb waere doppelt. delete einer Datei, die der Verlauf aufhebt,
laeuft ohne Freigabe; Ordner nur mit recursive:true (delete_folder, Freigabe);
zu grosse Dateien oder Verlauf aus (delete_unkept, Freigabe); die Wurzel nie.
Neue Aktionen deleted und restore mit voller Pfad-/Endungspruefung.
IAgentTool.StagingAktion bekommt eine Ueberladung mit Kontext.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:28:53 +02:00

145 lines
7.2 KiB
C#

using System.Text.Json;
using ClawdDotNet.Core.Files;
using ClawdDotNet.Core.Staging;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tools;
using ClawdDotNet.Tools.FileRW;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Tools.Tests.FileRW;
/// <summary>
/// #195: The file history keeps what a delete removes, so deleting a file is no irreversible
/// act any more and needs no approval. A folder at once, or a file the history cannot keep,
/// still goes to a human; the workspace itself is never deleted; and an agent can see and undo
/// what was deleted. Test instance 2026-10-01: eleven cleanup deletes waited for the human.
/// </summary>
public sealed class ReversibleDeleteTests : IDisposable
{
private readonly string _instance = Path.Combine(Path.GetTempPath(), "clawd-tests", "rd-" + Guid.NewGuid().ToString("N")[..8]);
private readonly string _agents;
private readonly string _shared;
private readonly FileHistorySettings _settings = new();
private readonly FileHistory _history;
private readonly FileRWTool _tool = new();
public ReversibleDeleteTests()
{
_agents = Path.Combine(_instance, "Agents");
_shared = Path.Combine(_agents, "SharedWorkspace");
Directory.CreateDirectory(_shared);
_history = new FileHistory(_agents, new SqliteStorage(Path.Combine(_instance, "state.db")), () => _settings);
}
public void Dispose()
{
SqliteStorage.ReleasePooledConnections(_instance);
try { Directory.Delete(_instance, recursive: true); }
catch { /* cleanup is secondary */ }
}
private AgentToolContext Context(string agentId = "keeper", bool history = true)
{
var personal = Path.Combine(_agents, "Agent-" + agentId, "Workspace");
Directory.CreateDirectory(personal);
return new AgentToolContext(agentId, "inst",
new Dictionary<string, object?> { ["sharedAccessLevel"] = "Admin", ["sharedAllowedExtensions"] = JsonSerializer.SerializeToElement(new[] { ".txt", ".md" }) },
new NullStateStore(), NullLogger.Instance, CancellationToken.None,
WorkspacePath: personal, SharedWorkspacePath: _shared)
{
History = history ? _history : null,
RunId = "run-" + agentId
};
}
private Task<ToolResult> Run(object input, AgentToolContext? context = null)
=> _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), context ?? Context(), CancellationToken.None);
private string? KeyOf(object input, AgentToolContext? context = null)
=> _tool.StagingAktion(JsonSerializer.SerializeToElement(input), context ?? Context());
private void Put(string relative, string content = "x")
{
var full = Path.Combine(_shared, relative);
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
File.WriteAllText(full, content);
}
[Fact]
public async Task A_kept_file_is_deleted_without_approval_and_put_back_by_the_agent()
{
Put("wetter/stunden/berlin_00.txt", "13.4 °C");
var delete = new { action = "delete", workspace = "shared", path = "wetter/stunden/berlin_00.txt" };
KeyOf(delete).ShouldBe("delete");
_tool.Vorgabe("delete", Context()).ShouldBe(StagingDecision.Auto);
var deleted = await Run(delete);
deleted.Success.ShouldBeTrue(deleted.ErrorMessage);
deleted.Content.ShouldContain("can be restored for 90 days");
var list = await Run(new { action = "deleted", workspace = "shared" });
list.Content.ShouldContain("wetter/stunden/berlin_00.txt");
var entry = (await _history.ListAsync("shared", null, null, null, 10, default)).First(e => e.Action == FileAction.Delete).Id;
var restored = await Run(new { action = "restore", workspace = "shared", entry });
restored.Success.ShouldBeTrue(restored.ErrorMessage);
File.ReadAllText(Path.Combine(_shared, "wetter/stunden/berlin_00.txt")).ShouldBe("13.4 °C");
(await Run(new { action = "restore", workspace = "shared", entry })).ErrorMessage.ShouldContain("exists again");
(await Run(new { action = "deleted", workspace = "shared" })).Content.ShouldContain("Nothing deleted");
}
[Fact]
public async Task A_folder_needs_recursive_and_a_human_and_the_workspace_is_never_deleted()
{
Put("archiv/a.txt");
Put("archiv/b.txt");
KeyOf(new { action = "delete", workspace = "shared", path = "archiv" }).ShouldBe("delete_folder");
KeyOf(new { action = "delete", workspace = "shared", path = "archiv", recursive = true }).ShouldBe("delete_folder");
new StagingPolicy().Decide("FileRW", "delete_folder").ShouldBe(StagingDecision.Approve);
(await Run(new { action = "delete", workspace = "shared", path = "archiv" })).ErrorMessage.ShouldContain("recursive");
(await Run(new { action = "delete", workspace = "shared", path = "." })).ErrorMessage.ShouldContain("workspace itself cannot be deleted");
Directory.Exists(Path.Combine(_shared, "archiv")).ShouldBeTrue();
(await Run(new { action = "delete", workspace = "shared", path = "archiv", recursive = true })).Success.ShouldBeTrue();
Directory.Exists(Path.Combine(_shared, "archiv")).ShouldBeFalse();
}
[Fact]
public void A_file_the_history_cannot_keep_stays_with_a_human()
{
Put("big.txt", new string('x', 2048));
KeyOf(new { action = "delete", workspace = "shared", path = "big.txt" }, Context(history: false)).ShouldBe("delete_unkept");
_tool.Vorgabe("delete", Context(history: false)).ShouldBeNull("without a history, the standard rule asks a human");
_settings.MaxObjectMegabytes = 0;
KeyOf(new { action = "delete", workspace = "shared", path = "big.txt" }).ShouldBe("delete_unkept");
new StagingPolicy().Decide("FileRW", "delete_unkept").ShouldBe(StagingDecision.Approve);
new StagingPolicy().Decide("FileRW", "delete").ShouldBe(StagingDecision.Approve, "without the history's word, delete stays with a human");
}
[Fact]
public async Task A_deletion_from_another_workspace_or_with_a_forbidden_extension_is_not_restored()
{
Put("note.txt", "shared note");
await Run(new { action = "delete", workspace = "shared", path = "note.txt" });
var entry = (await _history.ListAsync("shared", null, null, null, 10, default)).First(e => e.Action == FileAction.Delete).Id;
(await Run(new { action = "restore", workspace = "personal", entry })).ErrorMessage.ShouldContain("no deletion in the personal workspace");
(await Run(new { action = "restore", workspace = "shared" })).ErrorMessage.ShouldContain("'entry' is required");
// A file whose extension the workspace no longer allows does not come back through restore.
Put("tool.exe", "MZ");
var exe = await _history.BeforeAsync(Path.Combine(_shared, "tool.exe"), "shared", default);
File.Delete(Path.Combine(_shared, "tool.exe"));
var recorded = await _history.RecordAsync(exe, FileAction.Delete, "someone", null, default);
(await Run(new { action = "restore", workspace = "shared", entry = recorded!.Id })).Success.ShouldBeFalse();
File.Exists(Path.Combine(_shared, "tool.exe")).ShouldBeFalse();
}
}