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; /// /// #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. /// 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(), 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(() => 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(() => FTPTool.ResolveLocalPath("accounting:books.csv", Context("buyer"), write: false)).Message; var unknown = Should.Throw(() => 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(() => 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"); } }