- Core/Organisation/OrgTemplate: format, export, parse, structural checks. Export empties every credential (nested, name variants) and names it; a template that carries one is refused. - OrgAdministration: ExportTemplate, PreviewTemplate (problems, warnings, every text in full), ImportTemplateAsync - only with confirmation, units before agents, own workspace per unit, doctrines, model and budget factor overridable, templateOrigin on every unit and agent. - Endpoints GET /organisation/template, POST .../preview, POST .../import; templates card in the organisation view. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
103 lines
4.5 KiB
C#
103 lines
4.5 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using ClawdDotNet.Core.Config;
|
|
using ClawdDotNet.Core.Organisation;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.Core.Tests.Organisation;
|
|
|
|
/// <summary>
|
|
/// #691: A whole house as a file — never with credentials, and a broken file is refused before
|
|
/// it touches an instance.
|
|
/// </summary>
|
|
public sealed class OrgTemplateTests
|
|
{
|
|
private static InstanceConfig House()
|
|
{
|
|
var instance = new InstanceConfig();
|
|
instance.Units.Add(new UnitConfig { Id = "newsroom", Name = "Newsroom", LeaderAgentId = "editor", Goal = "Publish daily." });
|
|
instance.Units.Add(new UnitConfig { Id = "desk", Name = "Desk", ParentId = "newsroom" });
|
|
instance.Agents.Add(new AgentConfig
|
|
{
|
|
AgentId = "editor", DisplayName = "Editor", UnitId = "newsroom", Model = "m/large",
|
|
Identity = "You lead the newsroom.", Soul = "Careful.",
|
|
Budget = new AgentBudget { DailyCostUsd = 2 },
|
|
Tools = new()
|
|
{
|
|
["Mail"] = new() { ["host"] = "mail.example.org", ["password"] = "hunter2", ["smtpPassword"] = "x" },
|
|
["DirectAPI"] = new()
|
|
{
|
|
["providers"] = JsonSerializer.Deserialize<JsonElement>("""{"weather":{"url":"https://w.example","apiKey":"k-123"}}""")
|
|
}
|
|
}
|
|
});
|
|
instance.Agents.Add(new AgentConfig { AgentId = "writer", DisplayName = "Writer", UnitId = "desk", Model = "m/small" });
|
|
return instance;
|
|
}
|
|
|
|
private static OrgTemplate Export() => OrgTemplates.Export(House(), "Newsroom", "A small newsroom.",
|
|
unit => unit == "newsroom" ? "Every claim has a source." : null, DateTime.UtcNow);
|
|
|
|
[Fact]
|
|
public void Export_empties_every_credential_and_names_it_instead()
|
|
{
|
|
var template = Export();
|
|
var json = JsonSerializer.Serialize(template, OrgTemplates.Json);
|
|
|
|
json.ShouldNotContain("hunter2");
|
|
json.ShouldNotContain("k-123");
|
|
json.ShouldContain("mail.example.org", customMessage: "what is not secret stays");
|
|
template.Credentials.Select(c => $"{c.AgentId}:{c.Tool}:{c.Field}").ShouldBe(
|
|
["editor:Mail:password", "editor:Mail:smtpPassword", "editor:DirectAPI:providers.weather.apiKey"], ignoreOrder: true);
|
|
}
|
|
|
|
[Fact]
|
|
public void Export_carries_the_structure_texts_and_doctrines()
|
|
{
|
|
var template = OrgTemplates.Parse(JsonSerializer.Serialize(Export(), OrgTemplates.Json));
|
|
|
|
template.Units.Select(u => u.Id).ShouldBe(["newsroom", "desk"]);
|
|
template.Agents.Single(a => a.AgentId == "editor").Identity.ShouldBe("You lead the newsroom.");
|
|
template.Doctrines["newsroom"].ShouldBe("Every claim has a source.");
|
|
OrgTemplates.Problems(template).ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void A_file_that_is_no_template_is_refused()
|
|
{
|
|
Should.Throw<FormatException>(() => OrgTemplates.Parse("""{"units":[]}"""));
|
|
Should.Throw<FormatException>(() => OrgTemplates.Parse("""{"format":"clawddotnet-org-template","formatVersion":99}"""));
|
|
Should.Throw<FormatException>(() => OrgTemplates.Parse("not json"));
|
|
}
|
|
|
|
[Fact]
|
|
public void A_broken_structure_is_named_before_anything_is_written()
|
|
{
|
|
var template = Export();
|
|
template.Units.Add(new UnitConfig { Id = "a", Name = "A", ParentId = "b" });
|
|
template.Units.Add(new UnitConfig { Id = "b", Name = "B", ParentId = "a" });
|
|
template.Units.Add(new UnitConfig { Id = "orphan", Name = "Orphan", ParentId = "nowhere" });
|
|
template.Units[1].LeaderAgentId = "editor";
|
|
template.Agents.Add(new TemplateAgent { AgentId = "writer", DisplayName = "Writer 2" });
|
|
template.Agents[0].Tools["Mail"]["password"] = "smuggled";
|
|
|
|
var problems = OrgTemplates.Problems(template);
|
|
|
|
problems.ShouldContain(p => p.Contains("circle"));
|
|
problems.ShouldContain(p => p.Contains("'nowhere'"));
|
|
problems.ShouldContain(p => p.Contains("led by 'editor'"));
|
|
problems.ShouldContain(p => p.Contains("'writer' appears twice"));
|
|
problems.ShouldContain(p => p.Contains("never carries one"));
|
|
}
|
|
|
|
[Fact]
|
|
public void A_nested_secret_is_found_under_any_variant_of_its_name()
|
|
{
|
|
var node = JsonNode.Parse("""{"a":{"clientSecret":"s","list":[{"accessToken":"t"}],"name":"keep"}}""")!;
|
|
|
|
OrgTemplates.StripSecrets(node, "").ShouldBe(["a.clientSecret", "a.list[0].accessToken"]);
|
|
node.ToJsonString().ShouldContain("keep");
|
|
node.ToJsonString().ShouldNotContain("\"s\"");
|
|
}
|
|
}
|