feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar

This commit is contained in:
Richard
2026-08-10 10:48:34 +02:00
parent a0e18d2a57
commit b5bf97ae74
187 changed files with 20054 additions and 882 deletions
@@ -0,0 +1,127 @@
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Storage;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Audit;
/// <summary>
/// A3: das append-only Audit-Log und die Receipts. Gegen echte SQLite, weil Schema und
/// Persistenz der Punkt sind.
/// </summary>
public sealed class AuditRepositoryTests : IDisposable
{
private readonly string _directory;
private readonly SqliteStorage _storage;
private readonly SqliteAuditRepository _repo;
public AuditRepositoryTests()
{
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
_repo = new SqliteAuditRepository(_storage);
}
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
private AuditEntry Entry(string runId, string tool, AuditStatus status = AuditStatus.Ok) => new()
{
RunId = runId,
AgentId = "agent-a",
Model = "test/model",
Source = "task",
Tool = tool,
Arguments = "{\"x\":1}",
Status = status,
Summary = "",
DurationMs = 5,
OccurredAt = DateTime.UtcNow
};
[Fact]
public async Task Ein_Eintrag_wird_geschrieben_und_wiedergefunden()
{
await _repo.AppendAsync(Entry("run-1", "FileRW"), default);
var recent = await _repo.ListRecentAsync(10, default);
recent.Count.ShouldBe(1);
recent[0].Tool.ShouldBe("FileRW");
recent[0].RunId.ShouldBe("run-1");
(await _repo.CountAsync(default)).ShouldBe(1);
}
[Fact]
public async Task Die_Aufrufe_eines_Laufs_kommen_in_Reihenfolge()
{
await _repo.AppendAsync(Entry("run-1", "Memory"), default);
await _repo.AppendAsync(Entry("run-1", "FileRW"), default);
await _repo.AppendAsync(Entry("run-2", "Mail"), default);
var forRun = await _repo.ListForRunAsync("run-1", default);
forRun.Select(e => e.Tool).ShouldBe(["Memory", "FileRW"]);
}
[Fact]
public async Task Die_juengsten_Eintraege_kommen_zuerst()
{
await _repo.AppendAsync(Entry("run-1", "erst"), default);
await _repo.AppendAsync(Entry("run-1", "zuletzt"), default);
(await _repo.ListRecentAsync(10, default))[0].Tool.ShouldBe("zuletzt");
}
[Fact]
public async Task Der_Status_ueberlebt_den_Rundlauf()
{
await _repo.AppendAsync(Entry("r", "X", AuditStatus.Denied), default);
(await _repo.ListRecentAsync(1, default))[0].Status.ShouldBe(AuditStatus.Denied);
}
// ─── Receipts ───
[Fact]
public async Task Ein_Receipt_verknuepft_Lauf_und_Task()
{
await _repo.RecordReceiptAsync(new RunReceipt
{
RunId = "run-9",
AgentId = "agent-a",
Model = "test/model",
Source = "task",
TaskId = "t-42",
Status = "Completed",
StepCount = 3,
PromptTokens = 100,
CompletionTokens = 20,
CachedTokens = 10,
CostUsd = 0.0123m,
CostIsKnown = true,
DurationMs = 1200,
ResultRef = "fertig",
OccurredAt = DateTime.UtcNow
}, default);
var byRun = await _repo.GetReceiptForRunAsync("run-9", default);
byRun.ShouldNotBeNull();
byRun!.TaskId.ShouldBe("t-42");
byRun.CostUsd.ShouldBe(0.0123m);
byRun.CostIsKnown.ShouldBeTrue();
var byTask = await _repo.ListReceiptsForTaskAsync("t-42", default);
byTask.Count.ShouldBe(1);
byTask[0].RunId.ShouldBe("run-9");
}
[Fact]
public async Task Eintraege_ueberdauern_das_Schliessen_der_Datenbank()
{
await _repo.AppendAsync(Entry("run-1", "FileRW"), default);
var reopened = new SqliteAuditRepository(new SqliteStorage(Path.Combine(_directory, "state.db")));
(await reopened.CountAsync(default)).ShouldBe(1);
}
}
@@ -0,0 +1,125 @@
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Tests.Infrastructure;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Audit;
/// <summary>
/// A3 an der Engine: Jeder Tool-Aufruf wird protokolliert, jeder Lauf bekommt einen Beleg.
/// Entscheidend ist die Provenienz — die Engine stempelt Agent, Modell und Herkunft aus
/// ihrem eigenen Wissen, nicht aus dem Tool-Ergebnis.
/// </summary>
public sealed class EngineAuditTests
{
[Fact]
public async Task Jeder_Tool_Aufruf_landet_im_Audit_Log()
{
var audit = new InMemoryAuditRepository();
var fixture = new EngineFixture(audit).WithTool(FakeTool.Returning("ok"));
var agent = fixture.AddAgent("agent-a", "TestTool");
// Zwei Tool-Aufrufe, dann Text.
fixture.Client
.RespondsWithToolCall("TestTool")
.RespondsWithToolCall("TestTool")
.RespondsWithText("fertig");
await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
var entries = audit.Entries.ToList();
entries.Count.ShouldBe(2);
entries.ShouldAllBe(e => e.Tool == "TestTool");
entries.ShouldAllBe(e => e.Status == AuditStatus.Ok);
}
[Fact]
public async Task Die_Herkunft_wird_von_der_Engine_gestempelt()
{
var audit = new InMemoryAuditRepository();
var fixture = new EngineFixture(audit).WithTool(FakeTool.Returning("ok"));
var agent = fixture.AddAgent("agent-a", "TestTool");
fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("fertig");
await fixture.Engine.RunAsync(agent, "los", "test-instance", default, source: "task", taskId: "t-7");
var entry = audit.Entries.Single();
entry.AgentId.ShouldBe("agent-a");
entry.Model.ShouldBe("test/model");
entry.Source.ShouldBe("task");
entry.RunId.ShouldNotBeNullOrWhiteSpace();
}
[Fact]
public async Task Ein_verweigerter_Aufruf_wird_als_denied_protokolliert()
{
var audit = new InMemoryAuditRepository();
// Tool ist registriert, aber dem Agenten NICHT zugewiesen → PermissionGate greift.
var fixture = new EngineFixture(audit).WithTool(FakeTool.Returning("ok"));
var agent = fixture.AddAgent("agent-a" /* kein Tool zugewiesen */);
fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("fertig");
await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
audit.Entries.Single().Status.ShouldBe(AuditStatus.Denied);
}
[Fact]
public async Task Ein_Tool_Fehler_wird_als_error_protokolliert()
{
var audit = new InMemoryAuditRepository();
var fixture = new EngineFixture(audit)
.WithTool(FakeTool.Throwing(new InvalidOperationException("kaputt")));
var agent = fixture.AddAgent("agent-a", "TestTool");
fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("fertig");
await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
var entry = audit.Entries.Single();
entry.Status.ShouldBe(AuditStatus.Error);
entry.Summary.ShouldContain("kaputt");
}
[Fact]
public async Task Ein_Lauf_bekommt_einen_Receipt_mit_Task_Verknuepfung()
{
var audit = new InMemoryAuditRepository();
var fixture = new EngineFixture(audit).WithTool(FakeTool.Returning("ok"));
var agent = fixture.AddAgent("agent-a", "TestTool");
fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("erledigt");
await fixture.Engine.RunAsync(agent, "los", "test-instance", default, source: "task", taskId: "t-42");
var receipt = audit.Receipts.Single();
receipt.TaskId.ShouldBe("t-42");
receipt.Source.ShouldBe("task");
receipt.Status.ShouldBe("Completed");
receipt.ResultRef.ShouldBe("erledigt");
receipt.StepCount.ShouldBeGreaterThan(0);
}
[Fact]
public async Task Receipt_und_Audit_teilen_dieselbe_RunId()
{
var audit = new InMemoryAuditRepository();
var fixture = new EngineFixture(audit).WithTool(FakeTool.Returning("ok"));
var agent = fixture.AddAgent("agent-a", "TestTool");
fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("fertig");
await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
var runId = audit.Receipts.Single().RunId;
audit.Entries.Single().RunId.ShouldBe(runId, "alle Aufrufe eines Laufs tragen dieselbe RunId");
}
[Fact]
public async Task Ohne_Audit_Repository_laeuft_alles_normal_weiter()
{
// Gegenprobe: A3 ist optional — die Engine darf ohne Audit-Repo nicht anders laufen.
var fixture = new EngineFixture().WithTool(FakeTool.Returning("ok"));
var agent = fixture.AddAgent("agent-a", "TestTool");
fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("fertig");
var result = await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
result.Status.ToString().ShouldBe("Completed");
}
}
@@ -4,6 +4,7 @@ using ClawdDotNet.Core.Backup;
using ClawdDotNet.Core.Memory;
using ClawdDotNet.Core.Security;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tests.Infrastructure;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Backup;
@@ -11,7 +12,11 @@ namespace ClawdDotNet.Core.Tests.Backup;
/// <summary>
/// Ein ungeprüftes Wiederherstellen ist kein Backup, sondern eine Vermutung.
/// Deshalb liegt der Schwerpunkt hier auf dem vollständigen Rundlauf.
///
/// Läuft in <see cref="SecretKeyCollection"/>, weil das Umschreiben der Geheimnisse
/// beim Sichern denselben statischen Schlüssel benutzt wie die Geheimnistests.
/// </summary>
[Collection(SecretKeyCollection.Name)]
public sealed class BackupServiceTests : IDisposable
{
private readonly string _root;
@@ -0,0 +1,32 @@
using System.Net;
using System.Text;
namespace ClawdDotNet.Core.Tests.Deploymentcenter;
/// <summary>Fängt die letzte Anfrage ab und antwortet fest, ohne Netzwerkzugriff.</summary>
internal sealed class CapturingHandler(
HttpStatusCode status = HttpStatusCode.OK,
string responseBody = """{"status":"success"}""")
: HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
public string? LastBody { get; private set; }
public int Calls { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
Calls++;
LastRequest = request;
if (request.Content is not null)
LastBody = await request.Content.ReadAsStringAsync(cancellationToken);
return new HttpResponseMessage(status)
{
Content = new StringContent(responseBody, Encoding.UTF8, "application/json")
};
}
}
@@ -0,0 +1,69 @@
using System.Net;
using ClawdDotNet.Core.Deploymentcenter;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Deploymentcenter;
public sealed class DeploymentcenterApiTests
{
[Fact]
public async Task Token_geht_als_Bearer_mit()
{
var handler = new CapturingHandler();
using var api = new DeploymentcenterApi("https://dc.example", "tok-1", new HttpClient(handler));
await api.PostAsync("/api/health", new { }, default);
handler.LastRequest!.Headers.Authorization!.ToString().ShouldBe("Bearer tok-1");
}
[Fact]
public async Task Fehlerumschlag_wird_zu_einer_Ausnahme_mit_Code()
{
var handler = new CapturingHandler(HttpStatusCode.Unauthorized,
"""{"status":"error","error":{"code":"unauthorized","message":"Kein gueltiges Token."}}""");
using var api = new DeploymentcenterApi("https://dc.example", "tok", new HttpClient(handler));
var ex = await Should.ThrowAsync<DeploymentcenterException>(
() => api.PostAsync("/api/watchdog/v1/ping", new { }, default));
ex.Code.ShouldBe("unauthorized");
ex.IsAuthorizationProblem.ShouldBeTrue();
}
/// <summary>
/// Ein Fehlerumschlag zählt auch dann, wenn der Statuscode 200 lautet — der Server
/// antwortet nicht überall mit passendem HTTP-Code.
/// </summary>
[Fact]
public async Task Fehlerumschlag_mit_HTTP_200_zaehlt_trotzdem()
{
var handler = new CapturingHandler(HttpStatusCode.OK,
"""{"status":"error","error":{"code":"rate_limited","message":"Zu viele."}}""");
using var api = new DeploymentcenterApi("https://dc.example", "tok", new HttpClient(handler));
var ex = await Should.ThrowAsync<DeploymentcenterException>(
() => api.PostAsync("/x", new { }, default));
ex.Code.ShouldBe("rate_limited");
ex.IsAuthorizationProblem.ShouldBeFalse();
}
[Fact]
public void Nicht_HTTPS_wird_abgelehnt()
{
// Über eine ungesicherte Verbindung ginge das Token im Klartext.
Should.Throw<ArgumentException>(() => new DeploymentcenterApi("http://dc.example", "tok"));
}
[Fact]
public void Localhost_darf_auch_ohne_TLS()
{
// Eine lokale Testinstallation hat selten ein Zertifikat, und mithören kann
// auf dem eigenen Rechner niemand.
using var api = new DeploymentcenterApi("http://localhost:8080", "tok");
api.BaseUrl.ShouldBe("http://localhost:8080");
}
}
@@ -0,0 +1,97 @@
using System.Net;
using ClawdDotNet.Core.Deploymentcenter;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Deploymentcenter;
public sealed class ErrorReporterTests
{
private static (ErrorReporter Reporter, CapturingHandler Handler) Build(
Func<DateTimeOffset>? now = null,
HttpStatusCode status = HttpStatusCode.Created)
{
var handler = new CapturingHandler(status,
"""{"status":"success","item_id":7,"is_new":true,"occurrence_count":1}""");
var api = new DeploymentcenterApi("https://dc.example", "tok", new HttpClient(handler));
var reporter = new ErrorReporter(
api, "clawddotnet", "production", "0.1.0", NullLogger.Instance, ownsApi: true, now: now);
return (reporter, handler);
}
private static Exception Thrown(string message)
{
try { throw new InvalidOperationException(message); }
catch (Exception ex) { return ex; }
}
[Fact]
public async Task Meldung_geht_an_den_Fehler_Endpunkt()
{
var (reporter, handler) = Build();
using var _ = reporter;
var sent = await reporter.ReportAsync(Thrown("Kaputt"), fatal: true);
sent.ShouldBeTrue();
handler.LastRequest!.RequestUri!.ToString().ShouldBe("https://dc.example/api/errors/v1/report");
handler.LastBody!.ShouldContain("\"exception\":\"System.InvalidOperationException\"");
handler.LastBody.ShouldContain("\"message\":\"Kaputt\"");
handler.LastBody.ShouldContain("\"level\":\"fatal\"");
handler.LastBody.ShouldContain("\"project_slug\":\"clawddotnet\"");
}
/// <summary>
/// Eine Fehlerschleife darf nicht in tausend Anfragen münden. Derselbe Fehler geht
/// höchstens einmal je Zeitfenster raus — der Server drosselt zwar auch, aber erst,
/// nachdem die Anfragen schon über die Leitung waren.
/// </summary>
[Fact]
public async Task Derselbe_Fehler_geht_nur_einmal_je_Zeitfenster_raus()
{
var now = DateTimeOffset.UtcNow;
var (reporter, handler) = Build(() => now);
using var _ = reporter;
var error = Thrown("Immer wieder");
(await reporter.ReportAsync(error)).ShouldBeTrue();
(await reporter.ReportAsync(error)).ShouldBeFalse();
(await reporter.ReportAsync(error)).ShouldBeFalse();
handler.Calls.ShouldBe(1);
}
[Fact]
public async Task Nach_dem_Zeitfenster_wird_wieder_gemeldet()
{
var now = DateTimeOffset.UtcNow;
var (reporter, handler) = Build(() => now);
using var _ = reporter;
var error = Thrown("Immer wieder");
await reporter.ReportAsync(error);
now = now.AddMinutes(6);
await reporter.ReportAsync(error);
handler.Calls.ShouldBe(2);
}
/// <summary>
/// Ein Meldeweg, der selbst wirft, wäre die schlechteste aller Welten — der
/// ursprüngliche Fehler ginge dabei verloren.
/// </summary>
[Fact]
public async Task Ein_abgelehnter_Aufruf_wirft_nicht_nach_aussen()
{
var (reporter, _) = Build(status: HttpStatusCode.Unauthorized);
using var __ = reporter;
var sent = await reporter.ReportAsync(Thrown("Egal"));
sent.ShouldBeFalse();
}
}
@@ -0,0 +1,107 @@
using ClawdDotNet.Core.Accounting;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Deploymentcenter.Watchdog;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Deploymentcenter;
public sealed class InstanceHealthProviderTests
{
/// <summary>Liefert einen festen Tagesverbrauch, ohne DB.</summary>
private sealed class FakeUsage(DailyUsage daily) : IUsageRepository
{
public Task RecordAsync(RunUsage usage, CancellationToken ct) => Task.CompletedTask;
public Task<DailyUsage> GetDailyAsync(DateOnly date, string agentId, CancellationToken ct)
=> Task.FromResult(daily);
public Task<IReadOnlyList<RunUsage>> GetRecentAsync(int limit, CancellationToken ct)
=> Task.FromResult((IReadOnlyList<RunUsage>)Array.Empty<RunUsage>());
public Task<int> PurgeBeforeAsync(DateOnly date, CancellationToken ct) => Task.FromResult(0);
}
private static DailyUsage Usage(decimal cost, int tokens = 0) =>
new(DateOnly.FromDateTime(DateTime.Now), tokens, 0, cost, true, 1);
[Fact]
public async Task Ohne_API_Key_meldet_error()
{
var provider = new InstanceHealthProvider(
"Test", agentsEnabled: false, InstanceBudget.Unlimited,
usage: null, () => 2, () => 0);
var health = await provider.GetAsync(default);
health.Status.ShouldBe(WatchdogStatus.Error);
health.Metrics["agentCount"].ShouldBe(2);
health.Checks["agents"].Ok.ShouldBeFalse();
}
[Fact]
public async Task Erschoepftes_Kostenbudget_meldet_warning()
{
var budget = new InstanceBudget { DailyCostUsd = 5m };
var provider = new InstanceHealthProvider(
"Test", agentsEnabled: true, budget,
new FakeUsage(Usage(cost: 5m)), () => 1, () => 0);
var health = await provider.GetAsync(default);
health.Status.ShouldBe(WatchdogStatus.Warning);
health.Checks["budget"].Ok.ShouldBeFalse();
health.Message.ShouldNotBeNull();
}
/// <summary>
/// Ein Heartbeat beweist nur, dass ein Faden läuft. Steht der Scanner, arbeitet die
/// Instanz nichts mehr ab — und genau das soll die Prüfung sichtbar machen.
/// </summary>
[Fact]
public async Task Stehender_Scanner_meldet_warning()
{
var provider = new InstanceHealthProvider(
"Test", agentsEnabled: true, InstanceBudget.Unlimited,
new FakeUsage(Usage(cost: 0m)), () => 1, () => 0,
schedulerRunning: () => false);
var health = await provider.GetAsync(default);
health.Status.ShouldBe(WatchdogStatus.Warning);
health.Checks["scheduler"].Ok.ShouldBeFalse();
}
[Fact]
public async Task Normalbetrieb_meldet_ok_mit_Metriken()
{
var provider = new InstanceHealthProvider(
"Test", agentsEnabled: true, InstanceBudget.Unlimited,
new FakeUsage(Usage(cost: 0.5m, tokens: 1200)), () => 3, () => 1,
schedulerRunning: () => true);
var health = await provider.GetAsync(default);
health.Status.ShouldBe(WatchdogStatus.Ok);
health.Metrics["runningChats"].ShouldBe(1);
health.Metrics.Keys.ShouldContain("todayCostUsd");
health.Metrics.Keys.ShouldContain("todayTokens");
health.Checks.Values.ShouldAllBe(c => c.Ok);
}
/// <summary>
/// Metriken werden serverseitig nur ausgewertet, wenn sie Zahlen sind — alles andere
/// verwirft der Verlauf stillschweigend. Beschreibendes gehört in die Meldung.
/// </summary>
[Fact]
public async Task Instanzname_steht_in_der_Meldung_nicht_in_den_Metriken()
{
var provider = new InstanceHealthProvider(
"Produktion", agentsEnabled: true, InstanceBudget.Unlimited,
usage: null, () => 1, () => 0);
var health = await provider.GetAsync(default);
health.Message.ShouldContain("Produktion");
health.Metrics.Keys.ShouldNotContain("instanceName");
}
}
@@ -0,0 +1,50 @@
using System.Net;
using ClawdDotNet.Core.Deploymentcenter;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Deploymentcenter;
public sealed class TokenProvisionerTests
{
[Fact]
public async Task Instanz_bekommt_ein_eigenes_Sub_Token()
{
var handler = new CapturingHandler(HttpStatusCode.Created,
"""
{"status":"success","sub_token":"dc_sub_abc","token_id":"tok_s_1",
"scopes":["watchdog:ping","bugtracker:report"]}
""");
using var api = new DeploymentcenterApi("https://dc.example", "master", new HttpClient(handler));
var token = await new TokenProvisioner(api).ProvisionAsync(
"ClawdDotNet Produktion", "inst-1", TokenProvisioner.InstanceScopes);
handler.LastRequest!.RequestUri!.ToString().ShouldBe("https://dc.example/api/tokens/v1/provision");
handler.LastRequest.Headers.Authorization!.ToString().ShouldBe("Bearer master");
handler.LastBody!.ShouldContain("\"instance_id\":\"inst-1\"");
handler.LastBody.ShouldContain("watchdog:ping");
token.Token.ShouldBe("dc_sub_abc");
token.TokenId.ShouldBe("tok_s_1");
token.Scopes.ShouldContain("bugtracker:report");
}
/// <summary>
/// Ein Sub-Token darf keine weiteren ausstellen. Das ist der Regelfall beim zweiten
/// Start und muss als sauberer Fehler herauskommen, nicht als Absturz.
/// </summary>
[Fact]
public async Task Abgelehnte_Provisionierung_wirft_mit_Code()
{
var handler = new CapturingHandler(HttpStatusCode.Forbidden,
"""{"status":"error","error":{"code":"provision_denied","message":"Kein Master-Token."}}""");
using var api = new DeploymentcenterApi("https://dc.example", "sub", new HttpClient(handler));
var ex = await Should.ThrowAsync<DeploymentcenterException>(
() => new TokenProvisioner(api).ProvisionAsync("x", "inst-1", ["watchdog:ping"]));
ex.Code.ShouldBe("provision_denied");
}
}
@@ -0,0 +1,137 @@
using System.Net;
using ClawdDotNet.Core.Deploymentcenter;
using ClawdDotNet.Core.Deploymentcenter.Watchdog;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Deploymentcenter;
public sealed class WatchdogClientTests
{
private static WatchdogClient Client(HttpMessageHandler handler, string instance = "inst-1") =>
WatchdogClient.Create("https://dc.example", "tok", "clawddotnet", instance,
"ClawdDotNet", "Windows / .NET 10", "0.1.0", new HttpClient(handler));
private static InstanceHealth Health(
string status = WatchdogStatus.Ok,
string? message = null,
Dictionary<string, double>? metrics = null,
Dictionary<string, HealthCheck>? checks = null)
=> new(status, message, metrics ?? [], checks ?? []);
[Fact]
public async Task Heartbeat_geht_an_den_v1_Pfad_mit_Bearer_Token()
{
var handler = new CapturingHandler();
await Client(handler).SendHeartbeatAsync(Health(message: "Betrieb normal."), 60, default);
handler.LastRequest!.RequestUri!.ToString().ShouldBe("https://dc.example/api/watchdog/v1/ping");
handler.LastRequest.Method.ShouldBe(HttpMethod.Post);
handler.LastRequest.Headers.Authorization!.ToString().ShouldBe("Bearer tok");
handler.LastBody.ShouldNotBeNull();
handler.LastBody.ShouldContain("\"source\":\"clawddotnet\"");
handler.LastBody.ShouldContain("\"instance\":\"inst-1\"");
handler.LastBody.ShouldContain("\"type\":\"heartbeat\"");
handler.LastBody.ShouldContain("\"interval\":60");
}
/// <summary>
/// Der Server legt die Version in <c>watchdog_monitors.app_version</c> ab. Bei
/// mehreren Instanzen ist das der Unterschied zwischen „läuft" und „läuft noch auf
/// der alten Fassung".
/// </summary>
[Fact]
public async Task Heartbeat_traegt_die_Anwendungsversion()
{
var handler = new CapturingHandler();
await Client(handler).SendHeartbeatAsync(Health(), 60, default);
handler.LastBody!.ShouldContain("\"version\":\"0.1.0\"");
}
/// <summary>
/// Der Monitor wird serverseitig über source + instance geführt. Zwei Instanzen
/// derselben Anwendung müssen deshalb unterschiedliche instance-Werte melden —
/// sonst überschreiben sie sich gegenseitig, und der Ausfall einer bliebe unsichtbar.
/// </summary>
[Fact]
public async Task Jede_Instanz_meldet_unter_eigenem_Instanznamen()
{
var first = new CapturingHandler();
var second = new CapturingHandler();
await Client(first, "inst-a").SendHeartbeatAsync(Health(), 60, default);
await Client(second, "inst-b").SendHeartbeatAsync(Health(), 60, default);
first.LastBody!.ShouldContain("\"instance\":\"inst-a\"");
second.LastBody!.ShouldContain("\"instance\":\"inst-b\"");
}
[Fact]
public async Task Checks_und_Metriken_gehen_mit()
{
var handler = new CapturingHandler();
await Client(handler).SendHeartbeatAsync(
Health(
metrics: new Dictionary<string, double> { ["agentCount"] = 3 },
checks: new Dictionary<string, HealthCheck>
{
["scheduler"] = new(false, "Steht.")
}),
60, default);
handler.LastBody!.ShouldContain("\"agentCount\":3");
handler.LastBody.ShouldContain("\"scheduler\":{\"ok\":false,\"message\":\"Steht.\"}");
}
/// <summary>Leere Angaben bleiben weg, statt als leeres Objekt zu erscheinen.</summary>
[Fact]
public async Task Ohne_Checks_und_Metriken_bleiben_die_Felder_weg()
{
var handler = new CapturingHandler();
await Client(handler).SendHeartbeatAsync(Health(), 60, default);
handler.LastBody!.ShouldNotContain("\"checks\"");
handler.LastBody.ShouldNotContain("\"metrics\"");
}
[Fact]
public async Task Antwort_liefert_Zustand_und_fehlgeschlagene_Pruefungen()
{
var handler = new CapturingHandler(HttpStatusCode.OK,
"""{"status":"success","monitor":{"state":"warning","failing_checks":["scheduler"]}}""");
var result = await Client(handler).SendHeartbeatAsync(Health(), 60, default);
result.State.ShouldBe("warning");
result.FailingChecks.ShouldHaveSingleItem().ShouldBe("scheduler");
}
[Fact]
public async Task Ereignis_geht_an_den_Event_Endpunkt()
{
var handler = new CapturingHandler();
await Client(handler).SendEventAsync(
WatchdogEventKind.StoppedGraceful, "info", "Instanz beendet.", null, default);
handler.LastRequest!.RequestUri!.ToString().ShouldBe("https://dc.example/api/watchdog/v1/event");
handler.LastBody!.ShouldContain("\"kind\":\"stopped_graceful\"");
}
[Fact]
public async Task Abgelehntes_Token_wirft_mit_Code()
{
var handler = new CapturingHandler(HttpStatusCode.Unauthorized,
"""{"status":"error","error":{"code":"unauthorized","message":"Kein gueltiges Token."}}""");
var ex = await Should.ThrowAsync<DeploymentcenterException>(
() => Client(handler).SendHeartbeatAsync(Health(), 60, default));
ex.Code.ShouldBe("unauthorized");
}
}
@@ -0,0 +1,38 @@
using ClawdDotNet.Core.Tests.Infrastructure;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Engine;
/// <summary>
/// Form des ausgehenden Requests. B11/T8: <c>max_tokens</c> muss gesetzt sein, damit ein
/// Modell nicht in einem Schritt sein volles (teuerstes) Ausgabe-Limit ausschöpft.
/// </summary>
public sealed class RequestShapeTests
{
[Fact]
public async Task Der_Request_setzt_max_tokens()
{
var fixture = new EngineFixture();
var agent = fixture.AddAgent("agent-a");
agent.LoopGuard.MaxResponseTokens = 4_096;
fixture.Client.RespondsWithText("fertig");
await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
fixture.Client.ReceivedRequests.ShouldNotBeEmpty();
fixture.Client.ReceivedRequests[0].MaxTokens.ShouldBe(4_096);
}
[Fact]
public async Task Bei_Wert_0_bleibt_max_tokens_offen()
{
var fixture = new EngineFixture();
var agent = fixture.AddAgent("agent-a");
agent.LoopGuard.MaxResponseTokens = 0; // Anbieter-Standard
fixture.Client.RespondsWithText("fertig");
await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
fixture.Client.ReceivedRequests[0].MaxTokens.ShouldBeNull();
}
}
@@ -1,6 +1,8 @@
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Security;
using ClawdDotNet.Core.Staging;
using ClawdDotNet.Core.Tools;
namespace ClawdDotNet.Core.Tests.Infrastructure;
@@ -18,14 +20,19 @@ internal sealed class EngineFixture
private readonly List<AgentConfig> _agents = new();
public EngineFixture()
/// <summary>Die registrierten Agenten — für Tests, die einen Dispatcher selbst bauen.</summary>
public IReadOnlyList<AgentConfig> Agents => _agents;
public EngineFixture(IAuditRepository? audit = null, StagingGate? staging = null)
{
Engine = new AgentEngine(
Client,
Registry,
new PermissionGate(),
StateStore,
TestLogging.Factory);
TestLogging.Factory,
auditRepository: audit,
stagingGate: staging);
// Kein Agent-Verzeichnis → keine Persistenz auf die Platte während der Tests.
Engine.SetAgentConfigProvider(() => _agents, "test-instance", _ => null);
@@ -0,0 +1,19 @@
namespace ClawdDotNet.Core.Tests.Infrastructure;
/// <summary>
/// Ein steuerbarer <see cref="TimeProvider"/> für die Zeit-abhängigen Tests (R2 der
/// Teststrategie). Bewusst schlank: die Tests treiben den Scanner über
/// <c>ScanOnceAsync</c> direkt, sie brauchen nur eine feste, verstellbare Uhr.
/// </summary>
internal sealed class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public FakeTimeProvider(DateTimeOffset start) => _now = start;
public override DateTimeOffset GetUtcNow() => _now;
public void SetUtcNow(DateTimeOffset value) => _now = value;
public void Advance(TimeSpan delta) => _now += delta;
}
@@ -0,0 +1,40 @@
using System.Collections.Concurrent;
using ClawdDotNet.Core.Audit;
namespace ClawdDotNet.Core.Tests.Infrastructure;
/// <summary>
/// In-Memory-Attrappe des Audit-Logs für Engine-Tests — hält Einträge und Belege in
/// Listen, damit ein Test prüfen kann, was die Engine stempelt, ohne eine Datei.
/// </summary>
internal sealed class InMemoryAuditRepository : IAuditRepository
{
public ConcurrentQueue<AuditEntry> Entries { get; } = new();
public ConcurrentQueue<RunReceipt> Receipts { get; } = new();
public Task AppendAsync(AuditEntry entry, CancellationToken ct)
{
Entries.Enqueue(entry);
return Task.CompletedTask;
}
public Task RecordReceiptAsync(RunReceipt receipt, CancellationToken ct)
{
Receipts.Enqueue(receipt);
return Task.CompletedTask;
}
public Task<IReadOnlyList<AuditEntry>> ListRecentAsync(int limit, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<AuditEntry>>(Entries.Reverse().Take(limit).ToList());
public Task<IReadOnlyList<AuditEntry>> ListForRunAsync(string runId, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<AuditEntry>>(Entries.Where(e => e.RunId == runId).ToList());
public Task<RunReceipt?> GetReceiptForRunAsync(string runId, CancellationToken ct)
=> Task.FromResult(Receipts.LastOrDefault(r => r.RunId == runId));
public Task<IReadOnlyList<RunReceipt>> ListReceiptsForTaskAsync(string taskId, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<RunReceipt>>(Receipts.Where(r => r.TaskId == taskId).ToList());
public Task<int> CountAsync(CancellationToken ct) => Task.FromResult(Entries.Count);
}
@@ -0,0 +1,52 @@
using System.Collections.Concurrent;
using ClawdDotNet.Core.Staging;
namespace ClawdDotNet.Core.Tests.Infrastructure;
/// <summary>In-Memory-Attrappe der Staging-Warteschlange für Engine-Tests.</summary>
internal sealed class InMemoryStagingRepository : IStagingRepository
{
private readonly ConcurrentDictionary<long, StagedCall> _calls = new();
private long _nextId;
private readonly Lock _lock = new();
public Task<long> AppendAsync(StagedCall call, CancellationToken ct)
{
var id = Interlocked.Increment(ref _nextId);
_calls[id] = call with { Id = id, Status = StagingStatus.Pending, CreatedAt = DateTime.UtcNow };
return Task.FromResult(id);
}
public Task<StagedCall?> GetAsync(long id, CancellationToken ct)
=> Task.FromResult(_calls.GetValueOrDefault(id));
public Task<IReadOnlyList<StagedCall>> ListPendingAsync(CancellationToken ct)
=> Task.FromResult<IReadOnlyList<StagedCall>>(
_calls.Values.Where(c => c.Status == StagingStatus.Pending).OrderBy(c => c.Id).ToList());
public Task<bool> TryTransitionAsync(
long id, StagingStatus from, StagingStatus to,
string? decidedBy, string? rejectionReason, DateTime now, CancellationToken ct)
{
lock (_lock)
{
if (!_calls.TryGetValue(id, out var call) || call.Status != from)
return Task.FromResult(false);
_calls[id] = call with
{
Status = to, DecidedBy = decidedBy, DecidedAt = now, RejectionReason = rejectionReason
};
return Task.FromResult(true);
}
}
public Task FinalizeAsync(long id, StagingStatus status, string? resultRef, DateTime now, CancellationToken ct)
{
if (_calls.TryGetValue(id, out var call))
_calls[id] = call with { Status = status, ResultRef = resultRef, DecidedAt = now };
return Task.CompletedTask;
}
public Task<int> CountPendingAsync(CancellationToken ct)
=> Task.FromResult(_calls.Values.Count(c => c.Status == StagingStatus.Pending));
}
@@ -0,0 +1,31 @@
namespace ClawdDotNet.Core.Tests.Infrastructure;
/// <summary>
/// Ein Test, der nur unter Linux etwas aussagt.
///
/// Nötig geworden mit der Linux-Portierung: Groß-/Kleinschreibung und symbolische
/// Verknüpfungen verhalten sich je Dateisystem unterschiedlich. Solche Fälle unter
/// Windows mitlaufen zu lassen, würde entweder fehlschlagen oder — schlimmer — grün
/// sein, ohne das Gemeinte geprüft zu haben.
///
/// Bewusst ein Übersprung mit Begründung statt eines stillen <c>return</c>: Im
/// Testbericht ist dann sichtbar, dass hier etwas <em>nicht</em> geprüft wurde.
/// </summary>
public sealed class LinuxFactAttribute : FactAttribute
{
public LinuxFactAttribute()
{
if (!OperatingSystem.IsLinux())
Skip = "Nur unter Linux aussagekräftig (Groß-/Kleinschreibung, Verknüpfungen).";
}
}
/// <summary>Ein Test, der nur unter Windows etwas aussagt. Siehe <see cref="LinuxFactAttribute"/>.</summary>
public sealed class WindowsFactAttribute : FactAttribute
{
public WindowsFactAttribute()
{
if (!OperatingSystem.IsWindows())
Skip = "Nur unter Windows aussagekräftig.";
}
}
@@ -0,0 +1,33 @@
using System.Runtime.CompilerServices;
using ClawdDotNet.Core.Security;
namespace ClawdDotNet.Core.Tests.Infrastructure;
/// <summary>
/// Hält den Schlüssel von <see cref="SecretKeyStore"/> aus den Tests heraus.
///
/// Zwei Dinge müssen zusammenkommen:
///
/// 1. <see cref="Init"/> verlegt den Schlüssel <b>einmal beim Laden</b> der
/// Testassembly in ein Wegwerfverzeichnis. Ohne das würden die Tests den echten
/// Benutzerschlüssel anlegen oder lesen — auf dem Entwicklungsrechner wie auf dem
/// Bauserver.
///
/// 2. Wer den Schlüssel <em>während</em> eines Tests wechselt (etwa um zu prüfen, dass
/// ein fremder Schlüssel nicht liest), muss in dieser Sammlung laufen. Der Speicher
/// ist statisch; ohne abgeschaltete Parallelität zöge ein solcher Wechsel einer
/// gleichzeitig laufenden Klasse den Schlüssel unter den Füßen weg. Genau das ist
/// zwischen den Geheimnis- und den Sicherungstests passiert.
/// </summary>
[CollectionDefinition(Name, DisableParallelization = true)]
public sealed class SecretKeyCollection
{
public const string Name = "SecretKey";
[ModuleInitializer]
internal static void Init()
{
SecretKeyStore.UseDirectory(Path.Combine(
Path.GetTempPath(), "clawd-tests", "keys", Guid.NewGuid().ToString("N")));
}
}
@@ -0,0 +1,90 @@
using ClawdDotNet.Core.Scheduling;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Scheduling;
/// <summary>
/// Cron-Parsing und Terminberechnung (deckt Teile von B6/B7 ab). Der bisher ungetestete
/// Basis-Scheduler bekommt hier sein Netz: gültige Ausdrücke rechnen richtig, ungültige
/// werfen eine saubere <see cref="FormatException"/> statt einer rohen Parser-Exception
/// oder einer Endlosschleife.
/// </summary>
public sealed class CronExpressionTests
{
private static DateTime Local(int y, int mo, int d, int h, int mi)
=> new(y, mo, d, h, mi, 0, DateTimeKind.Local);
// ─── R1: korrekte nächste Zeitpunkte ───
[Fact]
public void Alle_30_Minuten()
{
var cron = CronExpression.Parse("*/30 * * * *");
cron.GetNextOccurrence(Local(2026, 7, 31, 10, 5)).ShouldBe(Local(2026, 7, 31, 10, 30));
cron.GetNextOccurrence(Local(2026, 7, 31, 10, 45)).ShouldBe(Local(2026, 7, 31, 11, 0));
}
[Fact]
public void Werktags_um_drei_Uhr_frueh()
{
var cron = CronExpression.Parse("0 3 * * 1-5"); // MoFr
// 2026-08-01 ist ein Samstag → nächster Lauf Montag, 2026-08-03 03:00
cron.GetNextOccurrence(Local(2026, 8, 1, 4, 0)).ShouldBe(Local(2026, 8, 3, 3, 0));
}
[Fact]
public void Listen_von_Minuten()
{
var cron = CronExpression.Parse("15,45 * * * *");
cron.GetNextOccurrence(Local(2026, 7, 31, 10, 20)).ShouldBe(Local(2026, 7, 31, 10, 45));
cron.GetNextOccurrence(Local(2026, 7, 31, 10, 50)).ShouldBe(Local(2026, 7, 31, 11, 15));
}
[Fact]
public void Schaltjahr_29_Februar()
{
var cron = CronExpression.Parse("0 0 29 2 *"); // nur am 29.02.
// 2028 ist das nächste Schaltjahr
cron.GetNextOccurrence(Local(2026, 3, 1, 0, 0)).ShouldBe(Local(2028, 2, 29, 0, 0));
}
// ─── R2: Invariante ───
[Theory]
[InlineData("*/5 * * * *")]
[InlineData("0 3 * * 1-5")]
[InlineData("15,45 9-17 * * *")]
[InlineData("0 0 1 * *")]
public void Der_naechste_Termin_liegt_echt_in_der_Zukunft_und_passt(string expr)
{
var cron = CronExpression.Parse(expr);
var now = Local(2026, 7, 31, 10, 23);
var next = cron.GetNextOccurrence(now);
next.ShouldNotBeNull();
next!.Value.ShouldBeGreaterThan(now);
cron.Matches(next.Value).ShouldBeTrue();
}
// ─── R3: ungültige Ausdrücke ───
[Theory]
[InlineData("* * *")] // zu wenige Felder
[InlineData("* * * * * *")] // zu viele Felder
[InlineData("99 * * * *")] // Minute außerhalb 0-59
[InlineData("* 25 * * *")] // Stunde außerhalb 0-23
[InlineData("a b c d e")] // keine Zahlen
[InlineData("*/0 * * * *")] // Schritt 0 (wäre Endlosschleife)
[InlineData("5-2 * * * *")] // rückwärtiger Bereich
[InlineData("* * * * ")] // ein leeres Feld nach Trim → 4 Felder
public void Ungueltige_Ausdruecke_werfen_eine_saubere_FormatException(string expr)
=> Should.Throw<FormatException>(() => CronExpression.Parse(expr));
[Fact]
public void Ein_gueltiger_Ausdruck_mit_Schritt_bleibt_endlich()
{
// Regressionswächter: */0 hätte früher eine Endlosschleife erzeugt.
Should.CompleteIn(() => CronExpression.Parse("*/15 * * * *"), TimeSpan.FromSeconds(2));
}
}
@@ -0,0 +1,88 @@
using ClawdDotNet.Core.Scheduling;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Scheduling;
/// <summary>
/// Zeitzonen heißen auf Windows und Linux verschieden. Task-Dateien wandern zwischen
/// beiden — also muss jede Seite die Schreibweise der anderen deuten können.
///
/// Die Tests laufen bewusst auf beiden Plattformen mit denselben Erwartungen: Genau
/// das ist die Eigenschaft, die wir brauchen.
/// </summary>
public sealed class TimeZonesTests
{
[Theory]
[InlineData("Europe/Berlin")]
[InlineData("W. Europe Standard Time")]
[InlineData("America/New_York")]
[InlineData("Eastern Standard Time")]
[InlineData("UTC")]
public void Beide_Schreibweisen_sind_aufloesbar(string id)
{
TimeZones.TryResolve(id).ShouldNotBeNull();
TimeZones.IsKnown(id).ShouldBeTrue();
}
[Fact]
public void Beide_Schreibweisen_meinen_dieselbe_Zone()
{
var iana = TimeZones.TryResolve("Europe/Berlin");
var windows = TimeZones.TryResolve("W. Europe Standard Time");
var sommer = new DateTime(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
iana!.GetUtcOffset(sommer).ShouldBe(windows!.GetUtcOffset(sommer));
iana.GetUtcOffset(sommer).ShouldBe(TimeSpan.FromHours(2)); // MESZ
}
[Theory]
[InlineData("Phantasie/Ort")]
[InlineData("Nicht Existierende Standard Time")]
[InlineData("völliger Unsinn")]
public void Unbekannte_Kennungen_werden_gemeldet_statt_geraten(string id)
{
TimeZones.TryResolve(id).ShouldBeNull();
TimeZones.IsKnown(id).ShouldBeFalse();
}
[Fact]
public void Leer_gilt_als_gueltig_denn_es_bedeutet_keine_Angabe()
{
TimeZones.IsKnown("").ShouldBeTrue();
TimeZones.IsKnown(null).ShouldBeTrue();
TimeZones.IsKnown(" ").ShouldBeTrue();
}
[Theory]
[InlineData("W. Europe Standard Time", "Europe/Berlin")]
[InlineData("Europe/Berlin", "Europe/Berlin")]
[InlineData("", "")]
public void Normalisieren_ergibt_die_IANA_Schreibweise(string input, string expected)
{
TimeZones.ToIana(input).ShouldBe(expected);
}
[Fact]
public void Eine_nicht_umsetzbare_Kennung_bleibt_unveraendert()
{
// Verfälschen wäre schlimmer als durchreichen — so bleibt im Zweifel sichtbar,
// was ursprünglich dastand.
TimeZones.ToIana("Phantasie Standard Time").ShouldBe("Phantasie Standard Time");
}
[Fact]
public void Die_lokale_Zone_wird_in_IANA_Schreibweise_geliefert()
{
var local = TimeZones.LocalIanaId;
local.ShouldNotBeNullOrWhiteSpace();
TimeZones.IsKnown(local).ShouldBeTrue();
// Der eigentliche Punkt: Was hier herauskommt, wird in Task-Dateien geschrieben
// und muss auf der anderen Plattform lesbar sein. IANA-Kennungen enthalten einen
// Schrägstrich, Windows-Kennungen nie — UTC ist die Ausnahme ohne beides.
if (local != "UTC")
local.ShouldContain("/");
}
}
@@ -1,6 +1,7 @@
using System.Text.Json;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Security;
using ClawdDotNet.Core.Tests.Infrastructure;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Security;
@@ -9,9 +10,29 @@ namespace ClawdDotNet.Core.Tests.Security;
/// S7 aus der Bestandsaufnahme: OpenRouter-Schlüssel, Datenbank-Verbindungszeichenfolgen
/// samt Passwort, Mail-Zugangsdaten und das Telegram-2FA-Passwort lagen im Klartext in
/// den JSON-Dateien. Wer die Dateien lesen konnte, hatte alle Zugänge.
///
/// Mit der Linux-Portierung kam <c>enc:v2</c> dazu (AES-GCM statt DPAPI). Die Tests
/// laufen deshalb gegen einen eigenen Schlüssel in einem Wegwerfverzeichnis — sonst
/// würden sie den echten Benutzerschlüssel anlegen oder lesen.
/// </summary>
public sealed class SecretProtectorTests
[Collection(SecretKeyCollection.Name)]
public sealed class SecretProtectorTests : IDisposable
{
private readonly string _keyDir;
public SecretProtectorTests()
{
_keyDir = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
SecretKeyStore.UseDirectory(_keyDir);
}
public void Dispose()
{
SecretKeyStore.UseDirectory(null);
try { Directory.Delete(_keyDir, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
[Fact]
public void Ein_verschluesselter_Wert_laesst_sich_wieder_lesen()
{
@@ -75,6 +96,9 @@ public sealed class SecretProtectorTests
// Klartext auszugeben würde einen unbrauchbaren Schlüssel an die API schicken.
Should.Throw<SecretProtectionException>(
() => SecretProtector.Unprotect("enc:v1:das-ist-kein-gueltiger-block"));
Should.Throw<SecretProtectionException>(
() => SecretProtector.Unprotect("enc:v2:das-ist-kein-gueltiger-block"));
}
[Fact]
@@ -84,10 +108,120 @@ public sealed class SecretProtectorTests
SecretProtector.Unprotect(SecretProtector.Protect(secret)).ShouldBe(secret);
}
// ═══════════════════════════════════════════════════════════
// Plattformübergreifendes Format (enc:v2)
// ═══════════════════════════════════════════════════════════
[Fact]
public void Neue_Werte_werden_im_plattformuebergreifenden_Format_geschrieben()
{
// Der Kern der Portierung: Unter Linux gab die vorige Fassung hier den Klartext
// zurück. Auf einem Server, der gesichert wird, war das schlechter als nichts.
var value = SecretProtector.Protect("sk-or-v1-geheim");
value.ShouldStartWith("enc:v2:");
}
[Fact]
public void Dieselbe_Eingabe_ergibt_zweimal_verschiedene_Ausgaben()
{
// AES-GCM mit zufälligem Nonce: Aus gleichen Werten dürfen keine gleichen
// Blöcke werden, sonst verrät die Konfigurationsdatei, wo dasselbe Passwort
// mehrfach benutzt wird.
var a = SecretProtector.Protect("dasselbe-passwort");
var b = SecretProtector.Protect("dasselbe-passwort");
a.ShouldNotBe(b);
SecretProtector.Unprotect(a).ShouldBe("dasselbe-passwort");
SecretProtector.Unprotect(b).ShouldBe("dasselbe-passwort");
}
[Fact]
public void Ein_veraenderter_Block_wird_erkannt_und_nicht_entschluesselt()
{
// Der Zweck der Authentifizierung in AES-GCM: Ein manipulierter Block darf
// keinen halb geratenen Klartext ergeben.
var value = SecretProtector.Protect("sk-or-v1-geheim")!;
var payload = Convert.FromBase64String(value["enc:v2:".Length..]);
payload[^1] ^= 0xFF;
var tampered = "enc:v2:" + Convert.ToBase64String(payload);
Should.Throw<SecretProtectionException>(() => SecretProtector.Unprotect(tampered));
}
[Fact]
public void Ein_fremder_Schluessel_kann_den_Wert_nicht_lesen()
{
// Genau die Eigenschaft, die den Schutz ausmacht: Die Konfigurationsdatei allein
// nützt auf einem anderen Rechner nichts, weil der Schlüssel nicht mitreist.
var value = SecretProtector.Protect("sk-or-v1-geheim");
var fremd = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
try
{
SecretKeyStore.UseDirectory(fremd);
Should.Throw<SecretProtectionException>(() => SecretProtector.Unprotect(value));
}
finally
{
SecretKeyStore.UseDirectory(_keyDir);
try { Directory.Delete(fremd, recursive: true); } catch { }
}
}
[Fact]
public void Der_Schluessel_wird_einmal_angelegt_und_dann_wiederverwendet()
{
var first = SecretProtector.Protect("geheim");
var keyBytes = File.ReadAllBytes(SecretKeyStore.KeyFilePath);
var second = SecretProtector.Protect("noch-geheimer");
File.ReadAllBytes(SecretKeyStore.KeyFilePath).ShouldBe(keyBytes);
SecretProtector.Unprotect(first).ShouldBe("geheim");
SecretProtector.Unprotect(second).ShouldBe("noch-geheimer");
}
[LinuxFact]
public void Die_Schluesseldatei_ist_nur_fuer_den_Besitzer_lesbar()
{
SecretProtector.Protect("geheim");
File.GetUnixFileMode(SecretKeyStore.KeyFilePath)
.ShouldBe(UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
[LinuxFact]
public void Ein_DPAPI_Wert_aus_einer_Windows_Instanz_wird_mit_Begruendung_abgewiesen()
{
// Der Umzugsfall. Ihn als Klartext durchzureichen würde einen unbrauchbaren
// Schlüssel an die API schicken — die Meldung muss sagen, was zu tun ist.
var ex = Should.Throw<SecretProtectionException>(
() => SecretProtector.Unprotect("enc:v1:" + Convert.ToBase64String([1, 2, 3, 4])));
ex.Message.ShouldContain("neu eingetragen");
}
}
public sealed class ConfigSecretsTests
[Collection(SecretKeyCollection.Name)]
public sealed class ConfigSecretsTests : IDisposable
{
private readonly string _keyDir;
public ConfigSecretsTests()
{
_keyDir = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
SecretKeyStore.UseDirectory(_keyDir);
}
public void Dispose()
{
SecretKeyStore.UseDirectory(null);
try { Directory.Delete(_keyDir, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
[Theory]
[InlineData("password", true)]
[InlineData("apiKey", true)]
@@ -0,0 +1,96 @@
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Staging;
using ClawdDotNet.Core.Tests.Infrastructure;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Staging;
/// <summary>
/// A2 an der Engine — die eigentliche Sicherheitswirkung: Eine <c>approve</c>-Aktion wird
/// <b>vorgeschlagen statt ausgeführt</b>. Eine Prompt-Injection kann damit nur einen
/// Vorschlag erzeugen, keine Ausführung.
/// </summary>
public sealed class EngineStagingTests
{
private static StagingGate Gate(InMemoryStagingRepository repo, StagingDecision decision)
=> new(new StagingPolicy(new Dictionary<string, StagingDecision> { ["TestTool"] = decision }), repo);
[Fact]
public async Task Eine_freigabepflichtige_Aktion_wird_vorgeschlagen_statt_ausgefuehrt()
{
var staging = new InMemoryStagingRepository();
var tool = FakeTool.Returning("ausgeführt");
var fixture = new EngineFixture(staging: Gate(staging, StagingDecision.Approve)).WithTool(tool);
var agent = fixture.AddAgent("agent-a", "TestTool");
fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("fertig");
await fixture.Engine.RunAsync(agent, "sende die Mail", "test-instance", default);
tool.Invocations.ShouldBeEmpty("die Aktion darf nicht ausgeführt worden sein");
(await staging.CountPendingAsync(default)).ShouldBe(1, "sie liegt als Vorschlag vor");
}
[Fact]
public async Task Der_Vorschlag_wird_als_Staged_ins_Audit_Log_geschrieben()
{
var staging = new InMemoryStagingRepository();
var audit = new InMemoryAuditRepository();
var fixture = new EngineFixture(audit, Gate(staging, StagingDecision.Approve))
.WithTool(FakeTool.Returning("x"));
var agent = fixture.AddAgent("agent-a", "TestTool");
fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("fertig");
await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
audit.Entries.Single().Status.ShouldBe(AuditStatus.Staged);
}
[Fact]
public async Task Eine_gesperrte_Aktion_wird_abgelehnt_und_nicht_ausgefuehrt()
{
var staging = new InMemoryStagingRepository();
var audit = new InMemoryAuditRepository();
var tool = FakeTool.Returning("x");
var fixture = new EngineFixture(audit, Gate(staging, StagingDecision.Deny)).WithTool(tool);
var agent = fixture.AddAgent("agent-a", "TestTool");
fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("fertig");
await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
tool.Invocations.ShouldBeEmpty();
(await staging.CountPendingAsync(default)).ShouldBe(0, "deny legt keinen Vorschlag an");
audit.Entries.Single().Status.ShouldBe(AuditStatus.Denied);
}
[Fact]
public async Task Eine_auto_Aktion_laeuft_unveraendert_durch()
{
var staging = new InMemoryStagingRepository();
var tool = FakeTool.Returning("ausgeführt");
var fixture = new EngineFixture(staging: Gate(staging, StagingDecision.Auto)).WithTool(tool);
var agent = fixture.AddAgent("agent-a", "TestTool");
fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("fertig");
await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
tool.Invocations.Count.ShouldBe(1, "auto führt normal aus");
(await staging.CountPendingAsync(default)).ShouldBe(0);
}
[Fact]
public async Task Ein_freigegebener_Aufruf_wird_direkt_ausgefuehrt_ohne_erneute_Pruefung()
{
// ExecuteApprovedCallAsync umgeht das Gate — genau der eingefrorene Aufruf läuft.
var staging = new InMemoryStagingRepository();
var tool = FakeTool.Returning("gesendet");
var fixture = new EngineFixture(staging: Gate(staging, StagingDecision.Approve)).WithTool(tool);
fixture.AddAgent("agent-a", "TestTool");
var result = await fixture.Engine.ExecuteApprovedCallAsync(
"agent-a", "TestTool", "{\"action\":\"send\"}", "run-x", default);
result.ShouldBe("gesendet");
tool.Invocations.Count.ShouldBe(1);
(await staging.CountPendingAsync(default)).ShouldBe(0, "ein freigegebener Aufruf staged nicht erneut");
}
}
@@ -0,0 +1,49 @@
using ClawdDotNet.Core.Staging;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Staging;
public sealed class StagingPolicyTests
{
[Theory]
[InlineData("Mail", "send")]
[InlineData("Telegram", "send_message")]
[InlineData("Database", "insert")]
[InlineData("Database", "upsert")]
[InlineData("FileRW", "delete")]
[InlineData("FTP", "upload")]
[InlineData("FTP", "delete")]
public void Irreversible_Aktionen_brauchen_standardmaessig_eine_Freigabe(string tool, string action)
=> new StagingPolicy().Decide(tool, action).ShouldBe(StagingDecision.Approve);
[Theory]
[InlineData("Mail", "read_inbox")]
[InlineData("FileRW", "read")]
[InlineData("Database", "query")]
[InlineData("FTP", "download")]
public void Lesende_Aktionen_laufen_ohne_Freigabe(string tool, string action)
=> new StagingPolicy().Decide(tool, action).ShouldBe(StagingDecision.Auto);
[Fact]
public void Die_speziellere_Regel_gewinnt()
{
var policy = new StagingPolicy(new Dictionary<string, StagingDecision>
{
["FileRW"] = StagingDecision.Approve, // ganzes Tool
["FileRW.read"] = StagingDecision.Auto // aber Lesen frei
});
policy.Decide("FileRW", "read").ShouldBe(StagingDecision.Auto);
policy.Decide("FileRW", "write").ShouldBe(StagingDecision.Approve);
}
[Fact]
public void Ohne_passende_Regel_gilt_der_Standard()
{
new StagingPolicy(new Dictionary<string, StagingDecision>())
.Decide("Irgendein", "ding").ShouldBe(StagingDecision.Auto);
new StagingPolicy(new Dictionary<string, StagingDecision>(), StagingDecision.Deny)
.Decide("Irgendein", "ding").ShouldBe(StagingDecision.Deny);
}
}
@@ -0,0 +1,96 @@
using ClawdDotNet.Core.Staging;
using ClawdDotNet.Core.Storage;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Staging;
/// <summary>
/// Der bedingte Statuswechsel ist der Kern: zwei Reviewer dürfen denselben Vorschlag nicht
/// doppelt entscheiden. Getestet gegen echte SQLite.
/// </summary>
public sealed class StagingRepositoryTests : IDisposable
{
private readonly string _directory;
private readonly SqliteStorage _storage;
private readonly SqliteStagingRepository _repo;
public StagingRepositoryTests()
{
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
_repo = new SqliteStagingRepository(_storage);
}
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
private Task<long> Stage(string tool = "Mail", string action = "send")
=> _repo.AppendAsync(new StagedCall
{
RunId = "run-1",
AgentId = "agent-a",
Tool = tool,
Action = action,
ArgumentsJson = "{\"action\":\"send\",\"to\":\"x@y.de\"}",
Proposal = $"{tool}.{action}"
}, default);
[Fact]
public async Task Ein_Vorschlag_wird_angelegt_und_erscheint_als_offen()
{
var id = await Stage();
var pending = await _repo.ListPendingAsync(default);
pending.Count.ShouldBe(1);
pending[0].Id.ShouldBe(id);
pending[0].Status.ShouldBe(StagingStatus.Pending);
(await _repo.CountPendingAsync(default)).ShouldBe(1);
}
[Fact]
public async Task Der_eingefrorene_Argument_JSON_bleibt_erhalten()
{
var id = await Stage();
(await _repo.GetAsync(id, default))!.ArgumentsJson.ShouldContain("x@y.de");
}
[Fact]
public async Task Nur_ein_Reviewer_kann_denselben_Vorschlag_entscheiden()
{
var id = await Stage();
var results = await Task.WhenAll(
Enumerable.Range(0, 16).Select(i =>
_repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Approved,
$"user-{i}", null, DateTime.UtcNow, default)));
results.Count(won => won).ShouldBe(1, "genau ein Reviewer gewinnt den Übergang");
}
[Fact]
public async Task Ein_bereits_entschiedener_Vorschlag_laesst_sich_nicht_erneut_wechseln()
{
var id = await Stage();
(await _repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Rejected, "u", "nein", DateTime.UtcNow, default)).ShouldBeTrue();
(await _repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Approved, "u2", null, DateTime.UtcNow, default))
.ShouldBeFalse("er ist nicht mehr pending");
(await _repo.ListPendingAsync(default)).ShouldBeEmpty();
}
[Fact]
public async Task Finalize_setzt_Endstatus_und_Ergebnis()
{
var id = await Stage();
await _repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Approved, "u", null, DateTime.UtcNow, default);
await _repo.FinalizeAsync(id, StagingStatus.Executed, "gesendet", DateTime.UtcNow, default);
var call = await _repo.GetAsync(id, default);
call!.Status.ShouldBe(StagingStatus.Executed);
call.ResultRef.ShouldBe("gesendet");
}
}
@@ -0,0 +1,156 @@
using ClawdDotNet.Core.Staging;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Staging;
/// <summary>
/// Der Freigabe-/Ablehnungs-Fluss (A2). Kernpunkte: der eingefrorene Aufruf wird bei
/// Freigabe direkt ausgeführt, der Agent wird über einen Folge-Task (A1) geweckt, und jede
/// Entscheidung landet als Approval-Record im Audit-Log (A3).
/// </summary>
public sealed class StagingServiceTests : IDisposable
{
private readonly string _directory;
private readonly SqliteStorage _storage;
private readonly SqliteStagingRepository _staging;
private readonly SqliteTaskRepository _tasks;
private readonly TaskboardService _board;
private readonly InMemoryAuditRepository _audit = new();
private readonly FakeExecutor _executor = new();
private readonly StagingService _service;
public StagingServiceTests()
{
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
_staging = new SqliteStagingRepository(_storage);
_tasks = new SqliteTaskRepository(_storage);
_board = new TaskboardService(_tasks, Path.Combine(_directory, "SharedWorkspace", "tasks"));
_service = new StagingService(_staging, _executor, _board, NullLoggerFactory.Instance, _audit);
}
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
private Task<long> Stage(string args = "{\"action\":\"send\",\"to\":\"x@y.de\"}")
=> _staging.AppendAsync(new StagedCall
{
RunId = "run-1",
AgentId = "agent-a",
Tool = "Mail",
Action = "send",
ArgumentsJson = args,
Proposal = "Mail.send"
}, default);
[Fact]
public async Task Freigabe_fuehrt_genau_den_eingefrorenen_Aufruf_aus()
{
var id = await Stage("{\"action\":\"send\",\"to\":\"chef@firma.de\"}");
var result = await _service.ApproveAsync(id, "richard", default);
result.Kind.ShouldBe(StagingResultKind.Executed);
_executor.Calls.Count.ShouldBe(1);
_executor.Calls[0].Tool.ShouldBe("Mail");
_executor.Calls[0].Args.ShouldContain("chef@firma.de", Case.Sensitive);
(await _staging.GetAsync(id, default))!.Status.ShouldBe(StagingStatus.Executed);
}
[Fact]
public async Task Freigabe_weckt_den_Agenten_ueber_einen_Folge_Task()
{
var id = await Stage();
await _service.ApproveAsync(id, "richard", default);
var tasks = await _tasks.ListAsync(new TaskQuery(), default);
tasks.Count.ShouldBe(1);
tasks[0].Assignee.ShouldBe("@agent-a", "der vorschlagende Agent wird mit seinem Kontext geweckt");
tasks[0].Status.ShouldBe(TaskItemStatus.Todo);
tasks[0].Body.ShouldContain("freigegeben");
}
[Fact]
public async Task Jede_Freigabe_wird_als_Approval_Record_protokolliert()
{
var id = await Stage();
await _service.ApproveAsync(id, "richard", default);
var entry = _audit.Entries.Single();
entry.Source.ShouldBe("approval");
entry.Tool.ShouldBe("Mail");
entry.Summary.ShouldContain("Freigegeben von richard");
}
[Fact]
public async Task Ablehnung_fuehrt_nichts_aus_und_weckt_den_Agenten_mit_dem_Grund()
{
var id = await Stage();
var result = await _service.RejectAsync(id, "richard", "zu riskant", default);
result.Kind.ShouldBe(StagingResultKind.Rejected);
_executor.Calls.ShouldBeEmpty("eine Ablehnung führt nichts aus");
(await _staging.GetAsync(id, default))!.Status.ShouldBe(StagingStatus.Rejected);
var tasks = await _tasks.ListAsync(new TaskQuery(), default);
tasks[0].Body.ShouldContain("abgelehnt");
tasks[0].Body.ShouldContain("zu riskant");
_audit.Entries.Single().Summary.ShouldContain("Abgelehnt von richard");
}
[Fact]
public async Task Ein_zweiter_Freigabeklick_laeuft_ins_Leere_und_fuehrt_nicht_erneut_aus()
{
var id = await Stage();
var first = await _service.ApproveAsync(id, "a", default);
var second = await _service.ApproveAsync(id, "b", default);
first.Kind.ShouldBe(StagingResultKind.Executed);
second.Kind.ShouldBe(StagingResultKind.AlreadyDecided);
_executor.Calls.Count.ShouldBe(1, "genau einmal ausgeführt");
}
[Fact]
public async Task Ein_unbekannter_Vorschlag_wird_gemeldet()
=> (await _service.ApproveAsync(999, "x", default)).Kind.ShouldBe(StagingResultKind.NotFound);
[Fact]
public async Task Scheitert_die_Ausfuehrung_wird_der_Vorschlag_als_fehlgeschlagen_markiert()
{
_executor.Throw = true;
var id = await Stage();
var result = await _service.ApproveAsync(id, "richard", default);
result.Kind.ShouldBe(StagingResultKind.Failed);
(await _staging.GetAsync(id, default))!.Status.ShouldBe(StagingStatus.Failed);
// Der Agent wird trotzdem geweckt (mit der Fehlermeldung).
(await _tasks.ListAsync(new TaskQuery(), default)).Count.ShouldBe(1);
}
private sealed class FakeExecutor : IFrozenCallExecutor
{
public List<(string Agent, string Tool, string Args)> Calls { get; } = [];
public bool Throw { get; set; }
public Task<string> ExecuteApprovedCallAsync(
string agentId, string tool, string argumentsJson, string runId, CancellationToken ct)
{
if (Throw) throw new InvalidOperationException("Tool kaputt");
Calls.Add((agentId, tool, argumentsJson));
return Task.FromResult("ausgeführt");
}
}
}
@@ -0,0 +1,152 @@
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tests.Infrastructure;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Storage;
/// <summary>
/// Die Einschließungsprüfung verglich bis zur Linux-Portierung mit
/// <c>OrdinalIgnoreCase</c> — die Windows-Annahme, dass Groß- und Kleinschreibung
/// zusammenfallen. Unter Linux sind <c>Workspace</c> und <c>workspace</c> zwei
/// Verzeichnisse, und eine symbolische Verknüpfung kann aus dem Wurzelverzeichnis
/// hinauszeigen, ohne dass die reine Pfadrechnung das bemerkt.
///
/// Das Dateisystem wird hier bewusst nicht abstrahiert: Genau die Semantik, um die es
/// geht, würde eine Abstraktion verstecken.
/// </summary>
public sealed class PathBoundaryTests : IDisposable
{
private readonly string _base;
private readonly string _root;
public PathBoundaryTests()
{
_base = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
_root = Path.Combine(_base, "Workspace");
Directory.CreateDirectory(_root);
}
public void Dispose()
{
try { Directory.Delete(_base, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
// ═══════════════════════════════════════════════════════════
// Verzeichnisgrenzen (auf beiden Plattformen gleich)
// ═══════════════════════════════════════════════════════════
[Fact]
public void Ein_Nachbarverzeichnis_mit_gleichem_Praefix_liegt_ausserhalb()
{
PathBoundary.IsInside(Path.Combine(_root + "-Backup", "geheim.txt"), _root)
.ShouldBeFalse();
}
[Fact]
public void Das_Wurzelverzeichnis_selbst_liegt_innerhalb()
{
PathBoundary.IsInside(_root, _root).ShouldBeTrue();
PathBoundary.IsInside(_root + Path.DirectorySeparatorChar, _root).ShouldBeTrue();
}
[Fact]
public void Ein_Unterverzeichnis_liegt_innerhalb()
{
PathBoundary.IsInside(Path.Combine(_root, "a", "b", "c.txt"), _root).ShouldBeTrue();
}
[Fact]
public void Punkt_Punkt_fuehrt_hinaus()
{
PathBoundary.IsInside(Path.Combine(_root, "..", "evil.txt"), _root).ShouldBeFalse();
}
// ═══════════════════════════════════════════════════════════
// Groß-/Kleinschreibung — hier gehen die Plattformen auseinander
// ═══════════════════════════════════════════════════════════
[LinuxFact]
public void Unter_Linux_ist_ein_Verzeichnis_mit_anderer_Schreibweise_ein_anderes()
{
// "workspace" neben "Workspace": zwei echte, getrennte Verzeichnisse.
var lower = Path.Combine(_base, "workspace");
Directory.CreateDirectory(lower);
PathBoundary.IsInside(Path.Combine(lower, "geheim.txt"), _root).ShouldBeFalse();
}
[WindowsFact]
public void Unter_Windows_meint_eine_andere_Schreibweise_dasselbe_Verzeichnis()
{
var lower = Path.Combine(_base, "workspace");
PathBoundary.IsInside(Path.Combine(lower, "datei.txt"), _root).ShouldBeTrue();
}
[Fact]
public void Der_Schluessel_fuer_Sperren_folgt_der_Plattform()
{
var upper = Path.Combine(_base, "Config.json");
var lower = Path.Combine(_base, "config.json");
var gleich = PathBoundary.CanonicalKey(upper) == PathBoundary.CanonicalKey(lower);
// Unter Windows dieselbe Datei — dieselbe Sperre. Unter Linux zwei Dateien,
// die sich keine teilen dürfen.
gleich.ShouldBe(OperatingSystem.IsWindows() || OperatingSystem.IsMacOS());
}
// ═══════════════════════════════════════════════════════════
// Symbolische Verknüpfungen
// ═══════════════════════════════════════════════════════════
[LinuxFact]
public void Eine_Verknuepfung_aus_dem_Wurzelverzeichnis_heraus_wird_erkannt()
{
// Der Fall, den die reine Pfadrechnung nicht sieht: "Workspace/raus" zeigt
// nach draußen, "Workspace/raus/beute.txt" liegt lexikalisch aber innerhalb.
var draussen = Path.Combine(_base, "Draussen");
Directory.CreateDirectory(draussen);
Directory.CreateSymbolicLink(Path.Combine(_root, "raus"), draussen);
PathBoundary.IsInside(Path.Combine(_root, "raus", "beute.txt"), _root)
.ShouldBeFalse();
}
[LinuxFact]
public void Eine_Verknuepfung_innerhalb_des_Wurzelverzeichnisses_bleibt_erlaubt()
{
// Gegenprobe: Wer innerhalb bleibt, wird nicht ausgesperrt.
var ziel = Path.Combine(_root, "echte-daten");
Directory.CreateDirectory(ziel);
Directory.CreateSymbolicLink(Path.Combine(_root, "abkuerzung"), ziel);
PathBoundary.IsInside(Path.Combine(_root, "abkuerzung", "bericht.md"), _root)
.ShouldBeTrue();
}
[LinuxFact]
public void Ein_Wurzelverzeichnis_hinter_einer_Verknuepfung_sperrt_sich_nicht_selbst_aus()
{
// Liegt der Workspace selbst hinter einer Verknüpfung (üblich bei /var → /private/var
// oder gemounteten Datenverzeichnissen), muss die Auflösung auf beiden Seiten
// greifen — sonst passt nach dem Auflösen nichts mehr zusammen.
var echt = Path.Combine(_base, "EchterOrt");
Directory.CreateDirectory(echt);
var ueber = Path.Combine(_base, "Verknuepft");
Directory.CreateSymbolicLink(ueber, echt);
PathBoundary.IsInside(Path.Combine(ueber, "datei.txt"), ueber).ShouldBeTrue();
PathBoundary.IsInside(Path.Combine(echt, "datei.txt"), ueber).ShouldBeTrue();
}
[Fact]
public void Nicht_existierende_Pfade_bleiben_pruefbar()
{
// Beim Schreiben gibt es die Datei noch nicht. Was nicht existiert, kann keine
// Verknüpfung sein — die Auflösung darf daran nicht scheitern.
PathBoundary.IsInside(Path.Combine(_root, "gibt", "es", "noch", "nicht.txt"), _root)
.ShouldBeTrue();
}
}
@@ -0,0 +1,118 @@
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Tasks;
/// <summary>
/// Schritt 4: die Übernahme der alten <c>coordination/*.md</c>-Dateien. Geprüft an den
/// tatsächlichen Altformaten (freies Markdown, kein Frontmatter). Kernanspruch: nur echte
/// Aufgaben (<c>task_*</c>) wandern ins Board, alles andere bleibt unangetastet, und die
/// Migration ist idempotent.
/// </summary>
public sealed class CoordinationMigrationTests : IDisposable
{
private readonly string _directory;
private readonly string _coordinationDir;
private readonly SqliteStorage _storage;
private readonly SqliteTaskRepository _repo;
private readonly TaskboardService _board;
private readonly CoordinationMigration _migration;
public CoordinationMigrationTests()
{
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
var shared = Path.Combine(_directory, "SharedWorkspace");
_coordinationDir = Path.Combine(shared, "coordination");
Directory.CreateDirectory(_coordinationDir);
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
_repo = new SqliteTaskRepository(_storage);
_board = new TaskboardService(_repo, Path.Combine(shared, "tasks"));
_migration = new CoordinationMigration(_board, _coordinationDir, NullLoggerFactory.Instance);
}
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
private void Write(string name, string content)
=> File.WriteAllText(Path.Combine(_coordinationDir, name), content);
[Fact]
public async Task Nur_task_Dateien_werden_uebernommen()
{
Write("task_video.md", "# Task: Video-Zusammenfassung\n## Status: ASSIGNED\nAnalysiere das Video.");
Write("status_senior.md", "# Senior Developer - Status\n## Status: ONLINE");
Write("broadcast.md", "# Broadcast\nBitte meldet euren Status.");
Write("incident_x.md", "# Incident Report\nProblem-Chain ...");
Write("data.json", "{ \"foo\": 1 }");
var migrated = await _migration.RunAsync(default);
migrated.ShouldBe(1);
var tasks = await _repo.ListAsync(new TaskQuery { IncludeArchived = true }, default);
tasks.Count.ShouldBe(1);
tasks[0].Title.ShouldBe("Video-Zusammenfassung");
tasks[0].Status.ShouldBe(TaskItemStatus.Backlog, "erst nach menschlicher Sichtung bereit");
tasks[0].Assignee.ShouldBe(TaskAssignee.Human);
tasks[0].Body.ShouldContain("Analysiere das Video.");
}
[Fact]
public async Task Die_uebernommene_Datei_wandert_nach_migrated_und_bleibt_erhalten()
{
Write("task_a.md", "# Task: Etwas\nInhalt.");
await _migration.RunAsync(default);
File.Exists(Path.Combine(_coordinationDir, "task_a.md")).ShouldBeFalse("aus der obersten Ebene entfernt");
File.Exists(Path.Combine(_coordinationDir, "migrated", "task_a.md")).ShouldBeTrue("aber nicht gelöscht");
}
[Fact]
public async Task Nicht_Aufgaben_bleiben_unangetastet()
{
Write("status_x.md", "# Status\nONLINE");
Write("report.json", "{}");
await _migration.RunAsync(default);
File.Exists(Path.Combine(_coordinationDir, "status_x.md")).ShouldBeTrue();
File.Exists(Path.Combine(_coordinationDir, "report.json")).ShouldBeTrue();
(await _repo.CountAsync(default)).ShouldBe(0);
}
[Fact]
public async Task Die_Migration_ist_idempotent()
{
Write("task_a.md", "# Task: Einmalig\nInhalt.");
(await _migration.RunAsync(default)).ShouldBe(1);
(await _migration.RunAsync(default)).ShouldBe(0, "zweiter Lauf findet keine task_*-Datei mehr");
(await _repo.CountAsync(default)).ShouldBe(1, "keine Dublette");
}
[Fact]
public async Task Ohne_Task_Ueberschrift_kommt_der_Titel_aus_dem_Dateinamen()
{
Write("task_video_analysis_progress.md", "## Status: IN PROGRESS\nOhne echte Überschrift.");
await _migration.RunAsync(default);
var tasks = await _repo.ListAsync(new TaskQuery(), default);
tasks[0].Title.ShouldBe("video analysis progress");
}
[Fact]
public async Task Ohne_coordination_Verzeichnis_passiert_nichts()
{
Directory.Delete(_coordinationDir, recursive: true);
(await _migration.RunAsync(default)).ShouldBe(0);
}
}
@@ -0,0 +1,27 @@
using ClawdDotNet.Core.Tasks;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Tasks;
/// <summary>
/// Der Assignee bestimmt, wie eine fällige Aufgabe zu einem Lauf wird (T7). Die Deutung
/// muss eindeutig sein — davon hängt ab, ob ein Agent frisch oder mit Kontext läuft.
/// </summary>
public sealed class TaskAssigneeTests
{
[Theory]
[InlineData("@human", TaskAssigneeKind.Human)]
[InlineData("@new", TaskAssigneeKind.New)]
[InlineData("@new:crawler", TaskAssigneeKind.New)]
[InlineData("@crawler", TaskAssigneeKind.Agent)]
public void Die_Art_wird_richtig_erkannt(string assignee, TaskAssigneeKind expected)
=> TaskAssignee.KindOf(assignee).ShouldBe(expected);
[Theory]
[InlineData("@crawler", "crawler")]
[InlineData("@new:crawler", "crawler")]
[InlineData("@new", "")] // frischer Lauf ohne benannten Agenten
[InlineData("@human", "")]
public void Die_Agent_Id_wird_richtig_herausgezogen(string assignee, string expected)
=> TaskAssignee.AgentId(assignee).ShouldBe(expected);
}
@@ -0,0 +1,188 @@
using ClawdDotNet.Core.Tasks;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Tasks;
/// <summary>
/// Der Frontmatter-Parser ist die Brücke zwischen der menschen-/agentenlesbaren
/// Task-Datei und dem DB-Zustand. Was er falsch liest, importiert das Board falsch —
/// deshalb steht hier der Rundlauf im Mittelpunkt: Serialisieren und wieder Einlesen
/// muss dieselbe Definition ergeben.
/// </summary>
public sealed class TaskFrontmatterTests
{
[Fact]
public void Ein_vollstaendiges_Frontmatter_wird_gelesen()
{
var text = """
---
id: t-8f3a2c
title: NVDA Earnings recherchieren
status: todo
type: work
priority: 4
assignee: "@crawler"
when:
kind: cron
value: "0 7 * * 1-5"
tz: Europe/Berlin
require_approval: true
acceptance: |
Aktuelle Zahlen mit Datum und Quelle.
In SharedWorkspace/data/nvda.json abgelegt.
blocked_by: [t-4b1e, t-9a2c]
onlyWhenMarketOpen: false
---
Recherchiere die neuesten Quartalszahlen.
""";
TaskFrontmatter.TryParse(text, out var task, out var error).ShouldBeTrue(error);
task.Id.ShouldBe("t-8f3a2c");
task.Title.ShouldBe("NVDA Earnings recherchieren");
task.Status.ShouldBe(TaskItemStatus.Todo);
task.Type.ShouldBe(TaskItemType.Work);
task.Priority.ShouldBe(4);
task.Assignee.ShouldBe("@crawler");
task.When.ShouldNotBeNull();
task.When!.Kind.ShouldBe(TaskWhenKind.Cron);
task.When.Value.ShouldBe("0 7 * * 1-5");
task.When.TimeZone.ShouldBe("Europe/Berlin");
task.RequireApproval.ShouldBeTrue();
task.Acceptance.ShouldContain("nvda.json");
task.Acceptance.ShouldContain("Aktuelle Zahlen");
task.BlockedBy.ShouldBe(["t-4b1e", "t-9a2c"]);
task.OnlyWhenMarketOpen.ShouldBeFalse();
task.Body.ShouldBe("Recherchiere die neuesten Quartalszahlen.");
}
[Fact]
public void Serialisieren_und_wieder_Einlesen_ergibt_dieselbe_Definition()
{
var original = new TaskItem
{
Id = "t-abc123",
Title = "Titel mit: Doppelpunkt und # Raute",
Status = TaskItemStatus.InReview,
Type = TaskItemType.Approval,
Priority = 5,
Assignee = "@human",
When = new TaskWhen { Kind = TaskWhenKind.Every, Value = "30m", TimeZone = "UTC" },
RequireApproval = true,
Acceptance = "Zeile eins\nZeile zwei",
BlockedBy = ["t-1", "t-2"],
OnlyWhenMarketOpen = true,
Body = "Mehrzeiliger\nRumpf mit Umlauten äöü und 🦀."
};
var text = TaskFrontmatter.Serialize(original);
TaskFrontmatter.TryParse(text, out var round, out var error).ShouldBeTrue(error);
round.Id.ShouldBe(original.Id);
round.Title.ShouldBe(original.Title);
round.Status.ShouldBe(original.Status);
round.Type.ShouldBe(original.Type);
round.Priority.ShouldBe(original.Priority);
round.Assignee.ShouldBe(original.Assignee);
round.When!.Kind.ShouldBe(TaskWhenKind.Every);
round.When.Value.ShouldBe("30m");
round.When.TimeZone.ShouldBe("UTC");
round.RequireApproval.ShouldBeTrue();
round.Acceptance.ShouldBe(original.Acceptance);
round.BlockedBy.ShouldBe(["t-1", "t-2"]);
round.OnlyWhenMarketOpen.ShouldBeTrue();
round.Body.ShouldBe(original.Body);
}
[Fact]
public void Ein_Assignee_mit_At_ueberlebt_den_Rundlauf()
{
// In YAML ist ein führendes '@' reserviert — ohne Quotierung ginge es verloren.
var task = new TaskItem { Id = "t-1", Title = "x", Assignee = "@new" };
var text = TaskFrontmatter.Serialize(task);
TaskFrontmatter.TryParse(text, out var round, out _).ShouldBeTrue();
round.Assignee.ShouldBe("@new");
TaskAssignee.KindOf(round.Assignee).ShouldBe(TaskAssigneeKind.New);
}
[Fact]
public void Ohne_when_ist_es_eine_einmalige_Aufgabe()
{
var text = """
---
id: t-1
title: Einmalig
assignee: "@new"
---
Mach das einmal.
""";
TaskFrontmatter.TryParse(text, out var task, out _).ShouldBeTrue();
task.When.ShouldBeNull();
}
[Fact]
public void Fehlende_Felder_fallen_auf_sichere_Standards_zurueck()
{
var text = """
---
id: t-1
title: Minimal
---
""";
TaskFrontmatter.TryParse(text, out var task, out _).ShouldBeTrue();
task.Status.ShouldBe(TaskItemStatus.Todo);
task.Type.ShouldBe(TaskItemType.Work);
task.Priority.ShouldBe(3);
task.Assignee.ShouldBe(TaskAssignee.Human, "ohne Zuordnung wartet die Aufgabe auf einen Menschen");
task.RequireApproval.ShouldBeFalse();
task.BlockedBy.ShouldBeEmpty();
}
[Fact]
public void Eine_unbekannte_Terminart_wird_verworfen_statt_geraten()
{
var text = """
---
id: t-1
title: x
when:
kind: phantasie
value: irgendwas
tz: UTC
---
""";
TaskFrontmatter.TryParse(text, out var task, out _).ShouldBeTrue();
task.When.ShouldBeNull();
}
[Fact]
public void Fehlt_der_Frontmatter_Block_meldet_der_Parser_das()
{
TaskFrontmatter.TryParse("Nur Text, kein Frontmatter.", out _, out var error).ShouldBeFalse();
error.ShouldNotBeNull();
}
[Fact]
public void Ein_nicht_geschlossener_Block_wird_gemeldet()
{
var text = "---\nid: t-1\ntitle: x\n";
TaskFrontmatter.TryParse(text, out _, out var error).ShouldBeFalse();
error.ShouldNotBeNull();
}
[Fact]
public void Windows_Zeilenenden_stoeren_nicht()
{
var text = "---\r\nid: t-1\r\ntitle: Mit CRLF\r\n---\r\nRumpf\r\n";
TaskFrontmatter.TryParse(text, out var task, out var error).ShouldBeTrue(error);
task.Title.ShouldBe("Mit CRLF");
task.Body.ShouldBe("Rumpf");
}
}
@@ -0,0 +1,310 @@
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Tasks;
/// <summary>
/// A1 Taskboard: der Ausführungszustand. Getestet gegen eine echte SQLite-Datei, weil es
/// gerade um Schema, Sperrverhalten und atomares Claiming geht — eine Attrappe würde
/// genau das verstecken, worauf es ankommt.
///
/// Die drei Invarianten aus dem Taskboard-Konzept stehen im Mittelpunkt: nie zwei Claims
/// auf einen Termin, kein Dispatch bei offenem Blocker, doppelter Tick = ein Lauf.
/// </summary>
public sealed class TaskRepositoryTests : IDisposable
{
private readonly string _directory;
private readonly SqliteStorage _storage;
private readonly SqliteTaskRepository _repo;
public TaskRepositoryTests()
{
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
_repo = new SqliteTaskRepository(_storage);
}
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
private Task<TaskItem> Seed(
string id, TaskItemStatus status = TaskItemStatus.Todo,
string assignee = "@new", int priority = 3,
string title = "Aufgabe", IReadOnlyList<string>? blockedBy = null)
=> _repo.UpsertAsync(new TaskItem
{
Id = id,
Title = title,
Status = status,
Assignee = assignee,
Priority = priority,
BlockedBy = blockedBy ?? []
}, default);
private static string Occ(DateTime t) => t.ToUniversalTime().ToString("O");
private static readonly DateTime T0 = new(2026, 7, 31, 7, 0, 0, DateTimeKind.Utc);
private static DateTime Now => DateTime.UtcNow;
private static DateTime NoStaleClaims => Now.AddHours(-1); // Lease-Cutoff weit in der Vergangenheit
// ═══════════════════════════════════════════════════════════
// Grundfunktionen: Import (Upsert), Lesen, Auflisten
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Eine_Aufgabe_wird_angelegt_und_wiedergefunden()
{
await Seed("t-1", title: "Recherche");
var found = await _repo.GetAsync("t-1", default);
found.ShouldNotBeNull();
found!.Title.ShouldBe("Recherche");
found.Status.ShouldBe(TaskItemStatus.Todo);
}
[Fact]
public async Task Der_Import_ist_idempotent_und_erzeugt_keine_Dublette()
{
await Seed("t-1", title: "Erste Fassung");
await Seed("t-1", title: "Zweite Fassung");
(await _repo.CountAsync(default)).ShouldBe(1);
(await _repo.GetAsync("t-1", default))!.Title.ShouldBe("Zweite Fassung");
}
[Fact]
public async Task Ein_Re_Import_laesst_den_Ausfuehrungszustand_unangetastet()
{
// Der springende Punkt der Wahrheitsaufteilung: Die Datei ist Wahrheit über die
// Definition, die DB über den Ausführungszustand. Ein erneuter Import darf einen
// laufenden oder abgeschlossenen Zustand nicht zurücksetzen.
await Seed("t-1");
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok", Now, NoStaleClaims, default)).ShouldBeTrue();
await _repo.CompleteClaimAsync("t-1", "tok", TaskItemStatus.Done, Now, default);
// Datei wird erneut importiert (Titel geändert, Status im Frontmatter noch "todo").
await _repo.UpsertAsync(new TaskItem { Id = "t-1", Title = "geändert", Status = TaskItemStatus.Todo }, default);
var after = await _repo.GetAsync("t-1", default);
after!.Title.ShouldBe("geändert", "die Definition wird übernommen");
after.Status.ShouldBe(TaskItemStatus.Done, "der Ausführungszustand bleibt");
after.LastOccurrence.ShouldBe(Occ(T0), "der Marker bleibt erhalten");
}
[Fact]
public async Task Die_Liste_filtert_nach_Status_und_blendet_Archiviertes_aus()
{
await Seed("t-1", TaskItemStatus.Todo);
await Seed("t-2", TaskItemStatus.Done);
await Seed("t-3", TaskItemStatus.Archived);
(await _repo.ListAsync(new TaskQuery { Status = TaskItemStatus.Todo }, default)).Count.ShouldBe(1);
(await _repo.ListAsync(new TaskQuery(), default)).Count.ShouldBe(2, "Archiviertes ist standardmäßig ausgeblendet");
(await _repo.ListAsync(new TaskQuery { IncludeArchived = true }, default)).Count.ShouldBe(3);
}
[Fact]
public async Task Die_Liste_ordnet_nach_Prioritaet()
{
await Seed("t-low", priority: 1, title: "nebensächlich");
await Seed("t-high", priority: 5, title: "dringend");
await Seed("t-mid", priority: 3, title: "mittel");
var list = await _repo.ListAsync(new TaskQuery(), default);
list[0].Title.ShouldBe("dringend");
list[^1].Title.ShouldBe("nebensächlich");
}
[Fact]
public async Task Die_Liste_filtert_nach_Assignee_und_Freitext()
{
await Seed("t-1", assignee: "@crawler", title: "NVDA Zahlen");
await Seed("t-2", assignee: "@analyst", title: "TSLA Bericht");
(await _repo.ListAsync(new TaskQuery { Assignee = "@crawler" }, default)).Count.ShouldBe(1);
(await _repo.ListAsync(new TaskQuery { Search = "nvda" }, default)).Count.ShouldBe(1);
}
// ═══════════════════════════════════════════════════════════
// Invariante 1 & 3: nie zwei Claims, doppelter Tick = ein Lauf
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Ein_Termin_laesst_sich_genau_einmal_beanspruchen()
{
await Seed("t-1");
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok-a", Now, NoStaleClaims, default)).ShouldBeTrue();
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok-b", Now, NoStaleClaims, default))
.ShouldBeFalse("der Termin ist bereits beansprucht");
}
[Fact]
public async Task Gleichzeitige_Claims_auf_denselben_Termin_ergeben_genau_einen_Gewinner()
{
// Die Kern-Invariante. Mehrfach wiederholt, weil ein Race sporadisch auftritt.
for (var round = 0; round < 25; round++)
{
var id = $"t-race-{round}";
await Seed(id);
var attempts = Enumerable.Range(0, 32)
.Select(i => _repo.TryClaimAsync(id, Occ(T0), $"tok-{i}", Now, NoStaleClaims, default));
var results = await Task.WhenAll(attempts);
results.Count(won => won).ShouldBe(1, $"Runde {round}: genau ein Lauf darf den Termin ziehen");
}
}
[Fact]
public async Task Ein_zweiter_Tick_auf_denselben_Termin_loest_keinen_zweiten_Lauf_aus()
{
await Seed("t-1");
// Erster Tick beansprucht und schließt ab.
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok", Now, NoStaleClaims, default)).ShouldBeTrue();
await _repo.CompleteClaimAsync("t-1", "tok", TaskItemStatus.Todo, Now, default);
// Zweiter Tick im selben Terminfenster: derselbe Occurrence-Key.
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok2", Now, NoStaleClaims, default))
.ShouldBeFalse("derselbe Termin darf nach Abschluss nicht erneut laufen");
}
[Fact]
public async Task Ein_gescheiterter_Lauf_wird_nicht_automatisch_wiederholt()
{
// Kein Retry-Sturm: Der Marker steht schon beim Claim, nicht erst beim Erfolg.
await Seed("t-1");
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok", Now, NoStaleClaims, default)).ShouldBeTrue();
// Lauf scheitert → zurück auf todo, aber derselbe Termin bleibt verbraucht.
await _repo.CompleteClaimAsync("t-1", "tok", TaskItemStatus.Todo, Now, default);
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok2", Now, NoStaleClaims, default))
.ShouldBeFalse("derselbe Termin wird nicht erneut versucht");
}
[Fact]
public async Task Der_naechste_planmaessige_Termin_laesst_sich_wieder_beanspruchen()
{
await Seed("t-1");
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok", Now, NoStaleClaims, default)).ShouldBeTrue();
await _repo.CompleteClaimAsync("t-1", "tok", TaskItemStatus.Todo, Now, default);
// Ein späterer Occurrence-Key liegt über dem Marker → wieder fällig.
var later = Occ(T0.AddDays(1));
(await _repo.TryClaimAsync("t-1", later, "tok3", Now, NoStaleClaims, default))
.ShouldBeTrue("ein neuer Termin darf laufen");
}
// ═══════════════════════════════════════════════════════════
// Invariante 2: kein Dispatch bei offenem Blocker
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Eine_blockierte_Aufgabe_laesst_sich_nicht_beanspruchen()
{
await Seed("t-1", TaskItemStatus.Blocked, blockedBy: ["t-0"]);
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok", Now, NoStaleClaims, default))
.ShouldBeFalse("solange der Blocker offen ist, läuft nichts");
}
[Fact]
public async Task Wartende_Aufgaben_lassen_sich_ueber_ihren_Blocker_finden()
{
await Seed("t-a", blockedBy: ["t-blocker"]);
await Seed("t-b", blockedBy: ["t-blocker", "t-anderer"]);
await Seed("t-c", blockedBy: ["t-blocker10"]);
var waiting = await _repo.ListBlockedByAsync("t-blocker", default);
waiting.Select(t => t.Id).OrderBy(x => x).ShouldBe(["t-a", "t-b"]);
waiting.ShouldNotContain(t => t.Id == "t-c", "t-blocker darf nicht t-blocker10 treffen");
}
// ═══════════════════════════════════════════════════════════
// Claim-Abschluss, Lease und Reconciliation
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Ein_Lauf_ohne_gueltigen_Claim_kann_nichts_ueberschreiben()
{
await Seed("t-1");
(await _repo.TryClaimAsync("t-1", Occ(T0), "echt", Now, NoStaleClaims, default)).ShouldBeTrue();
(await _repo.CompleteClaimAsync("t-1", "falsch", TaskItemStatus.Done, Now, default))
.ShouldBeFalse("ein fremdes Token darf den Abschluss nicht setzen");
(await _repo.CompleteClaimAsync("t-1", "echt", TaskItemStatus.Done, Now, default))
.ShouldBeTrue();
(await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Done);
}
[Fact]
public async Task Verwaiste_Claims_werden_beim_Start_zurueckgesetzt()
{
await Seed("t-stuck");
// Claim mit einem Zeitpunkt, der bereits weit zurückliegt (abgestürzter Lauf).
var longAgo = Now.AddMinutes(-30);
(await _repo.TryClaimAsync("t-stuck", Occ(T0), "tok", longAgo, longAgo.AddMinutes(-1), default)).ShouldBeTrue();
// Reconciliation: Claims älter als 5 Minuten gelten als verwaist.
var reset = await _repo.ReleaseStaleClaimsAsync(Now.AddMinutes(-5), Now, default);
reset.ShouldBe(1);
var after = await _repo.GetAsync("t-stuck", default);
after!.Status.ShouldBe(TaskItemStatus.Todo);
after.ClaimToken.ShouldBeNull();
}
[Fact]
public async Task Ein_frischer_Claim_wird_bei_der_Reconciliation_nicht_angetastet()
{
await Seed("t-live");
(await _repo.TryClaimAsync("t-live", Occ(T0), "tok", Now, NoStaleClaims, default)).ShouldBeTrue();
var reset = await _repo.ReleaseStaleClaimsAsync(Now.AddMinutes(-5), Now, default);
reset.ShouldBe(0, "ein laufender Lauf darf nicht abgeräumt werden");
(await _repo.GetAsync("t-live", default))!.Status.ShouldBe(TaskItemStatus.InProgress);
}
[Fact]
public async Task Der_Status_laesst_sich_direkt_setzen()
{
await Seed("t-1");
(await _repo.SetStatusAsync("t-1", TaskItemStatus.Canceled, Now, default)).ShouldBeTrue();
(await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Canceled);
}
// ═══════════════════════════════════════════════════════════
// Nebenläufigkeit und Persistenz
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Viele_Aufgaben_lassen_sich_gleichzeitig_importieren()
{
var imports = Enumerable.Range(0, 60).Select(i => Seed($"t-{i}"));
await Task.WhenAll(imports);
(await _repo.CountAsync(default)).ShouldBe(60);
}
[Fact]
public async Task Aufgaben_ueberdauern_das_Schliessen_der_Datenbank()
{
await Seed("t-1", title: "muss einen Neustart überleben");
var reopened = new SqliteTaskRepository(new SqliteStorage(Path.Combine(_directory, "state.db")));
(await reopened.GetAsync("t-1", default))!.Title.ShouldBe("muss einen Neustart überleben");
}
}
@@ -0,0 +1,248 @@
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Tasks;
/// <summary>
/// Der Scanner-Kern — der in der Roadmap für Opus 5/Fable markierte heikle Teil. Getestet
/// gegen echte SQLite (das atomare Claiming ist der Punkt) mit einer Attrappe für die
/// Ausführung, damit keine Engine nötig ist. Die drei Invarianten stehen im Mittelpunkt.
/// </summary>
public sealed class TaskScannerTests : IDisposable
{
private readonly string _directory;
private readonly SqliteStorage _storage;
private readonly SqliteTaskRepository _repo;
private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2026, 7, 31, 12, 0, 0, TimeSpan.Zero));
public TaskScannerTests()
{
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
_repo = new SqliteTaskRepository(_storage);
}
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
private TaskScanner Scanner(ITaskDispatcher dispatcher, IMarketCalendar? market = null)
=> new(_repo, dispatcher, NullLoggerFactory.Instance, market, _clock);
/// <summary>Eine sofort fällige Aufgabe (kein Termin, nie gelaufen).</summary>
private Task<TaskItem> SeedDue(
string id, string assignee = "@new",
TaskItemStatus status = TaskItemStatus.Todo,
bool requireApproval = false, IReadOnlyList<string>? blockedBy = null,
bool onlyWhenMarketOpen = false)
=> _repo.UpsertAsync(new TaskItem
{
Id = id,
Title = id,
Assignee = assignee,
Status = status,
RequireApproval = requireApproval,
BlockedBy = blockedBy ?? [],
OnlyWhenMarketOpen = onlyWhenMarketOpen
}, default);
// ─── Grundfluss ───
[Fact]
public async Task Eine_faellige_Aufgabe_wird_ausgefuehrt_und_abgeschlossen()
{
await SeedDue("t-1");
var dispatcher = new FakeDispatcher();
var dispatched = await Scanner(dispatcher).ScanOnceAsync(default);
dispatched.ShouldBe(1);
dispatcher.Dispatched.ShouldBe(["t-1"]);
(await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Done);
}
[Fact]
public async Task Mit_require_approval_landet_das_Ergebnis_im_Review()
{
await SeedDue("t-1", requireApproval: true);
await Scanner(new FakeDispatcher()).ScanOnceAsync(default);
(await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.InReview);
}
[Fact]
public async Task Ein_gescheiterter_Lauf_geht_zurueck_auf_todo_und_wird_nicht_wiederholt()
{
await SeedDue("t-1");
var dispatcher = new FakeDispatcher(succeed: false);
var scanner = Scanner(dispatcher);
await scanner.ScanOnceAsync(default);
(await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Todo);
// Zweiter Takt: der Marker ist gesetzt, derselbe Termin läuft nicht erneut.
await scanner.ScanOnceAsync(default);
dispatcher.Dispatched.Count.ShouldBe(1, "kein Retry-Sturm für denselben Termin");
}
// ─── Invariante 2: kein Dispatch bei offenem Blocker / an Menschen ───
[Fact]
public async Task Eine_blockierte_Aufgabe_wird_nicht_angestossen()
{
await SeedDue("t-1", status: TaskItemStatus.Blocked, blockedBy: ["t-0"]);
var dispatcher = new FakeDispatcher();
(await Scanner(dispatcher).ScanOnceAsync(default)).ShouldBe(0);
dispatcher.Dispatched.ShouldBeEmpty();
}
[Fact]
public async Task Eine_Aufgabe_fuer_einen_Menschen_wird_nicht_angestossen()
{
await SeedDue("t-1", assignee: "@human");
var dispatcher = new FakeDispatcher();
(await Scanner(dispatcher).ScanOnceAsync(default)).ShouldBe(0);
dispatcher.Dispatched.ShouldBeEmpty();
(await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Todo, "sie wartet unverändert");
}
// ─── Invariante 3: doppelter Takt = ein Lauf ───
[Fact]
public async Task Zwei_gleichzeitige_Takte_stossen_eine_Aufgabe_nur_einmal_an()
{
for (var round = 0; round < 20; round++)
{
var id = $"t-{round}";
await SeedDue(id);
var dispatcher = new FakeDispatcher();
var scanner = Scanner(dispatcher);
await Task.WhenAll(
scanner.ScanOnceAsync(default),
scanner.ScanOnceAsync(default));
dispatcher.Dispatched.Count(x => x == id).ShouldBe(1, $"Runde {round}");
}
}
// ─── Wiederkehrende Tasks (ersetzt den Alt-Scheduler) ───
[Fact]
public async Task Ein_wiederkehrender_Task_bleibt_nach_dem_Feuern_auf_todo()
{
_clock.SetUtcNow(new DateTimeOffset(2027, 1, 1, 9, 0, 0, TimeSpan.Zero));
await _repo.UpsertAsync(new TaskItem
{
Id = "t-rec", Title = "täglich", Assignee = "@new", Status = TaskItemStatus.Todo,
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "UTC" }
}, default);
await Scanner(new FakeDispatcher()).ScanOnceAsync(default);
(await _repo.GetAsync("t-rec", default))!.Status.ShouldBe(
TaskItemStatus.Todo, "sonst liefe ein Cron-Task nur ein einziges Mal");
}
[Fact]
public async Task Ein_wiederkehrender_Task_feuert_an_jedem_Termin_erneut()
{
await _repo.UpsertAsync(new TaskItem
{
Id = "t-rec", Title = "täglich", Assignee = "@new", Status = TaskItemStatus.Todo,
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "UTC" }
}, default);
var dispatcher = new FakeDispatcher();
var scanner = Scanner(dispatcher);
_clock.SetUtcNow(new DateTimeOffset(2027, 1, 1, 9, 0, 0, TimeSpan.Zero));
await scanner.ScanOnceAsync(default);
_clock.SetUtcNow(new DateTimeOffset(2027, 1, 2, 9, 0, 0, TimeSpan.Zero));
await scanner.ScanOnceAsync(default);
dispatcher.Dispatched.Count(x => x == "t-rec").ShouldBe(2, "zwei Termine, zwei Läufe");
}
[Fact]
public async Task Ein_backlog_Task_wird_nicht_ausgefuehrt()
{
await SeedDue("t-1", status: TaskItemStatus.Backlog);
var dispatcher = new FakeDispatcher();
(await Scanner(dispatcher).ScanOnceAsync(default)).ShouldBe(0);
dispatcher.Dispatched.ShouldBeEmpty();
}
// ─── C1-Haken: Marktkalender ───
[Fact]
public async Task Bei_geschlossenem_Markt_bleibt_ein_marktabhaengiger_Termin_liegen()
{
await SeedDue("t-1", onlyWhenMarketOpen: true);
var dispatcher = new FakeDispatcher();
var scanner = Scanner(dispatcher, new ClosedMarket());
(await scanner.ScanOnceAsync(default)).ShouldBe(0);
dispatcher.Dispatched.ShouldBeEmpty();
(await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Todo);
}
// ─── Auto-Dispatch: Blocker fertig → Wartende frei ───
[Fact]
public async Task Wird_der_letzte_Blocker_fertig_gibt_das_die_wartende_Aufgabe_frei()
{
await SeedDue("t-blocker");
await SeedDue("t-wartend", status: TaskItemStatus.Blocked, blockedBy: ["t-blocker"]);
await Scanner(new FakeDispatcher()).ScanOnceAsync(default);
(await _repo.GetAsync("t-blocker", default))!.Status.ShouldBe(TaskItemStatus.Done);
(await _repo.GetAsync("t-wartend", default))!.Status.ShouldBe(
TaskItemStatus.Todo, "der Blocker ist erledigt, also ist sie jetzt bereit");
}
[Fact]
public async Task Solange_ein_Blocker_offen_ist_bleibt_die_wartende_Aufgabe_blockiert()
{
await SeedDue("t-b1"); // wird in diesem Takt fertig
await SeedDue("t-b2", assignee: "@human"); // ein Mensch — bleibt offen
await SeedDue("t-wartend", status: TaskItemStatus.Blocked, blockedBy: ["t-b1", "t-b2"]);
await Scanner(new FakeDispatcher()).ScanOnceAsync(default);
(await _repo.GetAsync("t-wartend", default))!.Status.ShouldBe(
TaskItemStatus.Blocked, "nicht alle Blocker sind erledigt");
}
// ─── Attrappen ───
private sealed class FakeDispatcher(bool succeed = true) : ITaskDispatcher
{
private readonly Lock _lock = new();
public List<string> Dispatched { get; } = [];
public Task<bool> DispatchAsync(TaskItem task, CancellationToken ct)
{
lock (_lock)
Dispatched.Add(task.Id);
return Task.FromResult(succeed);
}
}
private sealed class ClosedMarket : IMarketCalendar
{
public bool IsOpen(DateTime nowUtc) => false;
}
}
@@ -0,0 +1,246 @@
using ClawdDotNet.Core.Tasks;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Tasks;
/// <summary>
/// Die Terminberechnung — Antwort auf B6 (keine Delays, nur „was ist jetzt fällig")
/// und B7 (jeder Termin wird explizit in seiner Zeitzone gedeutet). Reine Funktion,
/// deshalb ohne DB und ohne Uhr-Attrappe: „jetzt" wird direkt übergeben.
/// </summary>
public sealed class TaskScheduleTests
{
private static DateTime Utc(int y, int mo, int d, int h, int mi)
=> new(y, mo, d, h, mi, 0, DateTimeKind.Utc);
// ─── Einmalig, ohne Termin ───
[Fact]
public void Ohne_Termin_ist_die_Aufgabe_faellig_bis_sie_lief()
{
var task = new TaskItem { Id = "t-1", CreatedAt = Utc(2026, 7, 31, 6, 0) };
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 7, 0)).ShouldNotBeNull();
TaskSchedule.DueOccurrence(task with { LastOccurrence = "x" }, Utc(2026, 7, 31, 7, 0)).ShouldBeNull();
}
// ─── at: einmaliger Zeitpunkt ───
[Fact]
public void Ein_at_Termin_ist_erst_ab_dem_Zeitpunkt_faellig()
{
var task = new TaskItem
{
Id = "t-1",
When = new TaskWhen { Kind = TaskWhenKind.At, Value = "2026-07-31T09:00:00", TimeZone = "Europe/Berlin" }
};
// 09:00 Berlin (Sommerzeit) = 07:00 UTC
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 6, 59)).ShouldBeNull();
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 7, 0)).ShouldNotBeNull();
}
[Fact]
public void Ein_at_Termin_feuert_genau_einmal()
{
var task = new TaskItem
{
Id = "t-1",
LastOccurrence = "2026-07-31T07:00:00.0000000Z",
When = new TaskWhen { Kind = TaskWhenKind.At, Value = "2026-07-31T09:00:00", TimeZone = "Europe/Berlin" }
};
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 12, 0)).ShouldBeNull();
}
[Fact]
public void Ein_at_Termin_mit_Z_wird_als_UTC_gedeutet()
{
var task = new TaskItem
{
Id = "t-1",
When = new TaskWhen { Kind = TaskWhenKind.At, Value = "2026-07-31T09:00:00Z", TimeZone = "Europe/Berlin" }
};
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 8, 59)).ShouldBeNull();
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 9, 0)).ShouldNotBeNull();
}
// ─── every: Intervall ───
[Fact]
public void Ein_Intervall_wird_erst_nach_Ablauf_faellig()
{
var task = new TaskItem
{
Id = "t-1",
CreatedAt = Utc(2026, 7, 31, 7, 0),
When = new TaskWhen { Kind = TaskWhenKind.Every, Value = "30m", TimeZone = "UTC" }
};
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 7, 20)).ShouldBeNull();
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 7, 30)).ShouldNotBeNull();
}
[Fact]
public void Nach_dem_Marker_zaehlt_das_Intervall_neu()
{
var task = new TaskItem
{
Id = "t-1",
LastOccurrence = "2026-07-31T07:30:00.0000000Z",
When = new TaskWhen { Kind = TaskWhenKind.Every, Value = "30m", TimeZone = "UTC" }
};
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 7, 55)).ShouldBeNull();
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 8, 0)).ShouldNotBeNull();
}
// ─── cron: mit Zeitzone (der Kern von B7) ───
[Fact]
public void Ein_Cron_Termin_wird_in_seiner_Zeitzone_gedeutet_Sommer()
{
var task = new TaskItem
{
Id = "t-1",
CreatedAt = Utc(2026, 7, 31, 0, 0),
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "Europe/Berlin" }
};
// 09:00 Berlin im Sommer (CEST, UTC+2) = 07:00 UTC
var occ = TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 7, 5));
occ.ShouldBe("2026-07-31T07:00:00.0000000Z");
}
[Fact]
public void Derselbe_Cron_liegt_im_Winter_auf_einer_anderen_UTC_Stunde()
{
var task = new TaskItem
{
Id = "t-1",
CreatedAt = Utc(2026, 1, 15, 0, 0),
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "Europe/Berlin" }
};
// 09:00 Berlin im Winter (CET, UTC+1) = 08:00 UTC — genau das, was B7 verlangt.
var occ = TaskSchedule.DueOccurrence(task, Utc(2026, 1, 15, 8, 5));
occ.ShouldBe("2026-01-15T08:00:00.0000000Z");
}
[Fact]
public void Vor_der_geplanten_Zeit_ist_der_Cron_nicht_faellig()
{
var task = new TaskItem
{
Id = "t-1",
CreatedAt = Utc(2026, 7, 31, 5, 0),
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "Europe/Berlin" }
};
// 06:00 UTC = 08:00 Berlin, vor der 09:00-Marke
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 6, 0)).ShouldBeNull();
}
[Fact]
public void Eine_frische_Cron_Aufgabe_holt_keinen_Termin_von_vor_ihrer_Anlage_nach()
{
var task = new TaskItem
{
Id = "t-1",
CreatedAt = Utc(2026, 7, 31, 8, 0), // nach der heutigen 09:00-Berlin-Marke (07:00 UTC)
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "Europe/Berlin" }
};
// Jetzt 10:00 UTC — die heutige 09:00-Marke lag vor der Anlage, also nicht nachholen.
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 10, 0)).ShouldBeNull();
}
[Fact]
public void Derselbe_Cron_Termin_feuert_nicht_zweimal()
{
var task = new TaskItem
{
Id = "t-1",
LastOccurrence = "2026-07-31T07:00:00.0000000Z",
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "Europe/Berlin" }
};
// Später am selben Tag: der 09:00-Termin ist schon abgehakt.
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 10, 0)).ShouldBeNull();
// Der nächste Tag ist wieder fällig.
TaskSchedule.DueOccurrence(task, Utc(2026, 8, 1, 7, 5)).ShouldBe("2026-08-01T07:00:00.0000000Z");
}
[Fact]
public void Ein_kaputter_Cron_Ausdruck_kippt_den_Scanner_nicht()
{
var task = new TaskItem
{
Id = "t-1",
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "kein cron", TimeZone = "UTC" }
};
Should.NotThrow(() => TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 9, 0)))
.ShouldBeNull();
}
/// <summary>
/// Früher fiel eine unbekannte Zone still auf UTC zurück. Das ist der schlechtere
/// Fehler: Ein Task für 09:00 Ortszeit lief im Sommer um 07:00, ohne dass irgendwo
/// etwas auffiel. Jetzt feuert er gar nicht — und der Scanner meldet einmal, warum.
/// </summary>
[Fact]
public void Eine_unbekannte_Zeitzone_laesst_den_Termin_nicht_faellig_werden()
{
var task = new TaskItem
{
Id = "t-1",
CreatedAt = Utc(2026, 7, 31, 0, 0),
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "Phantasie/Ort" }
};
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 9, 5)).ShouldBeNull();
TaskSchedule.UnresolvableTimeZone(task).ShouldBe("Phantasie/Ort");
}
/// <summary>
/// Der Kern der Portierung: Dieselbe Zeitzone, zwei Schreibweisen. Eine Task-Datei,
/// die unter Windows entstanden ist, muss unter Linux dieselbe Feuerzeit ergeben —
/// und umgekehrt.
/// </summary>
[Theory]
[InlineData("Europe/Berlin")]
[InlineData("W. Europe Standard Time")]
public void Beide_Schreibweisen_derselben_Zone_ergeben_dieselbe_Feuerzeit(string tz)
{
var task = new TaskItem
{
Id = "t-1",
CreatedAt = Utc(2026, 7, 31, 0, 0),
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = tz }
};
TaskSchedule.UnresolvableTimeZone(task).ShouldBeNull();
// 09:00 Ortszeit im Sommer (MESZ, UTC+2) = 07:00 UTC.
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 9, 5))
.ShouldBe("2026-07-31T07:00:00.0000000Z");
}
[Fact]
public void Keine_Zeitzonenangabe_bedeutet_weiterhin_UTC()
{
var task = new TaskItem
{
Id = "t-1",
CreatedAt = Utc(2026, 7, 31, 0, 0),
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "" }
};
TaskSchedule.UnresolvableTimeZone(task).ShouldBeNull();
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 9, 5))
.ShouldBe("2026-07-31T09:00:00.0000000Z");
}
}
@@ -0,0 +1,163 @@
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Tasks;
/// <summary>
/// Die Wahrheitsaufteilung in Aktion: Der Dienst hält Markdown-Datei (Definition) und
/// DB (Ausführungszustand) im Gleichschritt. Getestet gegen echtes Dateisystem und echte
/// SQLite — beide Seiten sind der Punkt.
/// </summary>
public sealed class TaskboardServiceTests : IDisposable
{
private readonly string _directory;
private readonly string _tasksDir;
private readonly SqliteStorage _storage;
private readonly SqliteTaskRepository _repo;
private readonly TaskboardService _board;
public TaskboardServiceTests()
{
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
_tasksDir = Path.Combine(_directory, "SharedWorkspace", "tasks");
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
_repo = new SqliteTaskRepository(_storage);
_board = new TaskboardService(_repo, _tasksDir);
}
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
private static TaskItem Def(string title = "Aufgabe", string assignee = "@new")
=> new() { Title = title, Assignee = assignee, Body = "Tu etwas." };
[Fact]
public async Task Anlegen_erzeugt_Datei_und_DB_Zeile_in_einem_Zug()
{
var created = await _board.CreateAsync(Def("NVDA recherchieren"), default);
created.Id.ShouldNotBeNullOrWhiteSpace();
created.Id.ShouldStartWith("t-");
// DB-Seite
(await _repo.GetAsync(created.Id, default)).ShouldNotBeNull();
// Datei-Seite: existiert und lässt sich zur selben Definition zurücklesen
var path = Path.Combine(_tasksDir, created.FileName);
File.Exists(path).ShouldBeTrue();
TaskFrontmatter.TryParse(File.ReadAllText(path), out var fromFile, out _).ShouldBeTrue();
fromFile.Id.ShouldBe(created.Id);
fromFile.Title.ShouldBe("NVDA recherchieren");
fromFile.Assignee.ShouldBe("@new");
}
[Fact]
public async Task Der_Dateiname_ist_beschreibend()
{
var created = await _board.CreateAsync(Def("NVDA Earnings recherchieren"), default);
created.FileName.ShouldStartWith("nvda-earnings-recherchieren-");
created.FileName.ShouldEndWith(".md");
}
[Fact]
public async Task Aendern_schreibt_Datei_und_DB_fort()
{
var created = await _board.CreateAsync(Def("Alt"), default);
var updated = await _board.UpdateAsync(created.Id,
c => c with { Title = "Neu", Status = TaskItemStatus.Done }, default);
updated!.Title.ShouldBe("Neu");
updated.Status.ShouldBe(TaskItemStatus.Done);
// Beide Seiten spiegeln die Änderung
(await _repo.GetAsync(created.Id, default))!.Status.ShouldBe(TaskItemStatus.Done);
TaskFrontmatter.TryParse(
File.ReadAllText(Path.Combine(_tasksDir, created.FileName)), out var fromFile, out _);
fromFile.Title.ShouldBe("Neu");
fromFile.Status.ShouldBe(TaskItemStatus.Done);
}
[Fact]
public async Task Ein_Kommentar_landet_im_Rumpf()
{
var created = await _board.CreateAsync(Def(), default);
await _board.AddCommentAsync(created.Id, "agent-a", "Erstes Zwischenergebnis.", default);
await _board.AddCommentAsync(created.Id, "agent-b", "Bitte nachbessern.", default);
var body = (await _repo.GetAsync(created.Id, default))!.Body;
body.ShouldContain("Erstes Zwischenergebnis.");
body.ShouldContain("agent-b");
body.ShouldContain("Bitte nachbessern.");
}
[Fact]
public async Task Import_spiegelt_vorhandene_Dateien_in_die_DB()
{
Directory.CreateDirectory(_tasksDir);
File.WriteAllText(Path.Combine(_tasksDir, "eins.md"),
"---\nid: t-eins\ntitle: Eins\nassignee: \"@new\"\n---\nMach eins.");
File.WriteAllText(Path.Combine(_tasksDir, "zwei.md"),
"---\nid: t-zwei\ntitle: Zwei\nassignee: \"@new\"\n---\nMach zwei.");
var imported = await _board.ImportAllAsync(default);
imported.ShouldBe(2);
(await _repo.GetAsync("t-eins", default))!.Title.ShouldBe("Eins");
(await _repo.GetAsync("t-zwei", default))!.Title.ShouldBe("Zwei");
}
[Fact]
public async Task Eine_von_Hand_angelegte_Datei_ohne_Id_bekommt_eine_und_behaelt_sie()
{
Directory.CreateDirectory(_tasksDir);
var path = Path.Combine(_tasksDir, "handarbeit.md");
File.WriteAllText(path, "---\ntitle: Von Hand\nassignee: \"@human\"\n---\nManuell angelegt.");
var imported = await _board.ImportFileAsync(path, default);
imported.ShouldNotBeNull();
imported!.Id.ShouldNotBeNullOrWhiteSpace();
// Die Id wurde in die Datei zurückgeschrieben — überlebt den nächsten Start.
TaskFrontmatter.TryParse(File.ReadAllText(path), out var fromFile, out _);
fromFile.Id.ShouldBe(imported.Id);
}
[Fact]
public async Task Eine_verschwundene_Datei_wird_archiviert_statt_geloescht()
{
var created = await _board.CreateAsync(Def("Vergänglich"), default);
File.Delete(Path.Combine(_tasksDir, created.FileName));
await _board.ImportAllAsync(default);
// Die Historie bleibt: die Zeile ist archiviert, nicht weg.
var after = await _repo.GetAsync(created.Id, default);
after.ShouldNotBeNull();
after!.Status.ShouldBe(TaskItemStatus.Archived);
}
[Fact]
public async Task Ein_Re_Import_setzt_einen_laufenden_Status_nicht_zurueck()
{
// Die Kernregel der Wahrheitsaufteilung über den Dienst geprüft.
var created = await _board.CreateAsync(Def(), default);
await _repo.TryClaimAsync(created.Id, "2026-07-31T07:00:00.0000000Z", "tok",
DateTime.UtcNow, DateTime.UtcNow.AddHours(-1), default);
await _repo.CompleteClaimAsync(created.Id, "tok", TaskItemStatus.Done, DateTime.UtcNow, default);
// Die Datei sagt weiterhin "todo" (so wurde sie angelegt) — ein Import darf das
// nicht über den erreichten Done-Status stülpen.
await _board.ImportAllAsync(default);
(await _repo.GetAsync(created.Id, default))!.Status.ShouldBe(TaskItemStatus.Done);
}
}
@@ -0,0 +1,93 @@
using System.Text.Json;
using ClawdDotNet.Core.State;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tests.Infrastructure;
using ClawdDotNet.Core.Tools;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Tasks;
/// <summary>
/// Der tool_job-Dispatch — ersetzt den ToolJobScheduler. Ein fälliger Poll-Task tickt einen
/// <see cref="IToolJobProvider"/> und weckt den Agenten nur, wenn der Tick etwas meldet.
/// </summary>
public sealed class ToolJobDispatchTests
{
private static TaskItem PollTask(string agent = "agent-a") => new()
{
Id = "tj-1",
Title = "Poll",
Type = TaskItemType.ToolJob,
ToolName = "PollTool",
JobTypeId = "poll",
Assignee = "@" + agent
};
private static EngineTaskDispatcher Dispatcher(EngineFixture fixture)
=> new(fixture.Engine, () => fixture.Agents, "test-instance",
fixture.Registry, fixture.StateStore, NullLoggerFactory.Instance);
[Fact]
public async Task Ein_Poll_ohne_Fund_weckt_den_Agenten_nicht()
{
var tool = new FakeJobTool(ToolJobResult.NoAction("nichts Neues"));
var fixture = new EngineFixture().WithTool(tool);
fixture.AddAgent("agent-a", "PollTool");
var ok = await Dispatcher(fixture).DispatchAsync(PollTask(), default);
ok.ShouldBeTrue("ein Poll ohne Fund ist kein Fehler");
tool.Ticks.ShouldBe(1);
fixture.Client.ReceivedRequests.ShouldBeEmpty("kein Agent-Lauf, wenn nichts zu wecken war");
}
[Fact]
public async Task Ein_Poll_mit_Fund_weckt_den_Agenten()
{
var tool = new FakeJobTool(ToolJobResult.Wake("Neue Nachricht eingetroffen"));
var fixture = new EngineFixture().WithTool(tool);
fixture.AddAgent("agent-a", "PollTool");
fixture.Client.RespondsWithText("verarbeitet");
var ok = await Dispatcher(fixture).DispatchAsync(PollTask(), default);
ok.ShouldBeTrue();
tool.Ticks.ShouldBe(1);
fixture.Client.ReceivedRequests.ShouldNotBeEmpty("der Agent wurde geweckt");
}
[Fact]
public async Task Ein_unbekanntes_Poll_Tool_meldet_einen_Fehler()
{
var fixture = new EngineFixture(); // PollTool nicht registriert
fixture.AddAgent("agent-a");
(await Dispatcher(fixture).DispatchAsync(PollTask(), default)).ShouldBeFalse();
}
/// <summary>Ein Tool, das zugleich Poll-Provider ist — zählt Ticks, liefert ein festes Ergebnis.</summary>
private sealed class FakeJobTool(ToolJobResult result) : IAgentTool, IToolJobProvider
{
public int Ticks { get; private set; }
public string Name => "PollTool";
public string Description => "Test-Poll";
public JsonElement InputSchema { get; } =
JsonDocument.Parse("""{ "type": "object" }""").RootElement.Clone();
public Task<ToolResult> ExecuteAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
=> Task.FromResult(ToolResult.Ok("ok"));
public IReadOnlyList<ToolJobDefinition> GetJobDefinitions() => [];
public Task<ToolJobResult> ExecuteJobAsync(
string jobTypeId, IReadOnlyDictionary<string, object?> toolConfig, IStateStore stateStore,
ILogger logger, CancellationToken ct, string? agentId = null, string? workspacePath = null)
{
Ticks++;
return Task.FromResult(result);
}
}
}
@@ -9,8 +9,13 @@ namespace ClawdDotNet.Tools.Tests.FileRW;
/// "…\Workspace-Backup\…".
///
/// Das Dateisystem wird hier bewusst NICHT abstrahiert — die Tests sollen die echte
/// Windows-Pfadsemantik prüfen (.., UNC, Alternate Data Streams, abschließende Punkte).
/// Eine Abstraktion würde genau die Fehlerklasse verstecken, um die es geht.
/// Pfadsemantik der jeweiligen Plattform prüfen (.., UNC, Alternate Data Streams,
/// abschließende Punkte, unter Linux zusätzlich Groß-/Kleinschreibung und
/// Verknüpfungen). Eine Abstraktion würde genau die Fehlerklasse verstecken, um die es
/// geht.
///
/// Die Verzeichnisgrenze selbst prüft <c>PathBoundaryTests</c> in Core.Tests; hier geht
/// es um das, was <see cref="WorkspacePath.Resolve"/> darüber hinaus abweist.
/// </summary>
public sealed class WorkspacePathTests : IDisposable
{
@@ -45,6 +50,13 @@ public sealed class WorkspacePathTests : IDisposable
Should.Throw<UnauthorizedAccessException>(() => Resolve(path));
}
/// <summary>
/// Die Windows-Formen müssen auch unter Linux abgewiesen werden — dort greift
/// aber eine andere Regel: <c>Path.IsPathRooted(@"C:\temp\x")</c> ist unter Linux
/// <c>false</c>, weil das schlicht ein Dateiname mit Doppelpunkt ist. Gefangen
/// werden die Fälle dann von der Doppelpunkt- bzw. der <c>\\</c>-Prüfung.
/// Das Ergebnis ist auf beiden Plattformen dasselbe, der Weg dorthin nicht.
/// </summary>
[Theory]
[InlineData(@"C:\Windows\System32\config\SAM")]
[InlineData(@"\\server\share\evil.txt")]
@@ -55,6 +67,20 @@ public sealed class WorkspacePathTests : IDisposable
Should.Throw<UnauthorizedAccessException>(() => Resolve(path));
}
/// <summary>
/// Die Linux-Entsprechungen. Unter Windows sind das gewöhnliche relative Pfade,
/// die entweder ins Leere zeigen oder von der <c>..</c>-Regel gefangen werden —
/// deshalb laufen sie dort mit, statt übersprungen zu werden.
/// </summary>
[Theory]
[InlineData("/etc/passwd")]
[InlineData("/root/.ssh/id_rsa")]
[InlineData("../../../../etc/shadow")]
public void Unix_Systempfade_werden_abgelehnt(string path)
{
Should.Throw<UnauthorizedAccessException>(() => Resolve(path));
}
[Theory]
[InlineData("datei.txt:versteckt")]
[InlineData("datei.txt:$DATA")]