FTP und RocketChat kennen die benannten Arbeitsbereiche (#718)
Gemeinsamer Helfer NamedWorkspaces in Core statt dreier Kopien: unit: und Bereichs-Id als Praefix, Lesen braucht Read, Herunterladen braucht Write, Unbekanntes und Verbotenes antworten gleich. Ein Dateiname aus einer RocketChat-Nachricht waehlt keinen Bereich. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1bf26e9ff3
commit
88c42b244d
@@ -0,0 +1,155 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Organisation;
|
||||
using ClawdDotNet.Core.State;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using ClawdDotNet.Tools.FTP;
|
||||
using ClawdDotNet.Tools.RocketChat;
|
||||
using ClawdDotNet.Tools.Tests.FileRW;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Tools.Tests.Workspaces;
|
||||
|
||||
/// <summary>
|
||||
/// #718: FTP and RocketChat know the workspaces with member lists (#684) the way FileRW does.
|
||||
/// A buyer uploads from its unit's workspace without first copying the file to shared —
|
||||
/// where the separation between units does not hold.
|
||||
/// </summary>
|
||||
public sealed class NamedWorkspaceTransferTests : IDisposable
|
||||
{
|
||||
private readonly string _agents = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Agents");
|
||||
|
||||
private readonly OrgChart _org = new(
|
||||
[new() { Id = "purchasing", Name = "Purchasing" }, new() { Id = "marketing", Name = "Marketing" }, new() { Id = "accounting", Name = "Accounting" }],
|
||||
[
|
||||
new() { AgentId = "buyer", UnitId = "purchasing" },
|
||||
new() { AgentId = "ads", UnitId = "marketing" },
|
||||
new() { AgentId = "bookkeeper", UnitId = "accounting" }
|
||||
],
|
||||
[
|
||||
new() { Id = "purchasing", Name = "purchasing", OwnerUnitId = "purchasing", Members = [new() { UnitId = "purchasing", Access = WorkspaceAccess.Write }] },
|
||||
new() { Id = "accounting", Name = "accounting", OwnerUnitId = "accounting", Members = [new() { UnitId = "accounting", Access = WorkspaceAccess.Write }] },
|
||||
new()
|
||||
{
|
||||
Id = "articles", Name = "Article master", OwnerUnitId = "purchasing",
|
||||
Members = [new() { UnitId = "purchasing", Access = WorkspaceAccess.Manage }, new() { UnitId = "marketing", Access = WorkspaceAccess.Read }]
|
||||
}
|
||||
]);
|
||||
|
||||
public NamedWorkspaceTransferTests() => Directory.CreateDirectory(Path.Combine(_agents, "SharedWorkspace"));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(Path.GetDirectoryName(_agents)!, recursive: true); }
|
||||
catch { /* cleaning up is secondary */ }
|
||||
}
|
||||
|
||||
private string Area(string id) => Path.Combine(_agents, "SharedWorkspaces", id);
|
||||
|
||||
private AgentToolContext Context(string agentId)
|
||||
{
|
||||
var personal = Path.Combine(_agents, agentId, "Workspace");
|
||||
Directory.CreateDirectory(personal);
|
||||
return new AgentToolContext(agentId, "inst", new Dictionary<string, object?>(), new NullStateStore(),
|
||||
NullLogger.Instance, CancellationToken.None, WorkspacePath: personal, SharedWorkspacePath: Path.Combine(_agents, "SharedWorkspace"))
|
||||
{
|
||||
Organisation = _org
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════ the prefix ═══════════════════
|
||||
|
||||
[Theory]
|
||||
[InlineData("report.pdf", "personal", "report.pdf")]
|
||||
[InlineData("personal:a/b.txt", "personal", "a/b.txt")]
|
||||
[InlineData("SHARED:x.csv", "shared", "x.csv")]
|
||||
[InlineData("unit:orders.md", "unit", "orders.md")]
|
||||
[InlineData("articles:list.csv", "articles", "list.csv")]
|
||||
[InlineData("bericht.txt:hidden", "personal", "bericht.txt:hidden")] // an NTFS stream stays for the path check
|
||||
public void The_prefix_names_the_workspace_only_when_it_can_be_one(string path, string workspace, string relative)
|
||||
=> NamedWorkspaces.Split(path).ShouldBe((workspace, relative));
|
||||
|
||||
// ═══════════════════ FTP ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public void Ftp_uploads_from_the_own_units_workspace()
|
||||
{
|
||||
Directory.CreateDirectory(Area("purchasing"));
|
||||
File.WriteAllText(Path.Combine(Area("purchasing"), "orders.csv"), "x");
|
||||
|
||||
var (path, label) = FTPTool.ResolveLocalPath("unit:orders.csv", Context("buyer"), write: false);
|
||||
|
||||
path.ShouldBe(Path.Combine(Area("purchasing"), "orders.csv"));
|
||||
label.ShouldBe("purchasing");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ftp_reads_a_workspace_with_read_access_but_does_not_download_into_it()
|
||||
{
|
||||
FTPTool.ResolveLocalPath("articles:list.csv", Context("ads"), write: false).fullPath
|
||||
.ShouldBe(Path.Combine(Area("articles"), "list.csv"));
|
||||
|
||||
Should.Throw<ArgumentException>(() => FTPTool.ResolveLocalPath("articles:list.csv", Context("ads"), write: true))
|
||||
.Message.ShouldContain("may not write");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ftp_answers_a_foreign_workspace_like_an_unknown_one()
|
||||
{
|
||||
var foreign = Should.Throw<ArgumentException>(() => FTPTool.ResolveLocalPath("accounting:books.csv", Context("buyer"), write: false)).Message;
|
||||
var unknown = Should.Throw<ArgumentException>(() => FTPTool.ResolveLocalPath("nowhere:books.csv", Context("buyer"), write: false)).Message;
|
||||
|
||||
foreign.ShouldBe(unknown.Replace("nowhere", "accounting"));
|
||||
foreign.ShouldNotContain("accounting (=");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ftp_still_refuses_a_way_out_of_the_workspace()
|
||||
=> Should.Throw<UnauthorizedAccessException>(() => FTPTool.ResolveLocalPath("unit:../accounting/books.csv", Context("buyer"), write: false));
|
||||
|
||||
[Fact]
|
||||
public void Ftp_keeps_personal_and_shared_as_they_were()
|
||||
{
|
||||
FTPTool.ResolveLocalPath("notes.txt", Context("buyer"), write: true).workspaceLabel.ShouldBe("personal");
|
||||
FTPTool.ResolveLocalPath("shared:site/index.html", Context("buyer"), write: true).workspaceLabel.ShouldBe("shared");
|
||||
}
|
||||
|
||||
// ═══════════════════ RocketChat ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public void RocketChat_sends_from_and_saves_into_the_own_units_workspace()
|
||||
{
|
||||
var (send, error) = WorkspaceFile.Resolve("unit:plan.md", Context("buyer"), write: false);
|
||||
error.ShouldBeNull();
|
||||
send!.FullPath.ShouldBe(Path.Combine(Area("purchasing"), "plan.md"));
|
||||
|
||||
var (save, _) = WorkspaceFile.Resolve("unit:reply.pdf", Context("buyer"), write: true);
|
||||
save!.Label.ShouldBe("purchasing");
|
||||
Directory.Exists(Area("purchasing")).ShouldBeTrue("a workspace comes into being when it is first written to");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RocketChat_needs_write_access_to_save_into_a_workspace()
|
||||
{
|
||||
var (file, error) = WorkspaceFile.Resolve("articles:offer.pdf", Context("ads"), write: true);
|
||||
|
||||
file.ShouldBeNull();
|
||||
error!.ShouldContain("may not write");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RocketChat_answers_a_foreign_workspace_like_an_unknown_one()
|
||||
{
|
||||
var (_, foreign) = WorkspaceFile.Resolve("accounting:books.csv", Context("buyer"), write: false);
|
||||
var (_, unknown) = WorkspaceFile.Resolve("nowhere:books.csv", Context("buyer"), write: false);
|
||||
|
||||
foreign.ShouldBe(unknown!.Replace("nowhere", "accounting"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RocketChat_still_refuses_streams_and_ways_out()
|
||||
{
|
||||
WorkspaceFile.Resolve("bericht.txt:hidden", Context("buyer"), write: true).Error!.ShouldContain("Colons");
|
||||
WorkspaceFile.Resolve("unit:../../x", Context("buyer"), write: true).Error!.ShouldContain("outside");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user