# ClawdDotNet — Teststrategie Ergänzung zur [Bestandsaufnahme](Bestandsaufnahme-2026-07.md). 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: 1. **Kontext-Invarianten** — ungültige Nachrichtensequenzen kosten API-Fehler mitten im Lauf 2. **Sicherheitsgrenzen** — Pfad-, SQL-, URL- und Empfänger-Prüfungen 3. **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`](../src/ClawdDotNet.Core/Engine/AgentEngine.cs#L15) und [`ContextCompactor.cs:10`](../src/ClawdDotNet.Core/Engine/ContextCompactor.cs#L10) hängen an der konkreten, `sealed`-Klasse `OpenRouterClient`. Ohne Interface ist kein einziger Engine-Test möglich. ```csharp public interface IChatCompletionClient { Task 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: ```csharp internal sealed class FakeChatClient : IChatCompletionClient { private readonly Queue> _responses = new(); public List ReceivedRequests { get; } = new(); public FakeChatClient RespondsWithText(string text) { … } public FakeChatClient RespondsWithToolCall(string tool, string argsJson) { … } public FakeChatClient Throws(Exception ex) { … } public Task CompleteAsync(ChatRequest request, CancellationToken ct) { // Tiefe Kopie ablegen — die Engine mutiert die Nachrichtenliste weiter! ReceivedRequests.Add(DeepClone(request)); … } } ``` > Die tiefe Kopie ist wesentlich: `AgentEngine` reicht dieselbe `List`-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`](../src/ClawdDotNet.Core/Scheduling/CronExpression.cs#L53), [`AgentScheduler.cs:75`](../src/ClawdDotNet.Core/Scheduling/AgentScheduler.cs#L75), [`ToolJobScheduler.cs:111`](../src/ClawdDotNet.Core/Scheduling/ToolJobScheduler.cs#L111). Damit werden Zeitumstellungs- und Langzeit-Tests deterministisch und laufen in Millisekunden statt in Echtzeit. ### R3 — `IProcessRunner` für externe Prozesse [`SocialMediaManagerTool.cs:442`](../src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs#L442) startet yt-dlp/ffmpeg direkt. Für den Injection-Test (S2) muss die **Argumentbildung** ohne Prozessstart prüfbar sein: ```csharp internal static string[] BuildYtDlpArgs(string url, …); // rein, testbar public interface IProcessRunner { Task 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: ```csharp 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.slnx` fehlen vier Tool-Projekte (AgentComm, AgentSpawn, > AgentEditor, SocialMediaManager), obwohl sie in `src/ClawdDotNet.App/ClawdDotNet.App.csproj` referenziert 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: ```csharp internal static class ContextInvariants { /// Wirft, wenn die Nachrichtenfolge von der API abgelehnt würde. public static void AssertValid(IReadOnlyList 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:** ```csharp [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) ```csharp [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" ` (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: ```csharp [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: ```csharp [Trait("Category", "Integration")] ``` Standardlauf: ```bash 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: ```csharp [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.