feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar
This commit is contained in:
@@ -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"); // Mo–Fr
|
||||
// 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")]
|
||||
|
||||
Reference in New Issue
Block a user