IChatCompletionClient aus OpenRouterClient extrahiert, damit AgentEngine und ContextCompactor ohne echte API-Aufrufe testbar sind. Neues Testprojekt tests/ClawdDotNet.Core.Tests (xUnit, Shouldly, NSubstitute, FsCheck) mit: - FakeChatClient (programmierbare Antwortfolgen, Deep-Copy der Requests) - ContextInvariants (prueft die API-Regeln fuer tool_call-Paarung) - Conversation-Builder fuer gueltige Testkonversationen - 26 Tests: Compaction, LoopGuard, 2 Property-Tests 10 Tests sind bewusst rot — sie reproduzieren die Bugs B1, B3 und B14 aus der Bestandsaufnahme und werden mit den Fixes gruen. Ausserdem: fehlende Tool-Projekte in slnx ergaenzt, Test-Pakete im packageSourceMapping der NuGet.Config eingetragen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
21 KiB
ClawdDotNet — Teststrategie
Ergänzung zur Bestandsaufnahme. Ziel: Fehlerklassen wie B1 (Compaction zerstört tool_call-Paarung) und B2 (Race Condition) strukturell unmöglich machen, statt sie im Betrieb zu entdecken.
Leitgedanke: Nicht Coverage maximieren, sondern die teuren Fehler abfangen. In diesem Projekt sind das drei Kategorien:
- Kontext-Invarianten — ungültige Nachrichtensequenzen kosten API-Fehler mitten im Lauf
- Sicherheitsgrenzen — Pfad-, SQL-, URL- und Empfänger-Prüfungen
- Nebenläufigkeit & Zeit — Scheduler, paralleler Chat-Zugriff, Cron
UI-Layout, Designer-Code und Fremdbibliotheken werden bewusst nicht getestet.
1. Voraussetzung: Testbarkeit herstellen
Vier kleine Refactorings, ohne die die wichtigsten Tests nicht schreibbar sind.
R1 — IChatCompletionClient extrahieren ⚠️ blockiert alles Weitere
AgentEngine.cs:15 und
ContextCompactor.cs:10 hängen an der
konkreten, sealed-Klasse OpenRouterClient. Ohne Interface ist kein einziger Engine-Test möglich.
public interface IChatCompletionClient
{
Task<ChatResponse> CompleteAsync(ChatRequest request, CancellationToken ct);
}
public sealed class OpenRouterClient : IChatCompletionClient, IDisposable { … }
Dazu ein FakeChatClient im Testprojekt, der eine vorprogrammierte Antwortfolge liefert und
alle empfangenen Requests mitschreibt:
internal sealed class FakeChatClient : IChatCompletionClient
{
private readonly Queue<Func<ChatRequest, ChatResponse>> _responses = new();
public List<ChatRequest> ReceivedRequests { get; } = new();
public FakeChatClient RespondsWithText(string text) { … }
public FakeChatClient RespondsWithToolCall(string tool, string argsJson) { … }
public FakeChatClient Throws(Exception ex) { … }
public Task<ChatResponse> CompleteAsync(ChatRequest request, CancellationToken ct)
{
// Tiefe Kopie ablegen — die Engine mutiert die Nachrichtenliste weiter!
ReceivedRequests.Add(DeepClone(request));
…
}
}
Die tiefe Kopie ist wesentlich:
AgentEnginereicht dieselbeList<ChatMessage>-Instanz weiter und verändert sie danach. Ohne Kopie prüfen die Tests den Endzustand statt des tatsächlich gesendeten Requests.
R2 — TimeProvider statt DateTime.Now
.NET 10 bringt TimeProvider mit; FakeTimeProvider steckt in
Microsoft.Extensions.TimeProvider.Testing. Betrifft
CronExpression.cs:53,
AgentScheduler.cs:75,
ToolJobScheduler.cs:111.
Damit werden Zeitumstellungs- und Langzeit-Tests deterministisch und laufen in Millisekunden statt in Echtzeit.
R3 — IProcessRunner für externe Prozesse
SocialMediaManagerTool.cs:442
startet yt-dlp/ffmpeg direkt. Für den Injection-Test (S2) muss die Argumentbildung ohne
Prozessstart prüfbar sein:
internal static string[] BuildYtDlpArgs(string url, …); // rein, testbar
public interface IProcessRunner { Task<ProcessResult> RunAsync(string exe, string[] args, CancellationToken ct); }
R4 — Dateisystem nicht abstrahieren
Bewusste Entscheidung: Die Path-Traversal-Tests sollen echte Windows-Pfadsemantik prüfen
(.., UNC, Alternate Data Streams, Trailing Dots). Eine IFileSystem-Abstraktion würde genau
die Fehlerklasse verstecken, um die es geht. Stattdessen ein Temp-Verzeichnis-Fixture:
internal sealed class TempWorkspace : IDisposable
{
public string Personal { get; }
public string Shared { get; }
public TempWorkspace() { /* unter Path.GetTempPath()/clawd-tests/{guid} anlegen */ }
public void Dispose() { /* rekursiv löschen, Fehler schlucken */ }
}
2. Projektstruktur & Stack
tests/
ClawdDotNet.Core.Tests/ ← Engine, Compaction, LoopGuard, Cron, Config, State
ClawdDotNet.Tools.Tests/ ← alle Tool-Projekte, v.a. Sicherheitsgrenzen
ClawdDotNet.Integration.Tests/ ← echte Endpunkte, standardmäßig übersprungen
| Paket | Zweck |
|---|---|
xunit.v3 |
Test-Framework |
Shouldly |
Assertions — lesbar, frei nutzbar. (FluentAssertions ist ab v8 für kommerzielle Nutzung kostenpflichtig; falls gewünscht ist AwesomeAssertions ein freier Fork von v7.) |
NSubstitute |
Mocks für ILogger, IStateStore, IAgentTool |
Microsoft.Extensions.TimeProvider.Testing |
FakeTimeProvider |
FsCheck.Xunit |
Property-Based Tests für die Kontext-Invarianten |
Verify.Xunit |
Snapshot-Tests des Request-JSON |
Die drei Projekte in ClawdDotNet.slnx unter einem Ordner /tests/ eintragen.
Nebenbefund: In
ClawdDotNet.slnxfehlen vier Tool-Projekte (AgentComm, AgentSpawn, AgentEditor, SocialMediaManager), obwohl sie inClawdDotNet.csprojreferenziert sind. Sie sollten mit aufgenommen werden, sonst laufen sie in der IDE-Solution nicht mit.
3. Die Tests
Prio 1 — Regressionstests für die gefundenen Bugs
Vorgehen: erst den roten Test, dann den Fix. Diese Tests sind der eigentliche Beleg, dass die Bugs behoben sind — und sie bleiben als Wächter erhalten.
3.1 ContextCompactor — Kontext-Invarianten (deckt B1 ab)
Das Herzstück. Ich würde eine wiederverwendbare Prüffunktion bauen und sie überall einsetzen:
internal static class ContextInvariants
{
/// Wirft, wenn die Nachrichtenfolge von der API abgelehnt würde.
public static void AssertValid(IReadOnlyList<ChatMessage> messages)
{
// 1. Höchstens eine system-Nachricht, und zwar an Position 0
// 2. Jede tool-Nachricht hat eine unmittelbar vorausgehende Gruppe,
// deren assistant-Nachricht ein tool_call mit passender Id enthält
// 3. Jede assistant-Nachricht mit N tool_calls hat genau N tool-Antworten
// 4. Keine tool-Nachricht ohne ToolCallId
}
}
| # | Test | Erwartung |
|---|---|---|
| C1 | Compaction bei Tail-Grenze mitten in einer Tool-Sequenz | AssertValid besteht — schlägt heute fehl |
| C2 | Compaction bei assistant(2 tool_calls) → tool → tool an der Grenze |
Beide tool-Antworten bleiben zusammen mit ihrem Aufruf |
| C3 | System-Prompt vorhanden | Bleibt an Position 0 erhalten |
| C4 | Kein System-Prompt vorhanden | Kein leerer/erfundener System-Eintrag |
| C5 | LLM-Zusammenfassung wirft Exception | Nachrichtenliste bleibt unverändert und gültig (kein halb geleerter Zustand) |
| C6 | LLM liefert leeren String zurück | dito |
| C7 | PruneToolResults |
Kürzt nur außerhalb der letzten 6 Nachrichten |
| C8 | PruneToolResults bei Inhalt ≤ 2000 Zeichen |
Bleibt unverändert (kein Marker angehängt) |
| C9 | EstimateTokens |
Monoton: mehr Inhalt ⇒ nie weniger Tokens |
| C10 | Compaction unterhalb der Schwelle | Kein API-Call an den Fake (spart Geld!) |
Property-Based (FsCheck) — der wertvollste Test des ganzen Projekts:
[Property(MaxTest = 500)]
public Property Compaction_erhaelt_immer_eine_gueltige_Sequenz()
{
return Prop.ForAll(ValidConversationGenerator(), // 1..200 gültige Nachrichten
async conversation =>
{
await _compactor.CompactIfNeededAsync(conversation, 999_999, _guard, "m", default);
ContextInvariants.AssertValid(conversation);
});
}
Ein Generator für gültige Konversationen (System, dann abwechselnd User/Assistant, gelegentlich Assistant-mit-N-Tool-Calls gefolgt von genau N Tool-Antworten) findet Grenzfälle, die von Hand niemand schreibt — genau die Klasse, in die B1 fällt.
3.2 LoopGuard (deckt B3 ab)
| # | Test | Erwartung |
|---|---|---|
| L1 | MaxSteps erreicht |
Wirft bei Schritt N+1, nicht bei N |
| L2 | Konstanter 20k-Kontext über 10 Schritte | Bricht nicht ab — definiert die Zielsemantik nach dem Fix |
| L3 | Kostenbudget überschritten | Wirft mit klarer Meldung, welches Limit griff |
| L4 | 100 parallele RecordStep |
Zähler exakt 100 (Interlocked-Korrektheit) |
L2 ist der Test, der den heutigen Fehlentwurf festnagelt: Er schlägt mit den aktuellen Defaults
fehl und geht nach der Trennung von maxCumulativeTokens und maxContextTokens durch.
3.3 AgentEngine — Nebenläufigkeit (deckt B2 ab)
| # | Test | Erwartung |
|---|---|---|
| E1 | Zwei parallele ChatAsync für denselben Agenten |
Kontext danach AssertValid; schlägt heute fehl |
| E2 | 50 parallele ChatAsync, je 2 Tool-Schritte |
Keine Exception, Nachrichtenzahl deterministisch |
| E3 | AbortChat bei zwei laufenden Läufen |
Beide werden abgebrochen |
| E4 | Parallele Läufe für verschiedene Agenten | Laufen wirklich parallel (keine globale Serialisierung) |
E4 ist wichtig als Gegenprobe: Der Fix für B2 darf nicht versehentlich alle Agenten gegeneinander sperren.
3.4 CronExpression & Scheduler (deckt B6, B7 ab)
| # | Test | Erwartung |
|---|---|---|
| R1 | Tabelle: */30 * * * *, 0 3 * * 1-5, 0 0 29 2 *, 15,45 * * * * |
Korrekte nächste Zeitpunkte |
| R2 | Invariante über viele Ausdrücke | GetNextOccurrence(t) > t und Matches(ergebnis) |
| R3 | Ungültig: "* * *", "99 * * * *", "a b c d e", "*/0 * * * *" |
Saubere FormatException — heute wirft teils int.Parse/DivideByZero |
| R4 | Zeitumstellung Oktober, 0 2 * * * |
Feuert genau einmal |
| R5 | Zeitumstellung März, 0 2 * * * |
Feuert trotzdem (wird nicht übersprungen) |
| R6 | 0 3 1 1 * (jährlich) |
Scheduler überlebt Wartezeit > 24 Tage — schlägt heute fehl |
| R7 | Lauf dauert länger als das Intervall | Keine Überlappung, keine Aufstauung |
| R8 | Exception in einem Job-Tick | Scheduler-Schleife läuft weiter, Fehler wird geloggt |
3.5 Kostenerfassung (deckt B4 ab)
| # | Test | Erwartung |
|---|---|---|
| K1 | Modell nicht in der Preistabelle | Sichtbar markiert (nicht stillschweigend 0) |
| K2 | anthropic/claude-sonnet-4-5 (der Default!) |
Liefert einen Preis ≠ 0 |
| K3 | 95k Prompt / 5k Completion | Exakter Betrag gegen handgerechneten Wert — kein 50/50-Split |
Prio 2 — Sicherheitstests
Diese Tests sind gleichzeitig die dauerhafte Dokumentation der Bedrohungsmodelle. Jeder
Angriffsvektor aus der Bestandsaufnahme wird ein [Theory]-Fall.
3.6 FileRW — Pfadsicherheit (deckt S6 ab)
[Theory]
[InlineData("../../../Windows/System32/drivers/etc/hosts")]
[InlineData(@"..\..\evil.txt")]
[InlineData(@"C:\Windows\System32\config\SAM")]
[InlineData(@"\\server\share\evil.txt")]
[InlineData("subdir/../../../outside.txt")]
[InlineData("file.txt:hidden")] // Alternate Data Stream
[InlineData("file.txt.")] // Windows entfernt den Punkt
[InlineData(" .. /../evil.txt")]
public async Task Read_lehnt_Pfade_ausserhalb_des_Workspace_ab(string path) { … }
Plus der Präfix-Angriff, der heute durchgeht:
| # | Test | Erwartung |
|---|---|---|
| F1 | Root …\Workspace, Ziel …\Workspace-Backup\x.txt |
Abgelehnt — schlägt heute fehl |
| F2 | Endungs-Whitelist: .exe, .ps1, .dll |
Abgelehnt |
| F3 | .TXT bei Whitelist .txt |
Erlaubt (Groß/Kleinschreibung) |
| F4 | report.txt.exe |
Abgelehnt |
| F5 | copy mit destinationPath außerhalb des Ziel-Workspace |
Abgelehnt (eigener Pfad, eigener Test!) |
| F6 | sharedAccessLevel: Denied |
Jede shared-Aktion abgelehnt |
| F7 | ReadWrite + delete in shared |
Abgelehnt (nur Admin) |
| F8 | protectedPath stocks/, Schreiben auf existierende stocks/x.json |
Abgelehnt |
| F9 | protectedPath stocks/, Schreiben auf neue stocks/y.json |
Erlaubt |
| F10 | protectedPath stocks/, Pfad stocks-alt/x.json |
Nicht geschützt (Präfix-Verwechslung) |
| F11 | Admin-Level + protectedPath | Umgeht den Schutz |
| F12 | 20 parallele stock_add auf denselben Ticker |
_index.json enthält exakt 20 Einträge, gültiges JSON |
F12 deckt B9 ab und ist ein echter Nebenläufigkeitstest gegen das Dateisystem.
3.7 DatabaseTool — SQL-Whitelist (deckt S1 ab)
Die Bypass-Tests. Alle schlagen heute fehl und definieren die Zielsemantik:
| # | SQL | AccessLevel | Erwartung |
|---|---|---|---|
| D1 | DELETE FROM users -- prices |
ReadWrite | Abgelehnt |
| D2 | SELECT * FROM users WHERE note='prices' |
ReadOnly | Abgelehnt |
| D3 | SELECT * FROM prices; DROP TABLE users |
Admin | Abgelehnt (Mehrfach-Statement) |
| D4 | SELECT * FROM prices |
ReadOnly | Erlaubt |
| D5 | SELECT * FROM prices WHERE note='update' |
ReadOnly | Erlaubt (heutiger False Positive) |
| D6 | UPDATE prices SET x=1 |
ReadOnly | Abgelehnt |
| D7 | UPDATE prices SET x=1 |
ReadWrite | Erlaubt |
| D8 | DROP TABLE prices |
ReadWrite | Abgelehnt |
| D9 | allowedTables fehlt komplett |
egal | Abgelehnt (fail closed) |
| D10 | SELECT mit 10.000 Zeilen Ergebnis |
ReadOnly | Ergebnis wird gekappt |
D4/D5/D7 sind die Gegenproben — sie verhindern, dass der Fix zu restriktiv wird und legitime Abfragen blockiert.
3.8 WebFetch — SSRF (deckt S5 ab)
Mit einem FakeHttpMessageHandler, der Redirects simuliert:
| # | Test | Erwartung |
|---|---|---|
| W1 | Erlaubte Domain → Redirect auf nicht erlaubte | Abgelehnt — schlägt heute fehl |
| W2 | http://169.254.169.254/latest/meta-data/ |
Abgelehnt |
| W3 | http://localhost:8418/ |
Abgelehnt (privates Netz) |
| W4 | http://192.168.178.10/ |
Abgelehnt |
| W5 | file:///C:/Windows/win.ini |
Abgelehnt |
| W6 | Whitelist example.com, URL example.com.attacker.net |
Abgelehnt |
| W7 | Whitelist example.com, URL sub.example.com |
Erlaubt |
| W8 | Host mywww.example.com |
Wird nicht zu myexample.com verstümmelt |
| W9 | Antwort größer als maxResponseKb |
Sauber gekappt, kein OOM |
3.9 Mail — Empfängerprüfung
| # | Test | Erwartung |
|---|---|---|
| M1 | Nicht gelistete Adresse | Abgelehnt |
| M2 | ALLOWED@X.DE bei Whitelist allowed@x.de |
Erlaubt |
| M3 | allowed@x.de, evil@y.de (zwei in einem Feld) |
Abgelehnt |
| M4 | "allowed@x.de" <evil@y.de> (Display-Name-Trick) |
Abgelehnt |
| M5 | allowedRecipients fehlt |
Abgelehnt (fail closed) |
| M6 | read_inbox mit 500 Mails |
Ergebnis gekappt |
3.10 SocialMediaManager — Argument-Injection (deckt S2 ab)
| # | Test | Erwartung |
|---|---|---|
| Y1 | BuildYtDlpArgs("--exec calc.exe https://youtube.com/x") |
Wirft oder quotet — nie als eigene Option |
| Y2 | URL https://evil.com/x |
Abgelehnt (nur YouTube-Domains) |
| Y3 | URL mit Leerzeichen/Anführungszeichen | Erscheint als ein Argument in ArgumentList |
| Y4 | Legitime URL/Handle (@kanal, volle URL) |
Wird korrekt aufgelöst |
3.11 DirectAPI — Key-Leak (deckt S3 ab)
Ein einzelner, sehr einfacher Test mit hohem Wert:
[Fact]
public async Task Ergebnis_enthaelt_niemals_den_ApiKey()
{
const string key = "SECRET-KEY-12345";
var result = await _tool.ExecuteAsync(QuoteFor("NVDA"), ContextWithKey(key), default);
result.Content.ShouldNotContain(key); // schlägt heute fehl
}
Analog für alle Provider (twelvedata, alphavantage, massive) und alle Aktionen — als
[Theory]. Dieselbe Prüfung sollte es zusätzlich für die Log-Ausgabe geben.
Prio 3 — Engine-Verhalten (mit FakeChatClient)
| # | Test | Erwartung |
|---|---|---|
| A1 | Antwort ohne tool_calls | Genau 1 API-Call, FinalMessage korrekt, Status Completed |
| A2 | Antwort mit 2 tool_calls, dann Text | 2 Tools ausgeführt, 2 tool-Nachrichten mit passenden Ids, dann 2. Call |
| A3 | Tool wirft Exception | Lauf läuft weiter, Fehler kommt als tool-Antwort ins Modell |
| A4 | Modell ruft nicht zugewiesenes Tool auf | PermissionGate greift, Fehlermeldung als tool-Antwort, kein Absturz |
| A5 | Modell ruft unbekanntes Tool auf | Saubere Fehlermeldung |
| A6 | Timeout (FakeTimeProvider) |
Status Cancelled |
| A7 | Externe Cancellation | Status Cancelled |
| A8 | Loop-Limit erreicht | Status LoopLimitExceeded |
| A9 | OnRunCompleted feuert in jedem Endzustand genau einmal |
Parametrisiert über alle 5 Status |
| A10 | System-Prompt | Im Request genau einmal enthalten |
| A11 | FullSystemPrompt leer |
Kein leerer system-Eintrag im Request |
| A12 | Tool-Ergebnis über Limit | Gekappt (nach T2-Fix) |
| A13 | send_message A→B→A |
Nach maxDelegationDepth abgebrochen (nach B8-Fix) |
| A14 | spawn auf bereits laufenden Agenten |
Abgelehnt mit Hinweis |
| A15 | Snapshot des Request-JSON (Verify) | Wire-Format bleibt stabil |
A9 und A15 sind Invarianten-Tests mit überproportionalem Nutzen: A9 verhindert, dass beim Erweitern der Fehlerbehandlung die Token-Erfassung stillschweigend ausfällt; A15 schützt beim Caching-Umbau (T1) davor, das Wire-Format versehentlich zu verändern.
Prio 4 — Konfiguration & Persistenz
| # | Bereich | Test |
|---|---|---|
| P1 | ConfigLoader |
Fehlender openRouterApiKey → aussagekräftige Exception |
| P2 | ConfigLoader |
Doppelte agentId → Exception mit beiden Namen |
| P3 | ConfigLoader |
JSON mit Kommentaren und trailing commas wird akzeptiert |
| P4 | ConfigLoader |
Datei fehlt → FileNotFoundException mit Pfad |
| P5 | Chat-Persistenz | Round-Trip History + Context, inkl. tool_calls |
| P6 | Chat-Persistenz | Beide Alt-Formate lesbar (Array und {"messages":[…]}) |
| P7 | Chat-Persistenz | Korrupte JSON-Datei → Warnung, Agent startet trotzdem |
| P8 | Chat-Persistenz | Umlaute/Emoji überleben den Round-Trip (UTF-8) |
| P9 | SqliteStateStore |
Set/Get/Delete Round-Trip; Überschreiben; unbekannter Key → null |
| P10 | SqliteStateStore |
50 parallele Schreibzugriffe → kein database is locked |
| P11 | AgentConfig.FullSystemPrompt |
Reihenfolge Identity → Soul → SystemPrompt |
| P12 | AgentConfig.FullSystemPrompt |
Leere Teile werden ausgelassen, kein doppelter Leerraum |
| P13 | ToolRegistry |
GetForAgent liefert nur zugewiesene Tools |
P10 ist relevant, weil alle Tool-Jobs denselben Store teilen.
Prio 5 — Token & Caching (nach dem Umbau aus T1)
Diese Tests sichern ab, dass die Optimierung tatsächlich greift — ein stillschweigend nicht funktionierender Cache wäre sonst nicht bemerkbar.
| # | Test | Erwartung |
|---|---|---|
| T1 | Cache-Breakpoint | Sitzt am Ende von System-Prompt und Tool-Definitionen |
| T2 | Präfix-Stabilität über 10 Schritte | Serialisierter Präfix ist byte-identisch — der entscheidende Test |
| T3 | cached_tokens |
Wird aus der Antwort gelesen und in AgentRunResult weitergereicht |
| T4 | Compaction | Nutzt summaryModel, nicht agentConfig.Model |
| T5 | Tool-Ergebnis-Kappung | Greift bei allen Tools zentral, nicht nur bei WebFetch |
| T6 | max_tokens |
Ist im Request gesetzt |
T2 ist der Test, der den ganzen Caching-Aufwand absichert: Ein einziger Zeitstempel im System-Prompt würde den Cache bei jedem Schritt invalidieren — die Kosten blieben unverändert, ohne dass es irgendwo auffällt.
4. Arbeitsregeln
Test-first bei Bugfixes. Für jeden Punkt aus der Bestandsaufnahme zuerst den roten Test, dann den Fix. Das beweist, dass der Test den Fehler wirklich fängt — ein nachträglich geschriebener Test bestätigt oft nur die Implementierung.
Keine echten Netzwerkzugriffe in Unit-Tests. Sie sind langsam, flaky und kosten bei OpenRouter echtes Geld. Integrationstests kommen ins separate Projekt und werden markiert:
[Trait("Category", "Integration")]
Standardlauf:
dotnet test --filter "Category!=Integration"
Zeit immer über TimeProvider. Kein Thread.Sleep, kein Task.Delay in Tests — sonst
dauert die Suite Minuten und wird instabil.
Nebenläufigkeitstests wiederholen. Race Conditions treten sporadisch auf; einmaliges Bestehen sagt wenig:
[Theory]
[Repeat(50)]
Benennung durchgängig deutsch, im Stil Methode_Bedingung_Erwartung:
Compaction_bei_Toolsequenz_an_der_Grenze_erhaelt_gueltige_Paarung.
5. Startpaket
Für die erste Sitzung schlage ich ~35 Tests vor, die exakt die fünf Sofort-Punkte abdecken:
| Reihenfolge | Umfang | Deckt ab |
|---|---|---|
1. R1 (IChatCompletionClient) + Testprojekte anlegen |
Refactoring | Voraussetzung |
2. ContextInvariants.AssertValid + C1–C10 + Property-Test |
~12 Tests | B1 |
| 3. L1–L4 | 4 Tests | B3 |
| 4. E1–E4 | 4 Tests | B2 |
| 5. Y1–Y4 | 4 Tests | S2 |
| 6. DirectAPI Key-Leak (alle Provider) | ~6 Tests | S3 |
| 7. A9 + A15 (Invarianten) | 6 Tests | Absicherung für den Umbau |
Danach ist die Basis da, um die Fixes aus der Bestandsaufnahme mit Netz umzusetzen — und die Prio-2-Sicherheitstests lassen sich schrittweise ergänzen, wenn die jeweiligen Tools angefasst werden.
Langfristig sinnvoll: ein Gitea-Actions-Workflow, der bei jedem Push
dotnet test --filter "Category!=Integration" ausführt.