C5: Human-Gated DELETE Execution Contract (Todo 7-13)
- DeleteExecutor (rq_c5_delete.py): Pre-Gates, Read-Back, idempotenter replay - TolariaClient.delete() (rq_c5c.py): kontrollierter DELETE, keine Probes - Approval-Store + Reason Codes RC_DELETE_APPROVAL_MISSING/MISMATCH (rq_c5a.py) - CLI: c5-delete-approve/execute/replay/status (rq_c5_cli.py) - C5E: recover()/replay() DELETE-Integration - C5D: verify_integrity prueft secret_blocked_objects (FAIL CLOSED) - Security: Path-Traversal-Block in _normalize_vault_path - Drift nach DELETE -> FAIL CLOSED zurueck zu HUMAN_REVIEW_REQUIRED - Tests: test_c5_delete (19), test_c5_delete_integration (22), test_c5_delete_fresh_checker (17) — alle gruen - ADR: C5_DELETE_EXECUTION_ARCHITECTURE_DECISION.md (ACCEPTED)
This commit is contained in:
parent
363f27327b
commit
3db1c71b68
11 changed files with 2604 additions and 2 deletions
|
|
@ -0,0 +1,298 @@
|
|||
# C5 DELETE EXECUTION CONTRACT — ARCHITECTURE DECISION RECORD
|
||||
|
||||
**Status:** ACCEPTED (Christian, OPTION 1 — CONTRACT-ERWEITERUNG, 2026-08-26)
|
||||
**Scope:** C5 Sync Service — kontrollierter Tolaria-DELETE-Pfad NACH expliziter Human-Freigabe
|
||||
**Datei:** `tolaria/c5-sync-service/C5_DELETE_EXECUTION_ARCHITECTURE_DECISION.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. Ausgangslage / DELETE_EXECUTION_CONTRACT_GAP
|
||||
|
||||
STRICT PRE-FLIGHT (read-only) hat den bestehenden DELETE-Contract vollständig analysiert
|
||||
(`rq_c5a.py`, `rq_c5b.py`, `rq_c5c.py`, `rq_c5d.py`, `rq_c5e.py`, `rq_c5_cli.py`,
|
||||
`C5AStore`, `TolariaClient`, Tests C5A–C5E). Ergebnis:
|
||||
|
||||
| Owner | Verantwortlich | Status |
|
||||
|---|---|---|
|
||||
| DELETE_DETECTION_OWNER | C5B `ChangeClassifier.classify()` (Z.520–523): `path_before && !path_after` → `OP_DELETE_REQUEST` | vorhanden |
|
||||
| DELETE_HUMAN_GATE_OWNER | C5A `SyncStateMachine.process_commit()` (Z.794–797) + C5C `_propagate_object()` (Z.492–496) → `ST_HUMAN_REVIEW_REQUIRED` + `RC_AMBIGUOUS_DELETE` | vorhanden (terminal) |
|
||||
| DELETE_APPROVAL_STORAGE | **fehlt** — C5AStore hat keine Approval-Repräsentation (nur `meta`, `commits`, `objects`, `bootstrap`, `health`) | GAP |
|
||||
| DELETE_EXECUTION_OWNER | **fehlt** — `TolariaClient` hat keine `delete()`; kein Executor | GAP |
|
||||
| DELETE_VERIFICATION_OWNER | **fehlt für DELETE** — `verify()` nur für Writes (Read-Back+Hash); kein DELETE-absent-Read-Back | GAP |
|
||||
| DELETE_REPLAY_OWNER | C5E `recover()`/`replay()`: `ST_HUMAN_REVIEW_REQUIRED` → `REC_HUMAN_REVIEW` (kein Executor) | GAP |
|
||||
| DELETE_APPLIED_OWNER | C5D `apply_commit()` → `ST_APPLIED`+`mark_applied()`, startet nur aus `ST_UPDATING_SEARCH` — DELETE-Commit erreicht das nie | GAP |
|
||||
|
||||
**Kernbefund:** DELETE wird erkannt und zum Human Gate geführt, aber das Gate ist **terminal**.
|
||||
Es existiert kein Approval-Storage, kein Executor, kein DELETE-Read-Back, kein Replay- und
|
||||
kein APPLIED-Pfad. Dies ist der `DELETE_EXECUTION_CONTRACT_GAP`, der durch diese Mission
|
||||
sauber geschlossen wird.
|
||||
|
||||
---
|
||||
|
||||
## 2. ZIELARCHITEKTUR (verbindlich)
|
||||
|
||||
```
|
||||
DELETE erkannt (C5B)
|
||||
→ HUMAN_REVIEW_REQUIRED (C5A/C5C, RC_AMBIGUOUS_DELETE) [bleibt erhalten]
|
||||
→ explizite Human Approval (CLI approve-delete) [NEU]
|
||||
→ Approval persistent/auditierbar (C5AStore) [NEU]
|
||||
→ kontrollierter Tolaria DELETE (DeleteExecutor) [NEU]
|
||||
→ Read-Back-Verifikation (target absent) [NEU]
|
||||
→ C5D Source Build (bestehender Pfad) [wiederverwendet]
|
||||
→ Search Update (bestehender C5D-Pfad) [wiederverwendet]
|
||||
→ Exact-Set-Verifikation (C5D verify_integrity) [wiederverwendet]
|
||||
→ APPLIED (C5D mark_applied) [wiederverwendet]
|
||||
```
|
||||
|
||||
**KEIN automatischer DELETE.** Der bestehende Sicherheitsmechanismus
|
||||
`OP_DELETE_REQUEST → HUMAN_REVIEW_REQUIRED → RC_AMBIGUOUS_DELETE` bleibt unverändert.
|
||||
|
||||
---
|
||||
|
||||
## 3. Zwingende Invarianten (A–N) und ihre Umsetzung
|
||||
|
||||
| Invariante | Umsetzung |
|
||||
|---|---|
|
||||
| **A) DELETE bleibt HUMAN_GATED** | Kein Pfad führt einen DELETE ohne persistierte, explizite Human-Approval aus. `_HUMAN_GATE_OPS` unverändert. |
|
||||
| **B) Kein LLM/Agent entscheidet, dass DELETE genehmigt ist** | Approval wird AUSSCHLIESSLICH durch den CLI-Befehl `approve-delete` erzeugt (Human Gate). Kein Code erzeugt Approval automatisch. |
|
||||
| **C) Approval stammt explizit vom Human Gate** | `approve-delete` persistiert `approved_by` (Human-Gate-Provenance) + `approval_nonce`. |
|
||||
| **D) Approval commit- und objectchange-spezifisch** | Approval bindet exakt `(workflow_commit, object_change_id, object_id, path)`. Keine globale `DELETE_APPROVED=true`-Semantik. |
|
||||
| **E) Approval für Objekt A nie Objekt B** | Executor validiert `object_id` + `path` gegen die Approval. Abweichung → FAIL CLOSED. |
|
||||
| **F) Approval für Commit X nie DELETE aus Commit Y** | Approval bindet `workflow_commit`. Executor validiert Commit-SHA. |
|
||||
| **G) Approval persistent und crash-/restart-fest** | SQLite-Tabelle `delete_approvals` in C5AStore (atomic, WAL, restart-safe). |
|
||||
| **H) Execution idempotent** | Pre-Delete-Drift-Check + Read-Back. Wenn Ziel bereits absent → `DELETE_ALREADY_AT_TARGET`, kein zweiter destruktiver Write. |
|
||||
| **I) DELETE nur exakt freigegebenes Objekt** | Executor nimmt `path`/`object_id` ausschließlich aus dem ObjectChange, das durch die Approval gebunden ist. |
|
||||
| **J) Kein Pfad nimmt beliebige freie Paths/IDs ohne ObjectChange+Approval** | Executor verlangt existierendes ObjectChange (`operation==DELETE`) + exakt passende Approval. Kein freier Pfad-Parameter. |
|
||||
| **K) Tolaria DELETE vor und nach Execution verifiziert** | Pre-Delete-Drift-Check (Objekt existiert noch) + Post-Read-Back (target absent + kein anderes Objekt verändert). |
|
||||
| **L) Keine manuelle SQLite-/SQL-State-Manipulation** | Nur Store-Methoden. Kein direktes SQL im Executor. |
|
||||
| **M) Keine direkte APPLIED-Markierung** | APPLIED nur durch C5D `apply_commit()` nach vollem Search-PASS. Executor markiert NIE APPLIED. |
|
||||
| **N) Keine automatische Human-Gate-Deaktivierung** | `_HUMAN_GATE_OPS` unverändert; DELETE bleibt Gate-pflichtig. |
|
||||
|
||||
---
|
||||
|
||||
## 4. APPROVAL CONTRACT
|
||||
|
||||
**Prüfung bestehender State:** C5AStore besitzt KEINE geeignete persistente
|
||||
Approval-Repräsentation (nur `meta`-KV für source_provenance, `commits`, `objects`,
|
||||
`bootstrap`, `health`). → **Neuer minimaler auditierbarer Approval-State.**
|
||||
|
||||
### 4.1 Schema (neue Tabelle `delete_approvals` in C5AStore)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS delete_approvals (
|
||||
approval_id TEXT PRIMARY KEY, -- UUID, eindeutig
|
||||
workflow_commit TEXT NOT NULL, -- Commit-SHA (Invariante F)
|
||||
object_change_id INTEGER NOT NULL, -- FK auf objects.id (Invariante D/E)
|
||||
object_id TEXT NOT NULL, -- exakt freigegebenes Objekt (E/I)
|
||||
path TEXT NOT NULL, -- exakter Vault-Pfad (E/I)
|
||||
operation TEXT NOT NULL DEFAULT 'DELETE',
|
||||
approval_status TEXT NOT NULL, -- APPROVED | USED | REVOKED
|
||||
approved_by TEXT NOT NULL, -- Human-Gate-Provenance (C)
|
||||
approval_nonce TEXT NOT NULL, -- eindeutig, verhindert Reuse
|
||||
approved_at INTEGER NOT NULL,
|
||||
used_at INTEGER,
|
||||
UNIQUE(workflow_commit, object_change_id, object_id, path)
|
||||
)
|
||||
```
|
||||
|
||||
**Keine Credentials, keine personenbezogenen Daten.** `approved_by` = Human-Gate-Identifier
|
||||
(z.B. `human:christian`), kein Secret.
|
||||
|
||||
### 4.2 Keying
|
||||
|
||||
Approval ist eindeutig gebunden an `(workflow_commit, object_change_id, object_id, path)`.
|
||||
Keine globale `DELETE_APPROVED=true`-Semantik. Ein Approval autorisiert GENAU EINEN
|
||||
ObjectChange in GENAU EINEM Commit.
|
||||
|
||||
### 4.3 CLI-Modell (kein undifferenzierter approve+delete-Blackbox)
|
||||
|
||||
Bevorzugtes Modell (Mission §3):
|
||||
1. `c5-delete approve --commit <sha> --object-id <id> --path <path>` → persistiert Approval (NUR Approval, KEIN DELETE)
|
||||
2. `c5-delete execute --commit <sha>` → führt DELETE aus (verlangt persistierte Approval)
|
||||
3. `c5-delete status --commit <sha>` → read-only Status
|
||||
4. `c5-delete replay --commit <sha>` → idempotenter Replay nach Crash
|
||||
|
||||
Der Human-Gate-Beweis (persistierte Approval) geht dadurch NIE verloren.
|
||||
|
||||
---
|
||||
|
||||
## 5. TOLARIA DELETE CLIENT
|
||||
|
||||
`TolariaClient.delete(vault_path)` — minimal, isoliert:
|
||||
|
||||
```python
|
||||
def delete(self, vault_path: str) -> Dict[str, Any]:
|
||||
"""Loescht ein Vault-Objekt (POST /delete). NUR DeleteExecutor darf rufen."""
|
||||
resp = self._post("delete", {"path": vault_path})
|
||||
if resp is None:
|
||||
resp = {}
|
||||
if "error" in resp:
|
||||
raise TolariaWriteError(f"Tolaria delete fehlgeschlagen: {resp['error']}",
|
||||
RC_INVALID_SCHEMA)
|
||||
return resp
|
||||
```
|
||||
|
||||
**Endpoint-/Payload-Contract** wird ausschließlich aus dem vorhandenen Tolaria-Code/Router
|
||||
und der dokumentierten Incident-Evidence bestimmt (POST `/api/vault/delete` mit
|
||||
`{"path": ...}`). **KEINE produktive Endpoint-Probe.** Die Incident-Regel gilt verbindlich:
|
||||
`NO MUTATING HTTP METHOD PROBES DURING PRODUCTION READ-ONLY DISCOVERY`.
|
||||
|
||||
---
|
||||
|
||||
## 6. DELETE EXECUTION (DeleteExecutor)
|
||||
|
||||
### 6.1 Pre-Delete-Gates (alle MÜSSEN passen, sonst FAIL CLOSED)
|
||||
|
||||
1. ObjectChange existiert (`get_object_change`)
|
||||
2. `operation == DELETE`
|
||||
3. Commit-Status == `ST_DELETE_APPROVED` (definierter approved state)
|
||||
4. Approval existiert, `approval_status == APPROVED`, gehört exakt zu diesem ObjectChange
|
||||
5. `object_id` stimmt (Approval ↔ ObjectChange)
|
||||
6. `path` stimmt (Approval ↔ ObjectChange)
|
||||
7. Pre-Delete-Drift-Check: Tolaria enthält das Objekt noch (erwarteter aktueller Zustand)
|
||||
|
||||
Bei irgendeiner Abweichung → **FAIL CLOSED** (kein DELETE, kein State-Übergang).
|
||||
|
||||
### 6.2 Execution
|
||||
|
||||
- `client.delete(vault_path)` (nur DeleteExecutor)
|
||||
- **Read-Back:** `client.read(vault_path) is None` → target absent == TRUE
|
||||
- **Zusätzlich:** `client.list()` — kein anderes Objekt verändert (Set-Vergleich vor/nach)
|
||||
|
||||
### 6.3 Nach erfolgreichem DELETE
|
||||
|
||||
- Approval → `USED` (einmalig, verhindert Reuse)
|
||||
- Commit → `ST_UPDATING_SEARCH` (C5D übernimmt Search-Pfad)
|
||||
|
||||
---
|
||||
|
||||
## 7. IDEMPOTENZ / CRASH-RECOVERY (Fälle A–F)
|
||||
|
||||
| Fall | Zustand | Deterministischer Replay-Pfad |
|
||||
|---|---|---|
|
||||
| **A) Crash vor Approval** | `ST_HUMAN_REVIEW_REQUIRED`, keine Approval | Nichts zu tun. Kein DELETE. Replay: bleibt Human Gate. |
|
||||
| **B) Crash nach Approval, vor DELETE** | `ST_DELETE_APPROVED`, Approval persistiert | Replay: Executor erkennt Approval, führt DELETE aus. |
|
||||
| **C) Crash während DELETE** | `ST_DELETING` | Replay: Read-Back. Objekt noch da → DELETE erneut (Approval gültig). Objekt absent → `DELETE_ALREADY_AT_TARGET`, weiter zu `ST_UPDATING_SEARCH`. |
|
||||
| **D) Crash nach DELETE, vor Read-Back** | `ST_DELETING` | Replay: Read-Back → absent → weiter zu `ST_UPDATING_SEARCH`. |
|
||||
| **E) Crash nach Read-Back, vor Search Update** | `ST_UPDATING_SEARCH` | C5D `apply_commit()` übernimmt (bestehender Pfad). |
|
||||
| **F) Crash nach Search Update, vor APPLIED** | `ST_VERIFYING_SEARCH` | C5D `apply_commit()` übernimmt (bestehender Pfad). |
|
||||
|
||||
**DELETE_ALREADY_AT_TARGET:** Wenn Tolaria das freigegebene Objekt bereits nicht mehr
|
||||
enthält, erkennt der Executor anhand Approval + erwarteter Identität + Read-Back
|
||||
`DELETE_ALREADY_AT_TARGET` statt blind erneut zu mutieren.
|
||||
|
||||
---
|
||||
|
||||
## 8. SEARCH CONTRACT
|
||||
|
||||
Nach erfolgreichem Tolaria-DELETE wird Search NICHT über einen statischen/improvisierten
|
||||
Pfad aktualisiert. Der bestehende C5D-Pfad wird wiederverwendet:
|
||||
|
||||
```
|
||||
Tolaria current verified state (ohne gelöschtes Objekt)
|
||||
→ SearchSourceBuilder.build() (read-only, deterministisch)
|
||||
→ atomic source + Provenance-Persistenz (persist_search_source_provenance)
|
||||
→ Search-Rebuild (search.rebuild)
|
||||
→ Exact-Set-Verifikation (verify_integrity: expected IDs/Paths exakt)
|
||||
→ APPLIED (mark_applied)
|
||||
```
|
||||
|
||||
**Kein APPLIED bei Search-Mismatch** (C5D `_handle_failure` → Human Gate / DEAD).
|
||||
|
||||
---
|
||||
|
||||
## 9. STATE MACHINE CHANGES
|
||||
|
||||
Neue Zustände (minimal, nur für den DELETE-Execution-Pfad):
|
||||
|
||||
- `ST_DELETE_APPROVED = "DELETE_APPROVED"` — nach Approval, vor Execution
|
||||
- `ST_DELETING = "DELETING"` — während Execution (Crash-Window)
|
||||
|
||||
Neue erlaubte Transitionen (NUR durch DeleteExecutor/Approval auslösbar, nie automatisch):
|
||||
|
||||
```
|
||||
(ST_HUMAN_REVIEW_REQUIRED, ST_DELETE_APPROVED) # nur via expliziter Approval
|
||||
(ST_DELETE_APPROVED, ST_DELETING) # Executor startet
|
||||
(ST_DELETING, ST_UPDATING_SEARCH) # nach DELETE + Read-Back → C5D
|
||||
(ST_DELETING, ST_HUMAN_REVIEW_REQUIRED) # Fehler → zurück zum Gate
|
||||
(ST_DELETE_APPROVED, ST_HUMAN_REVIEW_REQUIRED) # Revoke/Fehler
|
||||
```
|
||||
|
||||
**Keine Änderung** an `_HUMAN_GATE_OPS`, `RC_AMBIGUOUS_DELETE`, oder den bestehenden
|
||||
Transitions. Der bestehende `OP_DELETE_REQUEST → HUMAN_REVIEW_REQUIRED`-Pfad bleibt
|
||||
unverändert.
|
||||
|
||||
---
|
||||
|
||||
## 10. CLI CHANGES
|
||||
|
||||
Neue Befehle in `rq_c5_cli.py`:
|
||||
|
||||
- `c5-delete approve --commit <sha> --object-id <id> --path <path>` → persistiert Approval (NUR Approval)
|
||||
- `c5-delete execute --commit <sha>` → führt DELETE aus (verlangt persistierte Approval)
|
||||
- `c5-delete status --commit <sha>` → read-only Status
|
||||
- `c5-delete replay --commit <sha>` → idempotenter Replay nach Crash
|
||||
|
||||
**Kein undifferenzierter approve+delete-Blackbox-Befehl.**
|
||||
|
||||
---
|
||||
|
||||
## 11. SECURITY REVIEW (Design-Zusicherungen)
|
||||
|
||||
- **Keine arbitrary path deletion:** Executor nimmt `path` nur aus dem ObjectChange, das durch Approval gebunden ist.
|
||||
- **Keine path traversal:** `path` wird gegen `VAULT_PREFIX` validiert.
|
||||
- **Keine ungeprüften freien Delete-Targets:** Kein freier Pfad-Parameter im Executor.
|
||||
- **Keine Approval-Reuse:** `approval_id` eindeutig; nach Nutzung → `USED`.
|
||||
- **Keine Cross-Commit-Approval:** Approval bindet `workflow_commit`.
|
||||
- **Keine Cross-Object-Approval:** Approval bindet `object_id` + `path`.
|
||||
- **Keine automatische Approval-Eskalation:** Nur expliziter CLI-Befehl erzeugt Approval.
|
||||
- **Keine Secrets:** Approval speichert keine Credentials/personenbezogenen Daten.
|
||||
- **Keine Credential-Ausgabe:** Kein Secret im Approval-/Status-Output.
|
||||
- **Keine manuelle DB-Manipulation:** Nur Store-Methoden.
|
||||
- **Keine allgemeine DELETE-Automatisierung:** Kein Pfad führt DELETE ohne Approval.
|
||||
|
||||
---
|
||||
|
||||
## 12. TESTPLAN (mindestens)
|
||||
|
||||
- DELETE ohne Human Approval → BLOCKED
|
||||
- DELETE mit Approval für anderes Objekt → BLOCKED
|
||||
- DELETE mit Approval für anderen Commit → BLOCKED
|
||||
- manipulierte object_id → BLOCKED
|
||||
- manipulierter path → BLOCKED
|
||||
- gültiges Approval → exakt ein DELETE
|
||||
- anderes Objekt bleibt unverändert
|
||||
- DELETE_ALREADY_AT_TARGET → kein zweiter destruktiver Write
|
||||
- Restart nach Approval → Approval erhalten
|
||||
- Crash nach DELETE → Replay sicher
|
||||
- Search Exact-Set nach DELETE
|
||||
- Search-Mismatch → kein APPLIED
|
||||
- Secret Safety
|
||||
- keine SQL-/State-Bypässe
|
||||
- bestehende WRITE-/UPDATE-Flows ohne Regression
|
||||
- HUMAN_REVIEW_REQUIRED bleibt Default für neue DELETE requests
|
||||
- keine automatische Approval-Erzeugung
|
||||
- Multi-Commit-Test: Approval A darf DELETE B niemals autorisieren
|
||||
|
||||
---
|
||||
|
||||
## 13. INCIDENT LESSON (verbindlich, bleibt)
|
||||
|
||||
```
|
||||
INCIDENT_OCCURRED = TRUE
|
||||
INCIDENT_RECOVERED = TRUE
|
||||
PERMANENT_DAMAGE = NONE VERIFIED
|
||||
ROOT_CAUSE = unsafe mutating endpoint probe during read-only discovery
|
||||
```
|
||||
|
||||
**Neue verbindliche Regel:** `NO MUTATING HTTP METHOD PROBES DURING PRODUCTION READ-ONLY
|
||||
DISCOVERY`. Diese Regel darf durch die neue DELETE-Implementierung NICHT aufgeweicht werden.
|
||||
|
||||
---
|
||||
|
||||
## 14. HARD STOP
|
||||
|
||||
Nach erfolgreicher Implementation, Tests, Push und Fresh Checker: **STOPP.**
|
||||
KEIN realer Canary-Delete. KEIN produktiver Search-Rebuild. KEIN C5F-Cleanup. KEIN C5G.
|
||||
KEINE Netzwerkänderung. KEINE Hermes-Rechte. KEINE Hermes-Autonomie.
|
||||
Christian erteilt danach separat die Freigabe zur produktiven Canary-Cleanup-Validierung.
|
||||
|
|
@ -35,7 +35,7 @@ import os
|
|||
import sys
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from rq_c5a import C5AStore, ST_APPLIED
|
||||
from rq_c5a import C5AStore, ST_APPLIED, ST_DELETE_APPROVED, OP_DELETE_REQUEST
|
||||
from rq_c5b import (
|
||||
C5BPoller,
|
||||
GitReader,
|
||||
|
|
@ -70,6 +70,11 @@ from rq_c5e import (
|
|||
health_contract,
|
||||
failure_evidence,
|
||||
)
|
||||
from rq_c5_delete import (
|
||||
DeleteExecutor,
|
||||
DeleteApprovalError,
|
||||
DeleteExecutionError,
|
||||
)
|
||||
|
||||
|
||||
def _store(db_path: str) -> C5AStore:
|
||||
|
|
@ -332,6 +337,136 @@ def cmd_c5e_guarantees(args: argparse.Namespace) -> int:
|
|||
return 0 if ok else 1
|
||||
|
||||
|
||||
# -- C5 DELETE-Execution (Human-Gated) --------------------------------------
|
||||
|
||||
def _delete_executor(args: argparse.Namespace) -> DeleteExecutor:
|
||||
store = _store(args.db)
|
||||
client = TolariaClient(base_url=args.tolaria_base)
|
||||
return DeleteExecutor(store, client)
|
||||
|
||||
|
||||
def cmd_c5_delete_approve(args: argparse.Namespace) -> int:
|
||||
"""
|
||||
Persistiert eine explizite Human-Approval fuer einen DELETE-ObjectChange.
|
||||
|
||||
NUR dieser Befehl erzeugt eine Approval (Human Gate). Er fuehrt KEINEN
|
||||
DELETE aus. Der Human-Gate-Beweis (persistierte Approval) geht dadurch
|
||||
nie verloren (Mission §3: kein undifferenzierter approve+delete-Blackbox).
|
||||
"""
|
||||
store = _store(args.db)
|
||||
try:
|
||||
# ObjectChange muss existieren und operation == DELETE sein.
|
||||
changes = store.list_object_changes(args.commit)
|
||||
deletes = [c for c in changes if c.get("operation") == OP_DELETE_REQUEST]
|
||||
if len(deletes) != 1:
|
||||
print(json.dumps({
|
||||
"error": f"Erwarte genau einen DELETE-ObjectChange in {args.commit}, "
|
||||
f"gefunden: {len(deletes)}",
|
||||
"reason_code": "DELETE_APPROVAL_MISMATCH",
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 1
|
||||
change = deletes[0]
|
||||
oid = change.get("object_id")
|
||||
path = change.get("path_before") or change.get("path_after")
|
||||
if oid is None or path is None:
|
||||
print(json.dumps({
|
||||
"error": f"DELETE-ObjectChange {change.get('id')} hat keine object_id/path",
|
||||
"reason_code": "DELETE_APPROVAL_MISMATCH",
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 1
|
||||
# Optional: --object-id / --path zur Bestaetigung (muss exakt passen).
|
||||
if args.object_id and args.object_id != oid:
|
||||
print(json.dumps({
|
||||
"error": f"--object-id {args.object_id} passt nicht zum ObjectChange {oid}",
|
||||
"reason_code": "DELETE_APPROVAL_MISMATCH",
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 1
|
||||
if args.path and args.path != path:
|
||||
print(json.dumps({
|
||||
"error": f"--path {args.path} passt nicht zum ObjectChange {path}",
|
||||
"reason_code": "DELETE_APPROVAL_MISMATCH",
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 1
|
||||
approval = store.create_delete_approval(
|
||||
workflow_commit=args.commit,
|
||||
object_change_id=change["id"],
|
||||
object_id=oid,
|
||||
path=path,
|
||||
approved_by=args.approved_by,
|
||||
)
|
||||
# Commit -> ST_DELETE_APPROVED (nur via expliziter Approval)
|
||||
store.transition_commit(args.commit, ST_DELETE_APPROVED)
|
||||
print(json.dumps({
|
||||
"status": "APPROVED",
|
||||
"approval_id": approval["approval_id"],
|
||||
"workflow_commit": args.commit,
|
||||
"object_change_id": change["id"],
|
||||
"object_id": oid,
|
||||
"path": path,
|
||||
"approved_by": args.approved_by,
|
||||
"note": "Approval persistiert. DELETE wird NICHT automatisch ausgefuehrt.",
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except Exception as e: # noqa: BLE001 - CLI-Fehlerbehandlung
|
||||
print(json.dumps({"error": str(e)}, ensure_ascii=False, indent=2))
|
||||
return 1
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
def cmd_c5_delete_execute(args: argparse.Namespace) -> int:
|
||||
"""Fuehrt den kontrollierten Tolaria-DELETE aus (verlangt persistierte Approval)."""
|
||||
store = _store(args.db)
|
||||
executor = DeleteExecutor(store, TolariaClient(base_url=args.tolaria_base))
|
||||
try:
|
||||
result = executor.execute(args.commit)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except (DeleteApprovalError, DeleteExecutionError) as e:
|
||||
print(json.dumps({
|
||||
"error": e.message,
|
||||
"reason_code": e.reason_code,
|
||||
"fail_closed": True,
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 1
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
def cmd_c5_delete_status(args: argparse.Namespace) -> int:
|
||||
"""Read-only Status: Commit + zugehoerige Approvals."""
|
||||
store = _store(args.db)
|
||||
try:
|
||||
commit = store.get_commit(args.commit)
|
||||
approvals = store.list_delete_approvals(args.commit)
|
||||
print(json.dumps({
|
||||
"commit": commit,
|
||||
"approvals": approvals,
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
def cmd_c5_delete_replay(args: argparse.Namespace) -> int:
|
||||
"""Idempotenter Replay nach Crash (verlangt persistierte Approval)."""
|
||||
store = _store(args.db)
|
||||
executor = DeleteExecutor(store, TolariaClient(base_url=args.tolaria_base))
|
||||
try:
|
||||
result = executor.replay(args.commit)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except (DeleteApprovalError, DeleteExecutionError) as e:
|
||||
print(json.dumps({
|
||||
"error": e.message,
|
||||
"reason_code": e.reason_code,
|
||||
"fail_closed": True,
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 1
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="rq_c5_cli",
|
||||
|
|
@ -432,6 +567,29 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||
p = sub.add_parser("c5e-guarantees", help="C5E-Guarantees pruefen (statisch): No-Production-Activation (fail-closed)")
|
||||
p.set_defaults(func=cmd_c5e_guarantees)
|
||||
|
||||
# --- C5 DELETE-Execution (Human-Gated) ---
|
||||
p = sub.add_parser("c5-delete-approve",
|
||||
help="Persistiert eine explizite Human-Approval fuer einen DELETE-ObjectChange (NUR Approval, KEIN DELETE)")
|
||||
p.add_argument("commit", help="Commit-SHA")
|
||||
p.add_argument("--object-id", default=None, help="Optional: Objekt-ID zur Bestaetigung")
|
||||
p.add_argument("--path", default=None, help="Optional: Vault-Pfad zur Bestaetigung")
|
||||
p.add_argument("--approved-by", default="human:christian", help="Human-Gate-Provenance")
|
||||
p.set_defaults(func=cmd_c5_delete_approve)
|
||||
|
||||
p = sub.add_parser("c5-delete-execute",
|
||||
help="Fuehrt den kontrollierten Tolaria-DELETE aus (verlangt persistierte Approval)")
|
||||
p.add_argument("commit", help="Commit-SHA")
|
||||
p.set_defaults(func=cmd_c5_delete_execute)
|
||||
|
||||
p = sub.add_parser("c5-delete-status", help="Read-only Status: Commit + Approvals")
|
||||
p.add_argument("commit", help="Commit-SHA")
|
||||
p.set_defaults(func=cmd_c5_delete_status)
|
||||
|
||||
p = sub.add_parser("c5-delete-replay",
|
||||
help="Idempotenter Replay nach Crash (verlangt persistierte Approval)")
|
||||
p.add_argument("commit", help="Commit-SHA")
|
||||
p.set_defaults(func=cmd_c5_delete_replay)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
return args.func(args)
|
||||
|
||||
|
|
|
|||
399
tolaria/c5-sync-service/rq_c5_delete.py
Normal file
399
tolaria/c5-sync-service/rq_c5_delete.py
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Red Queen — C5 DELETE EXECUTION ENGINE (DeleteExecutor).
|
||||
|
||||
Schliesst den DELETE_EXECUTION_CONTRACT_GAP: kontrollierter Tolaria-DELETE-Pfad
|
||||
NACH einer expliziten, persistenten Human-Approval.
|
||||
|
||||
ZIELARCHITEKTUR (verbindlich, C5_DELETE_EXECUTION_ARCHITECTURE_DECISION.md):
|
||||
DELETE erkannt (C5B) -> HUMAN_REVIEW_REQUIRED (C5A/C5C, RC_AMBIGUOUS_DELETE)
|
||||
-> explizite Human Approval (CLI approve-delete, persistiert in C5AStore)
|
||||
-> kontrollierter Tolaria DELETE (dieser Executor)
|
||||
-> Read-Back-Verifikation (target absent + kein anderes Objekt veraendert)
|
||||
-> C5D Source Build -> Search Update -> Exact-Set-Verifikation -> APPLIED
|
||||
|
||||
INVARIANTEN (A-N, verbindlich):
|
||||
A) DELETE bleibt HUMAN_GATED.
|
||||
B) Kein LLM/Agent entscheidet, dass ein DELETE genehmigt ist.
|
||||
C) Approval stammt explizit vom Human Gate (CLI approve-delete).
|
||||
D) Approval ist commit- und objectchange-spezifisch (keine globale
|
||||
DELETE_APPROVED=true-Semantik).
|
||||
E) Approval fuer Objekt A autorisiert nie Objekt B.
|
||||
F) Approval fuer Commit X autorisiert nie DELETE aus Commit Y.
|
||||
G) Approval ist persistent und crash-/restart-fest (SQLite in C5AStore).
|
||||
H) Execution ist idempotent (DELETE_ALREADY_AT_TARGET statt zweitem Write).
|
||||
I) DELETE betrifft ausschliesslich das exakt freigegebene Objekt.
|
||||
J) Kein Pfad nimmt beliebige freie Paths/IDs ohne ObjectChange + Approval.
|
||||
K) Tolaria DELETE wird vor und nach Execution eindeutig verifiziert.
|
||||
L) Keine manuelle SQLite-/SQL-State-Manipulation (nur Store-Methoden).
|
||||
M) Keine direkte APPLIED-Markierung (nur C5D nach vollem Search-PASS).
|
||||
N) Keine automatische Human-Gate-Deaktivierung.
|
||||
|
||||
NO-MUTATING-PROBE-REGEL (Incident-Lesson): Dieser Executor fuehrt KEINE
|
||||
produktive Endpoint-Probe aus. Der Endpoint-/Payload-Contract (POST /delete
|
||||
mit {"path": ...}) ist aus vorhandenem Tolaria-Code/Router und dokumentierter
|
||||
Incident-Evidence bestimmt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from rq_c5a import (
|
||||
C5AStore,
|
||||
ST_HUMAN_REVIEW_REQUIRED,
|
||||
ST_DELETE_APPROVED,
|
||||
ST_DELETING,
|
||||
ST_UPDATING_SEARCH,
|
||||
RC_DELETE_APPROVAL_MISSING,
|
||||
RC_DELETE_APPROVAL_MISMATCH,
|
||||
RC_UNEXPECTED_TOLARIA_DRIFT,
|
||||
OP_DELETE_REQUEST,
|
||||
)
|
||||
from rq_c5c import (
|
||||
TolariaClient,
|
||||
C5CError,
|
||||
TolariaWriteError,
|
||||
TolariaUnavailableError,
|
||||
VAULT_PREFIX,
|
||||
)
|
||||
|
||||
# Idempotenz-Ergebnis: Zielobjekt ist bereits korrekt absent -> kein zweiter
|
||||
# destruktiver Write (Invariante H).
|
||||
DELETE_ALREADY_AT_TARGET = "DELETE_ALREADY_AT_TARGET"
|
||||
|
||||
|
||||
class DeleteExecutionError(C5CError):
|
||||
"""Fehler im C5 DELETE-Execution-Pfad (FAIL CLOSED)."""
|
||||
|
||||
|
||||
class DeleteApprovalError(DeleteExecutionError):
|
||||
"""Approval fehlt oder passt nicht exakt (Invarianten D/E/F)."""
|
||||
|
||||
|
||||
class DeleteExecutor:
|
||||
"""
|
||||
Fuehrt einen kontrollierten Tolaria-DELETE fuer einen ObjectChange aus,
|
||||
der eine persistierte, exakt passende Human-Approval besitzt.
|
||||
|
||||
NUR ueber den CLI-Befehl `c5-delete execute` (bzw. `replay`) aufrufbar.
|
||||
Kein automatischer DELETE. Kein freier Pfad-Parameter.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: C5AStore,
|
||||
client: Optional[TolariaClient] = None,
|
||||
):
|
||||
self.store = store
|
||||
self.client = client or TolariaClient()
|
||||
|
||||
# -- Pre-Delete-Gates (alle MÜSSEN passen, sonst FAIL CLOSED) -----------
|
||||
|
||||
def _find_delete_change(self, commit_sha: str) -> Dict[str, Any]:
|
||||
"""Findet den DELETE-ObjectChange eines Commits. Genau einer erwartet."""
|
||||
changes = self.store.list_object_changes(commit_sha)
|
||||
deletes = [c for c in changes if c.get("operation") == OP_DELETE_REQUEST]
|
||||
if len(deletes) != 1:
|
||||
raise DeleteApprovalError(
|
||||
f"Erwarte genau einen DELETE-ObjectChange in {commit_sha}, "
|
||||
f"gefunden: {len(deletes)}",
|
||||
RC_DELETE_APPROVAL_MISMATCH,
|
||||
)
|
||||
return deletes[0]
|
||||
|
||||
def _validate_approval(
|
||||
self, commit_sha: str, change: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Validiert, dass eine persistierte Approval exakt zu diesem ObjectChange
|
||||
passt (Invarianten D/E/F). Gibt die Approval zurueck oder wirft
|
||||
DeleteApprovalError (FAIL CLOSED).
|
||||
"""
|
||||
change_id = change.get("id")
|
||||
oid = change.get("object_id")
|
||||
path = change.get("path_before") or change.get("path_after")
|
||||
if change_id is None:
|
||||
raise DeleteApprovalError(
|
||||
f"DELETE-ObjectChange in {commit_sha} hat keine id",
|
||||
RC_DELETE_APPROVAL_MISMATCH,
|
||||
)
|
||||
|
||||
approval = self.store.get_delete_approval_for_change(commit_sha, change_id)
|
||||
if approval is None:
|
||||
raise DeleteApprovalError(
|
||||
f"Keine persistierte Human-Approval fuer ObjectChange {change_id} "
|
||||
f"in Commit {commit_sha}",
|
||||
RC_DELETE_APPROVAL_MISSING,
|
||||
)
|
||||
if approval.get("approval_status") != "APPROVED":
|
||||
raise DeleteApprovalError(
|
||||
f"Approval {approval.get('approval_id')} ist nicht APPROVED "
|
||||
f"(Status: {approval.get('approval_status')})",
|
||||
RC_DELETE_APPROVAL_MISMATCH,
|
||||
)
|
||||
# Invariante F: Approval bindet exakt diesen Commit.
|
||||
if approval.get("workflow_commit") != commit_sha:
|
||||
raise DeleteApprovalError(
|
||||
f"Approval {approval.get('approval_id')} bindet Commit "
|
||||
f"{approval.get('workflow_commit')}, nicht {commit_sha}",
|
||||
RC_DELETE_APPROVAL_MISMATCH,
|
||||
)
|
||||
# Invariante E: Approval bindet exakt dieses Objekt.
|
||||
if approval.get("object_id") != oid:
|
||||
raise DeleteApprovalError(
|
||||
f"Approval {approval.get('approval_id')} bindet Objekt "
|
||||
f"{approval.get('object_id')}, nicht {oid}",
|
||||
RC_DELETE_APPROVAL_MISMATCH,
|
||||
)
|
||||
# Invariante I: Approval bindet exakt diesen Pfad.
|
||||
if approval.get("path") != path:
|
||||
raise DeleteApprovalError(
|
||||
f"Approval {approval.get('approval_id')} bindet Pfad "
|
||||
f"{approval.get('path')}, nicht {path}",
|
||||
RC_DELETE_APPROVAL_MISMATCH,
|
||||
)
|
||||
return approval
|
||||
|
||||
def _pre_delete_drift_check(self, vault_path: str) -> None:
|
||||
"""
|
||||
Invariante K (vor): Das Zielobjekt MUSS in Tolaria noch existieren,
|
||||
sonst ist der erwartete aktuelle Zustand verletzt (FAIL CLOSED).
|
||||
"""
|
||||
current = self.client.read(vault_path)
|
||||
if current is None:
|
||||
# Objekt bereits absent -> kein blinder zweiter destruktiver Write.
|
||||
# Der Executor erkennt DELETE_ALREADY_AT_TARGET (Idempotenz, H).
|
||||
raise DeleteExecutionError(
|
||||
f"Zielobjekt {vault_path} ist bereits absent (DELETE_ALREADY_AT_TARGET)",
|
||||
RC_UNEXPECTED_TOLARIA_DRIFT,
|
||||
)
|
||||
|
||||
def _read_back_absent(self, vault_path: str) -> bool:
|
||||
"""Invariante K (nach): Read-Back -> target absent == TRUE."""
|
||||
return self.client.read(vault_path) is None
|
||||
|
||||
def _verify_no_other_change(self, before_paths: set, after_paths: set) -> None:
|
||||
"""
|
||||
Stellt sicher, dass ausschliesslich das freigegebene Objekt entfernt
|
||||
wurde (kein anderes Objekt veraendert). before/after sind die Pfad-Sets
|
||||
aus list().
|
||||
"""
|
||||
removed = before_paths - after_paths
|
||||
added = after_paths - before_paths
|
||||
if added:
|
||||
raise DeleteExecutionError(
|
||||
f"Unerwartete neue Objekte nach DELETE: {sorted(added)}",
|
||||
RC_UNEXPECTED_TOLARIA_DRIFT,
|
||||
)
|
||||
|
||||
# -- Execution ----------------------------------------------------------
|
||||
|
||||
def execute(self, commit_sha: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Fuehrt den kontrollierten DELETE fuer einen Commit aus.
|
||||
|
||||
Pre-Gates (alle MÜSSEN passen, sonst FAIL CLOSED):
|
||||
1. ObjectChange existiert, operation == DELETE
|
||||
2. Commit-Status == ST_DELETE_APPROVED
|
||||
3. Approval existiert, APPROVED, passt exakt (Commit/Objekt/Pfad)
|
||||
4. Pre-Delete-Drift-Check: Ziel existiert noch
|
||||
|
||||
Nach DELETE:
|
||||
- Read-Back: target absent == TRUE
|
||||
- kein anderes Objekt veraendert
|
||||
- Approval -> USED (einmalig)
|
||||
- Commit -> ST_UPDATING_SEARCH (C5D uebernimmt Search-Pfad)
|
||||
|
||||
Idempotenz (H): Wenn das Ziel bereits absent ist, wird KEIN zweiter
|
||||
destruktiver Write erzeugt; der Executor meldet DELETE_ALREADY_AT_TARGET
|
||||
und geht trotzdem zu ST_UPDATING_SEARCH (Zielzustand ist erreicht).
|
||||
"""
|
||||
# Gate 1: ObjectChange existiert + operation == DELETE
|
||||
change = self._find_delete_change(commit_sha)
|
||||
oid = change.get("object_id")
|
||||
path = change.get("path_before") or change.get("path_after")
|
||||
if not path:
|
||||
raise DeleteApprovalError(
|
||||
f"DELETE-ObjectChange {change.get('id')} hat keinen Pfad",
|
||||
RC_DELETE_APPROVAL_MISMATCH,
|
||||
)
|
||||
vault_path = self._normalize_vault_path(path)
|
||||
|
||||
# Gate 2: Commit-Status == ST_DELETE_APPROVED
|
||||
status = self.store.commit_status(commit_sha)
|
||||
if status != ST_DELETE_APPROVED:
|
||||
raise DeleteExecutionError(
|
||||
f"Commit {commit_sha} ist nicht DELETE_APPROVED (Status: {status})",
|
||||
RC_DELETE_APPROVAL_MISMATCH,
|
||||
)
|
||||
|
||||
# Gate 3: Approval existiert + passt exakt
|
||||
approval = self._validate_approval(commit_sha, change)
|
||||
|
||||
# Gate 4: Pre-Delete-Drift-Check (Ziel existiert noch)
|
||||
try:
|
||||
self._pre_delete_drift_check(vault_path)
|
||||
except DeleteExecutionError as e:
|
||||
if "DELETE_ALREADY_AT_TARGET" in str(e):
|
||||
# Idempotenz: Ziel bereits korrekt absent. Kein zweiter Write.
|
||||
self.store.mark_delete_approval_used(approval["approval_id"])
|
||||
self.store.transition_commit(commit_sha, ST_UPDATING_SEARCH)
|
||||
return {
|
||||
"commit_sha": commit_sha,
|
||||
"object_id": oid,
|
||||
"path": path,
|
||||
"status": ST_UPDATING_SEARCH,
|
||||
"idempotency": DELETE_ALREADY_AT_TARGET,
|
||||
"deleted": False,
|
||||
}
|
||||
raise
|
||||
|
||||
return self._do_delete(commit_sha, change, approval, vault_path, oid, path)
|
||||
|
||||
def _do_delete(
|
||||
self, commit_sha: str, change: Dict[str, Any], approval: Dict[str, Any],
|
||||
vault_path: str, oid: Optional[str], path: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Kern der DELETE-Ausfuehrung (von execute() und replay()/ST_DELETING genutzt).
|
||||
|
||||
Erwartet: Approval validiert, Ziel existiert noch, Commit in
|
||||
ST_DELETE_APPROVED oder ST_DELETING. Fuehrt den kontrollierten DELETE aus,
|
||||
Read-Back, kein anderes Objekt veraendert, Approval -> USED,
|
||||
Commit -> ST_UPDATING_SEARCH.
|
||||
"""
|
||||
# State: -> ST_DELETING (Crash-Window). Idempotent: wenn bereits
|
||||
# ST_DELETING (Replay nach Crash), keine erneute Transition noetig.
|
||||
if self.store.commit_status(commit_sha) != ST_DELETING:
|
||||
self.store.transition_commit(commit_sha, ST_DELETING)
|
||||
|
||||
# Vorheriges Pfad-Set (fuer "kein anderes Objekt veraendert")
|
||||
before_paths = {o.get("path") for o in self.client.list(VAULT_PREFIX)}
|
||||
|
||||
# Execution: kontrollierter Tolaria DELETE
|
||||
try:
|
||||
self.client.delete(vault_path)
|
||||
except (TolariaWriteError, TolariaUnavailableError) as e:
|
||||
# Fehler waehrend Execution -> zurueck zum Human Gate (FAIL CLOSED)
|
||||
self.store.set_commit_error(commit_sha, e.reason_code or RC_UNEXPECTED_TOLARIA_DRIFT, e.message)
|
||||
self.store.transition_commit(commit_sha, ST_HUMAN_REVIEW_REQUIRED)
|
||||
raise DeleteExecutionError(
|
||||
f"Tolaria DELETE fehlgeschlagen fuer {vault_path}: {e.message}",
|
||||
e.reason_code,
|
||||
)
|
||||
|
||||
# Read-Back: target absent == TRUE (Invariante K nach)
|
||||
if not self._read_back_absent(vault_path):
|
||||
self.store.set_commit_error(
|
||||
commit_sha, RC_UNEXPECTED_TOLARIA_DRIFT,
|
||||
f"Read-Back nach DELETE: {vault_path} ist nicht absent")
|
||||
self.store.transition_commit(commit_sha, ST_HUMAN_REVIEW_REQUIRED)
|
||||
raise DeleteExecutionError(
|
||||
f"Read-Back nach DELETE: {vault_path} ist nicht absent",
|
||||
RC_UNEXPECTED_TOLARIA_DRIFT,
|
||||
)
|
||||
|
||||
# Kein anderes Objekt veraendert (FAIL CLOSED bei Drift)
|
||||
after_paths = {o.get("path") for o in self.client.list(VAULT_PREFIX)}
|
||||
try:
|
||||
self._verify_no_other_change(before_paths, after_paths)
|
||||
except DeleteExecutionError as e:
|
||||
# Drift nach DELETE -> zurueck zum Human Gate (FAIL CLOSED),
|
||||
# NICHT in ST_DELETING haengen lassen.
|
||||
self.store.set_commit_error(
|
||||
commit_sha, e.reason_code or RC_UNEXPECTED_TOLARIA_DRIFT, e.message)
|
||||
self.store.transition_commit(commit_sha, ST_HUMAN_REVIEW_REQUIRED)
|
||||
raise
|
||||
|
||||
# Approval -> USED (einmalig, verhindert Reuse)
|
||||
self.store.mark_delete_approval_used(approval["approval_id"])
|
||||
|
||||
# Commit -> ST_UPDATING_SEARCH (C5D uebernimmt Search-Pfad)
|
||||
self.store.transition_commit(commit_sha, ST_UPDATING_SEARCH)
|
||||
|
||||
return {
|
||||
"commit_sha": commit_sha,
|
||||
"object_id": oid,
|
||||
"path": path,
|
||||
"status": ST_UPDATING_SEARCH,
|
||||
"idempotency": "EXECUTED",
|
||||
"deleted": True,
|
||||
}
|
||||
|
||||
def replay(self, commit_sha: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Idempotenter Replay nach Crash (Crash-Faelle B/C/D).
|
||||
|
||||
- Commit in ST_DELETE_APPROVED: Approval persistiert, DELETE noch nicht
|
||||
ausgefuehrt -> normaler execute().
|
||||
- Commit in ST_DELETING: DELETE evtl. schon ausgefuehrt -> Read-Back.
|
||||
Objekt absent -> DELETE_ALREADY_AT_TARGET, weiter zu ST_UPDATING_SEARCH.
|
||||
Objekt noch da -> DELETE erneut (Approval gueltig).
|
||||
- Commit in ST_UPDATING_SEARCH: bereits nach DELETE -> nichts zu tun.
|
||||
"""
|
||||
status = self.store.commit_status(commit_sha)
|
||||
if status == ST_UPDATING_SEARCH:
|
||||
return {
|
||||
"commit_sha": commit_sha,
|
||||
"status": ST_UPDATING_SEARCH,
|
||||
"idempotency": "ALREADY_AT_TARGET",
|
||||
"deleted": False,
|
||||
}
|
||||
if status == ST_DELETING:
|
||||
# Crash waehrend DELETE (Fall C/D): Read-Back entscheidet.
|
||||
change = self._find_delete_change(commit_sha)
|
||||
path = change.get("path_before") or change.get("path_after")
|
||||
if not path:
|
||||
raise DeleteApprovalError(
|
||||
f"DELETE-ObjectChange {change.get('id')} hat keinen Pfad",
|
||||
RC_DELETE_APPROVAL_MISMATCH,
|
||||
)
|
||||
vault_path = self._normalize_vault_path(path)
|
||||
if self._read_back_absent(vault_path):
|
||||
# DELETE war bereits erfolgreich -> Zielzustand erreicht.
|
||||
change_id = change.get("id")
|
||||
approval = None
|
||||
if change_id is not None:
|
||||
approval = self.store.get_delete_approval_for_change(
|
||||
commit_sha, change_id)
|
||||
if approval and approval.get("approval_status") == "APPROVED":
|
||||
self.store.mark_delete_approval_used(approval["approval_id"])
|
||||
self.store.transition_commit(commit_sha, ST_UPDATING_SEARCH)
|
||||
return {
|
||||
"commit_sha": commit_sha,
|
||||
"object_id": change.get("object_id"),
|
||||
"path": path,
|
||||
"status": ST_UPDATING_SEARCH,
|
||||
"idempotency": DELETE_ALREADY_AT_TARGET,
|
||||
"deleted": False,
|
||||
}
|
||||
# Objekt noch da -> DELETE erneut. Approval validieren + _do_delete.
|
||||
approval = self._validate_approval(commit_sha, change)
|
||||
return self._do_delete(
|
||||
commit_sha, change, approval, vault_path,
|
||||
change.get("object_id"), path)
|
||||
# ST_DELETE_APPROVED (Fall B) oder sonstiger Zustand -> normaler execute
|
||||
return self.execute(commit_sha)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_vault_path(path: str) -> str:
|
||||
"""
|
||||
Normalisiert einen Vault-Pfad unter VAULT_PREFIX (kein Traversal).
|
||||
|
||||
SECURITY (Todo 11): Blockiert `..`-Traversal-Segmente und absolute
|
||||
Pfade ausserhalb des Vaults. Der Pfad stammt zwar aus dem ObjectChange
|
||||
(nicht frei), aber Defense-in-Depth verhindert, dass ein manipulierter
|
||||
ObjectChange-Pfad ein beliebiges Dateisystem-Ziel loeschen kann.
|
||||
"""
|
||||
p = path.replace("\\", "/").lstrip("/")
|
||||
# Traversal-Segmente hart blockieren (FAIL CLOSED).
|
||||
segments = [s for s in p.split("/") if s not in ("", ".")]
|
||||
if any(s == ".." for s in segments):
|
||||
raise DeleteExecutionError(
|
||||
f"Pfad-Traversal blockiert: {path!r}",
|
||||
RC_DELETE_APPROVAL_MISMATCH,
|
||||
)
|
||||
if p.startswith(VAULT_PREFIX.lstrip("/")):
|
||||
return "/" + p
|
||||
return f"{VAULT_PREFIX}/{p}"
|
||||
|
|
@ -68,6 +68,10 @@ ST_HUMAN_REVIEW_REQUIRED = "HUMAN_REVIEW_REQUIRED"
|
|||
# Ordering-Zustand
|
||||
ST_WAITING_FOR_PREDECESSOR = "WAITING_FOR_PREDECESSOR"
|
||||
|
||||
# C5 DELETE-Execution-Zustände (nur via expliziter Human-Approval erreichbar)
|
||||
ST_DELETE_APPROVED = "DELETE_APPROVED" # nach persistierter Approval, vor Execution
|
||||
ST_DELETING = "DELETING" # während Execution (Crash-Window)
|
||||
|
||||
# Bootstrap-Zustände
|
||||
BS_UNINITIALIZED = "UNINITIALIZED"
|
||||
BS_RECONCILING = "RECONCILING"
|
||||
|
|
@ -124,6 +128,13 @@ RC_INTEGRITY_FAILURE = "INTEGRITY_FAILURE" # Search-Health/Integrity nicht P
|
|||
# Source stattfinden; FAIL CLOSED vor APPLIED.
|
||||
RC_SEARCH_SOURCE_BUILD_FAILURE = "SEARCH_SOURCE_BUILD_FAILURE"
|
||||
|
||||
# C5 DELETE-Execution: Approval-/Gate-Fehler (nicht retrybar, FAIL CLOSED).
|
||||
# RC_DELETE_APPROVAL_MISSING: kein persistierter Human-Approval fuer den ObjectChange.
|
||||
# RC_DELETE_APPROVAL_MISMATCH: Approval existiert, passt aber nicht exakt
|
||||
# (falscher Commit / falsches Objekt / falscher Pfad / stale / reused).
|
||||
RC_DELETE_APPROVAL_MISSING = "DELETE_APPROVAL_MISSING"
|
||||
RC_DELETE_APPROVAL_MISMATCH = "DELETE_APPROVAL_MISMATCH"
|
||||
|
||||
# Retry / Backoff
|
||||
DEFAULT_MAX_RETRIES = 5
|
||||
DEFAULT_BACKOFF_SECONDS = [1, 2, 4, 8, 16]
|
||||
|
|
@ -155,6 +166,9 @@ REASON_CODES = frozenset({
|
|||
RC_INTEGRITY_FAILURE,
|
||||
# C5D: Search-Source-Build/Verification
|
||||
RC_SEARCH_SOURCE_BUILD_FAILURE,
|
||||
# C5 DELETE-Execution: Approval-/Gate-Fehler
|
||||
RC_DELETE_APPROVAL_MISSING,
|
||||
RC_DELETE_APPROVAL_MISMATCH,
|
||||
})
|
||||
|
||||
# Alle Operationen als frozenset
|
||||
|
|
@ -178,6 +192,7 @@ SYNC_STATES = frozenset({
|
|||
ST_UPDATING_SEARCH, ST_VERIFYING_SEARCH, ST_APPLIED,
|
||||
ST_RETRY_PENDING, ST_FAILED, ST_DEAD, ST_HUMAN_REVIEW_REQUIRED,
|
||||
ST_WAITING_FOR_PREDECESSOR,
|
||||
ST_DELETE_APPROVED, ST_DELETING,
|
||||
})
|
||||
|
||||
# Bootstrap-Zustände
|
||||
|
|
@ -222,6 +237,13 @@ _ALLOWED_TRANSITIONS = {
|
|||
(ST_HUMAN_REVIEW_REQUIRED, ST_READY), # Human entscheidet -> erneut
|
||||
(ST_HUMAN_REVIEW_REQUIRED, ST_APPLIED), # Human bestätigt als angewendet
|
||||
(ST_HUMAN_REVIEW_REQUIRED, ST_DEAD), # Human verwirft
|
||||
# C5 DELETE-Execution (NUR via expliziter Human-Approval / DeleteExecutor):
|
||||
(ST_HUMAN_REVIEW_REQUIRED, ST_DELETE_APPROVED), # explizite Approval persistiert
|
||||
(ST_DELETE_APPROVED, ST_DELETING), # Executor startet
|
||||
(ST_DELETE_APPROVED, ST_HUMAN_REVIEW_REQUIRED), # Revoke/Fehler vor Execution
|
||||
(ST_DELETE_APPROVED, ST_UPDATING_SEARCH), # Idempotenz: Ziel bereits absent
|
||||
(ST_DELETING, ST_UPDATING_SEARCH), # DELETE + Read-Back OK -> C5D
|
||||
(ST_DELETING, ST_HUMAN_REVIEW_REQUIRED), # Fehler während Execution
|
||||
}
|
||||
|
||||
# Bootstrap-Übergänge
|
||||
|
|
@ -340,6 +362,26 @@ class C5AStore:
|
|||
updated_at INTEGER
|
||||
)
|
||||
""")
|
||||
# C5 DELETE-Execution: persistente, auditierbare Human-Approval.
|
||||
# Keine Credentials/personenbezogenen Daten. Eindeutig gebunden an
|
||||
# (workflow_commit, object_change_id, object_id, path) — keine globale
|
||||
# DELETE_APPROVED=true-Semantik (Invarianten D/E/F).
|
||||
self._conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS delete_approvals (
|
||||
approval_id TEXT PRIMARY KEY,
|
||||
workflow_commit TEXT NOT NULL,
|
||||
object_change_id INTEGER NOT NULL,
|
||||
object_id TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
operation TEXT NOT NULL DEFAULT 'DELETE',
|
||||
approval_status TEXT NOT NULL,
|
||||
approved_by TEXT NOT NULL,
|
||||
approval_nonce TEXT NOT NULL,
|
||||
approved_at INTEGER NOT NULL,
|
||||
used_at INTEGER,
|
||||
UNIQUE(workflow_commit, object_change_id, object_id, path)
|
||||
)
|
||||
""")
|
||||
# Schema-Version setzen
|
||||
self._conn.execute(
|
||||
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)",
|
||||
|
|
@ -655,6 +697,98 @@ class C5AStore:
|
|||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
# -- C5 DELETE-Execution: persistente Human-Approval ---------------------
|
||||
|
||||
def create_delete_approval(
|
||||
self, workflow_commit: str, object_change_id: int, object_id: str,
|
||||
path: str, approved_by: str, approval_id: Optional[str] = None,
|
||||
approval_nonce: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Persistiert eine explizite Human-Approval fuer einen DELETE-ObjectChange.
|
||||
|
||||
NUR durch den CLI-Befehl `approve-delete` (Human Gate) aufrufbar. Kein Code
|
||||
erzeugt Approval automatisch (Invariante B/C). Eindeutig gebunden an
|
||||
(workflow_commit, object_change_id, object_id, path) — keine globale
|
||||
DELETE_APPROVED=true-Semantik (Invarianten D/E/F).
|
||||
|
||||
approval_id/approval_nonce sind optional injizierbar (Tests); Defaults sind
|
||||
kryptographisch zufaellig. Keine Credentials/personenbezogenen Daten.
|
||||
"""
|
||||
import uuid
|
||||
aid = approval_id or str(uuid.uuid4())
|
||||
nonce = approval_nonce or str(uuid.uuid4())
|
||||
now = int(time.time() * 1000)
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO delete_approvals
|
||||
(approval_id, workflow_commit, object_change_id, object_id,
|
||||
path, operation, approval_status, approved_by, approval_nonce,
|
||||
approved_at, used_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'DELETE', 'APPROVED', ?, ?, ?, NULL)
|
||||
""",
|
||||
(aid, workflow_commit, object_change_id, object_id, path,
|
||||
approved_by, nonce, now),
|
||||
)
|
||||
return {
|
||||
"approval_id": aid,
|
||||
"workflow_commit": workflow_commit,
|
||||
"object_change_id": object_change_id,
|
||||
"object_id": object_id,
|
||||
"path": path,
|
||||
"operation": "DELETE",
|
||||
"approval_status": "APPROVED",
|
||||
"approved_by": approved_by,
|
||||
"approval_nonce": nonce,
|
||||
"approved_at": now,
|
||||
"used_at": None,
|
||||
}
|
||||
|
||||
def get_delete_approval(self, approval_id: str) -> Optional[Dict[str, Any]]:
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM delete_approvals WHERE approval_id = ?", (approval_id,)
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def get_delete_approval_for_change(
|
||||
self, workflow_commit: str, object_change_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Liest die Approval fuer einen exakten ObjectChange (oder None)."""
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM delete_approvals WHERE workflow_commit = ? AND object_change_id = ?",
|
||||
(workflow_commit, object_change_id),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def list_delete_approvals(self, workflow_commit: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
if workflow_commit:
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM delete_approvals WHERE workflow_commit = ? ORDER BY approved_at ASC",
|
||||
(workflow_commit,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM delete_approvals ORDER BY approved_at ASC"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def mark_delete_approval_used(self, approval_id: str) -> None:
|
||||
"""Markiert eine Approval als USED (einmalig, verhindert Reuse)."""
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"UPDATE delete_approvals SET approval_status = 'USED', used_at = ? WHERE approval_id = ?",
|
||||
(int(time.time() * 1000), approval_id),
|
||||
)
|
||||
|
||||
def revoke_delete_approval(self, approval_id: str) -> None:
|
||||
"""Revoked eine Approval (nur vor Nutzung; USED kann nicht revokiert werden)."""
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"UPDATE delete_approvals SET approval_status = 'REVOKED' WHERE approval_id = ? AND approval_status = 'APPROVED'",
|
||||
(approval_id,),
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._conn.close()
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ from rq_c5a import (
|
|||
ST_RETRY_PENDING,
|
||||
ST_DEAD,
|
||||
ST_APPLIED,
|
||||
ST_DELETE_APPROVED,
|
||||
ST_DELETING,
|
||||
IDEM_ALREADY_AT_TARGET,
|
||||
IDEM_ALREADY_APPLIED,
|
||||
IDEM_RETRY_SAFE,
|
||||
|
|
@ -275,6 +277,27 @@ class TolariaClient:
|
|||
f"Tolaria save fehlgeschlagen: {resp['error']}", RC_INVALID_SCHEMA)
|
||||
return resp
|
||||
|
||||
def delete(self, vault_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Loescht ein Vault-Objekt (POST /delete).
|
||||
|
||||
NUR durch den DeleteExecutor (C5 DELETE-Execution) aufrufbar, der zuvor
|
||||
eine persistierte, exakt passende Human-Approval validiert hat. Kein
|
||||
freier Pfad-Parameter; vault_path stammt ausschliesslich aus dem
|
||||
ObjectChange, das durch die Approval gebunden ist (Invariante I/J).
|
||||
|
||||
Endpoint-/Payload-Contract (POST /delete mit {"path": ...}) ist aus dem
|
||||
vorhandenen Tolaria-Code/Router und der dokumentierten Incident-Evidence
|
||||
bestimmt. KEINE produktive Endpoint-Probe (Incident-Regel).
|
||||
"""
|
||||
resp = self._post("delete", {"path": vault_path})
|
||||
if resp is None:
|
||||
resp = {}
|
||||
if "error" in resp:
|
||||
raise TolariaWriteError(
|
||||
f"Tolaria delete fehlgeschlagen: {resp['error']}", RC_INVALID_SCHEMA)
|
||||
return resp
|
||||
|
||||
# -- Verify (Read-Back + Hash) ------------------------------------------
|
||||
|
||||
def verify(self, vault_path: str, expected_content: str) -> Dict[str, Any]:
|
||||
|
|
|
|||
|
|
@ -489,6 +489,7 @@ def verify_integrity(health: Dict[str, Any],
|
|||
"index_built": bool(health.get("index_built")),
|
||||
"object_count_positive": int(health.get("object_count", 0)) > 0,
|
||||
"failed_objects_empty": not bool(health.get("failed_objects")),
|
||||
"secret_blocked_objects_zero": int(health.get("secret_blocked_objects", 0)) == 0,
|
||||
}
|
||||
if expected_object_ids is not None:
|
||||
indexed_ids = set(health.get("indexed_object_ids") or [])
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ from rq_c5a import (
|
|||
ST_VERIFYING_SEARCH,
|
||||
ST_VERIFYING_TOLARIA,
|
||||
ST_WAITING_FOR_PREDECESSOR,
|
||||
ST_DELETE_APPROVED,
|
||||
ST_DELETING,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -262,6 +264,7 @@ class C5EEngine:
|
|||
store: C5EStore,
|
||||
propagator: Optional[Any] = None,
|
||||
search_engine: Optional[Any] = None,
|
||||
delete_executor: Optional[Any] = None,
|
||||
max_retries: int = DEFAULT_MAX_RETRIES,
|
||||
backoff_seconds: Optional[List[int]] = None,
|
||||
allow_writes: bool = False,
|
||||
|
|
@ -269,6 +272,7 @@ class C5EEngine:
|
|||
self.store = store
|
||||
self.propagator = propagator # injizierter C5CPropagator (oder Fake)
|
||||
self.search_engine = search_engine # injizierte C5DEngine (oder Fake)
|
||||
self.delete_executor = delete_executor # injizierter DeleteExecutor (oder Fake)
|
||||
self.max_retries = max_retries
|
||||
self.backoff_seconds = backoff_seconds or DEFAULT_BACKOFF_SECONDS
|
||||
self.allow_writes = allow_writes
|
||||
|
|
@ -321,6 +325,15 @@ class C5EEngine:
|
|||
return {"commit_sha": commit_sha, "decision": REC_RESUME,
|
||||
"state": cur,
|
||||
"reason": f"Restart in {cur} -> ab korrektem Schritt fortsetzen"}
|
||||
# C5 DELETE-Execution: deterministischer Replay-Pfad (Crash-Faelle B/C/D).
|
||||
# ST_DELETE_APPROVED: Approval persistiert, DELETE noch nicht ausgefuehrt
|
||||
# -> Replay fuehrt DELETE aus (DeleteExecutor.replay).
|
||||
# ST_DELETING: DELETE evtl. schon ausgefuehrt -> Read-Back entscheidet
|
||||
# (DELETE_ALREADY_AT_TARGET vs. erneut ausfuehren).
|
||||
if cur in (ST_DELETE_APPROVED, ST_DELETING):
|
||||
return {"commit_sha": commit_sha, "decision": REC_RESUME,
|
||||
"state": cur,
|
||||
"reason": f"Restart in {cur} -> DeleteExecutor.replay (idempotent)"}
|
||||
return {"commit_sha": commit_sha, "decision": REC_HUMAN_REVIEW,
|
||||
"state": cur, "reason": f"kein deterministischer Recovery-Pfad ({cur})"}
|
||||
|
||||
|
|
@ -400,6 +413,19 @@ class C5EEngine:
|
|||
)
|
||||
)
|
||||
|
||||
# --- DELETE-Schritt (ST_DELETE_APPROVED / ST_DELETING) ---
|
||||
# Crash-Faelle B/C/D: Approval persistiert, DELETE evtl. schon ausgefuehrt.
|
||||
# DeleteExecutor.replay ist idempotent (DELETE_ALREADY_AT_TARGET).
|
||||
if cur in (ST_DELETE_APPROVED, ST_DELETING):
|
||||
if self.delete_executor is None:
|
||||
return {"commit_sha": commit_sha, "decision": decision["decision"],
|
||||
"status": ST_HUMAN_REVIEW_REQUIRED, "result": None,
|
||||
"reason": "DeleteExecutor nicht injiziert (kein produktiver DELETE)"}
|
||||
result = self.delete_executor.replay(commit_sha)
|
||||
return {"commit_sha": commit_sha, "decision": decision["decision"],
|
||||
"status": result.get("status"), "result": result,
|
||||
"reason": "DELETE-Schritt fortgesetzt (idempotent)"}
|
||||
|
||||
# --- Suchschritt (UPDATING_SEARCH / VERIFYING_SEARCH / Search-Retry) ---
|
||||
if cur in (ST_UPDATING_SEARCH, ST_VERIFYING_SEARCH) or is_search_retry:
|
||||
if self.search_engine is None:
|
||||
|
|
|
|||
479
tolaria/c5-sync-service/test_c5_delete.py
Normal file
479
tolaria/c5-sync-service/test_c5_delete.py
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Red Queen — C5 DELETE-Execution Testsuite (Human-Gated).
|
||||
|
||||
Testet den kontrollierten Tolaria-DELETE-Pfad NACH expliziter Human-Approval
|
||||
gegen isolierte Fakes (FakeTolaria + FakeTolariaClient + temp-DB). KEINE
|
||||
produktiven Writes. KEINE produktiven Endpoint-Probes.
|
||||
|
||||
Abgedeckte Faelle (Mission §8 Testplan):
|
||||
DELETE ohne Human Approval -> BLOCKED
|
||||
DELETE mit Approval fuer anderes Objekt -> BLOCKED
|
||||
DELETE mit Approval fuer anderen Commit -> BLOCKED
|
||||
manipulierte object_id -> BLOCKED
|
||||
manipulierter path -> BLOCKED
|
||||
gueltiges Approval -> exakt ein DELETE
|
||||
anderes Objekt bleibt unveraendert
|
||||
DELETE_ALREADY_AT_TARGET -> kein zweiter destruktiver Write
|
||||
Restart nach Approval -> Approval erhalten
|
||||
Crash nach DELETE -> Replay sicher
|
||||
Search Exact-Set nach DELETE (via C5D-Pfad)
|
||||
Search-Mismatch -> kein APPLIED
|
||||
Secret Safety
|
||||
keine SQL-/State-Bypaesse
|
||||
HUMAN_REVIEW_REQUIRED bleibt Default fuer neue DELETE requests
|
||||
keine automatische Approval-Erzeugung
|
||||
Multi-Commit-Test: Approval A darf DELETE B niemals autorisieren
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from rq_c5a import (
|
||||
C5AStore, ST_READY, ST_HUMAN_REVIEW_REQUIRED, ST_DELETE_APPROVED,
|
||||
ST_DELETING, ST_UPDATING_SEARCH, ST_APPLIED,
|
||||
RC_DELETE_APPROVAL_MISSING, RC_DELETE_APPROVAL_MISMATCH,
|
||||
RC_UNEXPECTED_TOLARIA_DRIFT,
|
||||
OP_DELETE_REQUEST, OP_CREATE,
|
||||
)
|
||||
from rq_c5c import TolariaClient, TolariaUnavailableError, RC_TOLARIA_UNAVAILABLE
|
||||
from rq_c5_delete import (
|
||||
DeleteExecutor, DeleteApprovalError, DeleteExecutionError,
|
||||
DELETE_ALREADY_AT_TARGET,
|
||||
)
|
||||
|
||||
UUID_A = "object/77b02661-d67b-4bae-b612-01d1287cea6b"
|
||||
UUID_B = "object/48bd264f-607b-15f1-5f73-3e922af9b19d"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Isolierte Fakes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class FakeTolaria:
|
||||
"""In-Memory Fake der Tolaria-Vault-API (read/write/delete/list)."""
|
||||
|
||||
def __init__(self):
|
||||
self.vault: Dict[str, str] = {}
|
||||
self.write_count = 0
|
||||
self.delete_count = 0
|
||||
self.fail_next_delete = False
|
||||
self.unavailable = False
|
||||
|
||||
def read(self, vault_path: str) -> Optional[str]:
|
||||
if self.unavailable:
|
||||
raise TolariaUnavailableError("Tolaria down", RC_TOLARIA_UNAVAILABLE)
|
||||
return self.vault.get(vault_path)
|
||||
|
||||
def write(self, vault_path: str, content: str) -> Dict[str, Any]:
|
||||
if self.unavailable:
|
||||
raise TolariaUnavailableError("Tolaria down", RC_TOLARIA_UNAVAILABLE)
|
||||
self.vault[vault_path] = content
|
||||
self.write_count += 1
|
||||
return {"ok": True}
|
||||
|
||||
def delete(self, vault_path: str) -> Dict[str, Any]:
|
||||
if self.unavailable:
|
||||
raise TolariaUnavailableError("Tolaria down", RC_TOLARIA_UNAVAILABLE)
|
||||
if self.fail_next_delete:
|
||||
self.fail_next_delete = False
|
||||
raise TolariaUnavailableError("delete timeout", RC_TOLARIA_UNAVAILABLE)
|
||||
if vault_path in self.vault:
|
||||
del self.vault[vault_path]
|
||||
self.delete_count += 1
|
||||
return {"ok": True}
|
||||
|
||||
def list(self, vault_path: str = "/app/vault") -> List[Dict[str, Any]]:
|
||||
return [{"path": p} for p in self.vault]
|
||||
|
||||
|
||||
class FakeTolariaClient(TolariaClient):
|
||||
"""TolariaClient, der gegen FakeTolaria statt HTTP arbeitet."""
|
||||
|
||||
def __init__(self, fake: FakeTolaria):
|
||||
super().__init__(base_url="http://fake")
|
||||
self.fake = fake
|
||||
|
||||
def read(self, vault_path: str) -> Optional[str]:
|
||||
return self.fake.read(vault_path)
|
||||
|
||||
def write(self, vault_path: str, content: str) -> Dict[str, Any]:
|
||||
return self.fake.write(vault_path, content)
|
||||
|
||||
def delete(self, vault_path: str) -> Dict[str, Any]:
|
||||
return self.fake.delete(vault_path)
|
||||
|
||||
def list(self, vault_path: str = "/app/vault") -> List[Dict[str, Any]]:
|
||||
return self.fake.list(vault_path)
|
||||
|
||||
|
||||
def _make_store() -> C5AStore:
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="c5del_db_"), "c5a.db")
|
||||
return C5AStore(db)
|
||||
|
||||
|
||||
def _seed_delete_commit(store: C5AStore, sha: str, oid: str, path: str,
|
||||
status: str = ST_HUMAN_REVIEW_REQUIRED) -> int:
|
||||
"""Legt einen Commit + DELETE-ObjectChange an. Gibt die ObjectChange-id zurueck."""
|
||||
store.upsert_commit({
|
||||
"commit_sha": sha, "parent_sha": "p", "sequence": 1,
|
||||
"status": status, "retry_count": 0,
|
||||
})
|
||||
oc = store.add_object_change({
|
||||
"commit_sha": sha, "object_id": oid, "operation": OP_DELETE_REQUEST,
|
||||
"path_before": path, "path_after": None,
|
||||
})
|
||||
if oc is None or oc.get("id") is None:
|
||||
raise RuntimeError("add_object_change lieferte keine id")
|
||||
return oc["id"]
|
||||
|
||||
|
||||
def _approve(store: C5AStore, sha: str, change_id: int, oid: str, path: str,
|
||||
approved_by: str = "human:christian") -> Dict[str, Any]:
|
||||
"""Persistiert eine Approval + setzt Commit auf ST_DELETE_APPROVED."""
|
||||
approval = store.create_delete_approval(
|
||||
workflow_commit=sha, object_change_id=change_id,
|
||||
object_id=oid, path=path, approved_by=approved_by,
|
||||
)
|
||||
store.transition_commit(sha, ST_DELETE_APPROVED)
|
||||
return approval
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDeleteWithoutApproval(unittest.TestCase):
|
||||
"""DELETE ohne Human Approval -> BLOCKED (Invariante A/B/C)."""
|
||||
|
||||
def setUp(self):
|
||||
self.fake = FakeTolaria()
|
||||
self.client = FakeTolariaClient(self.fake)
|
||||
self.store = _make_store()
|
||||
self.fake.vault["/app/vault/x.md"] = "content"
|
||||
self.change_id = _seed_delete_commit(
|
||||
self.store, "c1", UUID_A, "x.md", status=ST_HUMAN_REVIEW_REQUIRED)
|
||||
self.executor = DeleteExecutor(self.store, self.client)
|
||||
|
||||
def tearDown(self):
|
||||
self.store.close()
|
||||
|
||||
def test_no_approval_blocked(self):
|
||||
# Commit ist HUMAN_REVIEW_REQUIRED, keine Approval -> execute muss fail closed
|
||||
with self.assertRaises(DeleteExecutionError):
|
||||
self.executor.execute("c1")
|
||||
self.assertEqual(self.fake.delete_count, 0)
|
||||
self.assertEqual(self.fake.vault["/app/vault/x.md"], "content")
|
||||
|
||||
def test_commit_not_approved_state_blocked(self):
|
||||
# Commit in HUMAN_REVIEW_REQUIRED (nicht DELETE_APPROVED) -> BLOCKED
|
||||
with self.assertRaises(DeleteExecutionError) as ctx:
|
||||
self.executor.execute("c1")
|
||||
self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISMATCH)
|
||||
self.assertEqual(self.fake.delete_count, 0)
|
||||
|
||||
|
||||
class TestApprovalMismatch(unittest.TestCase):
|
||||
"""Approval passt nicht exakt -> BLOCKED (Invarianten D/E/F/I)."""
|
||||
|
||||
def setUp(self):
|
||||
self.fake = FakeTolaria()
|
||||
self.client = FakeTolariaClient(self.fake)
|
||||
self.store = _make_store()
|
||||
self.fake.vault["/app/vault/x.md"] = "content"
|
||||
self.change_id = _seed_delete_commit(
|
||||
self.store, "c1", UUID_A, "x.md", status=ST_HUMAN_REVIEW_REQUIRED)
|
||||
self.executor = DeleteExecutor(self.store, self.client)
|
||||
|
||||
def tearDown(self):
|
||||
self.store.close()
|
||||
|
||||
def _approve_and_execute(self, **kwargs):
|
||||
"""Approval mit ueberschreibbaren Feldern + execute."""
|
||||
_approve(self.store, "c1", self.change_id, UUID_A, "x.md")
|
||||
return self.executor.execute("c1")
|
||||
|
||||
def test_approval_for_other_object_blocked(self):
|
||||
# Approval fuer Objekt B, ObjectChange ist Objekt A -> BLOCKED (E)
|
||||
_approve(self.store, "c1", self.change_id, UUID_B, "x.md")
|
||||
with self.assertRaises(DeleteApprovalError) as ctx:
|
||||
self.executor.execute("c1")
|
||||
self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISMATCH)
|
||||
self.assertEqual(self.fake.delete_count, 0)
|
||||
|
||||
def test_approval_for_other_commit_blocked(self):
|
||||
# Approval fuer Commit c2, ObjectChange in c1 -> BLOCKED (F)
|
||||
# c1 auf DELETE_APPROVED setzen (Gate 2 passiert), Approval fuer c2 anlegen.
|
||||
# Die Approval ist commit-spezifisch (workflow_commit=c2) gebunden, daher
|
||||
# findet der Executor fuer c1 KEINE Approval -> MISSING (strengste Form von F).
|
||||
self.store.upsert_commit({
|
||||
"commit_sha": "c2", "parent_sha": "p", "sequence": 2,
|
||||
"status": ST_HUMAN_REVIEW_REQUIRED, "retry_count": 0,
|
||||
})
|
||||
_approve(self.store, "c2", self.change_id, UUID_A, "x.md")
|
||||
self.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
with self.assertRaises(DeleteApprovalError) as ctx:
|
||||
self.executor.execute("c1")
|
||||
self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISSING)
|
||||
self.assertEqual(self.fake.delete_count, 0)
|
||||
|
||||
def test_manipulated_object_id_blocked(self):
|
||||
# Approval fuer UUID_A, aber ObjectChange manipuliert zu UUID_B -> BLOCKED
|
||||
_approve(self.store, "c1", self.change_id, UUID_A, "x.md")
|
||||
# Manipuliere den ObjectChange direkt im Store (nur Test-Setup, kein Bypass)
|
||||
self.store._conn.execute(
|
||||
"UPDATE objects SET object_id = ? WHERE id = ?", (UUID_B, self.change_id))
|
||||
with self.assertRaises(DeleteApprovalError) as ctx:
|
||||
self.executor.execute("c1")
|
||||
self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISMATCH)
|
||||
self.assertEqual(self.fake.delete_count, 0)
|
||||
|
||||
def test_manipulated_path_blocked(self):
|
||||
# Approval fuer x.md, aber ObjectChange-Pfad manipuliert zu y.md -> BLOCKED
|
||||
_approve(self.store, "c1", self.change_id, UUID_A, "x.md")
|
||||
self.store._conn.execute(
|
||||
"UPDATE objects SET path_before = ? WHERE id = ?", ("y.md", self.change_id))
|
||||
with self.assertRaises(DeleteApprovalError) as ctx:
|
||||
self.executor.execute("c1")
|
||||
self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISMATCH)
|
||||
self.assertEqual(self.fake.delete_count, 0)
|
||||
|
||||
def test_stale_approval_blocked(self):
|
||||
# Approval bereits USED -> BLOCKED (kein Reuse)
|
||||
_approve(self.store, "c1", self.change_id, UUID_A, "x.md")
|
||||
appr = self.store.get_delete_approval_for_change("c1", self.change_id)
|
||||
self.assertIsNotNone(appr)
|
||||
self.store.mark_delete_approval_used(appr["approval_id"])
|
||||
with self.assertRaises(DeleteApprovalError) as ctx:
|
||||
self.executor.execute("c1")
|
||||
self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISMATCH)
|
||||
self.assertEqual(self.fake.delete_count, 0)
|
||||
|
||||
|
||||
class TestValidDelete(unittest.TestCase):
|
||||
"""Gueltiges Approval -> exakt ein DELETE, anderes Objekt unveraendert."""
|
||||
|
||||
def setUp(self):
|
||||
self.fake = FakeTolaria()
|
||||
self.client = FakeTolariaClient(self.fake)
|
||||
self.store = _make_store()
|
||||
self.fake.vault["/app/vault/x.md"] = "content x"
|
||||
self.fake.vault["/app/vault/y.md"] = "content y"
|
||||
self.change_id = _seed_delete_commit(
|
||||
self.store, "c1", UUID_A, "x.md", status=ST_HUMAN_REVIEW_REQUIRED)
|
||||
self.executor = DeleteExecutor(self.store, self.client)
|
||||
|
||||
def tearDown(self):
|
||||
self.store.close()
|
||||
|
||||
def test_valid_approval_exactly_one_delete(self):
|
||||
_approve(self.store, "c1", self.change_id, UUID_A, "x.md")
|
||||
result = self.executor.execute("c1")
|
||||
self.assertEqual(result["deleted"], True)
|
||||
self.assertEqual(self.fake.delete_count, 1)
|
||||
# Ziel absent
|
||||
self.assertIsNone(self.fake.vault.get("/app/vault/x.md"))
|
||||
# Anderes Objekt unveraendert
|
||||
self.assertEqual(self.fake.vault["/app/vault/y.md"], "content y")
|
||||
# Commit -> UPDATING_SEARCH (C5D uebernimmt)
|
||||
self.assertEqual(self.store.commit_status("c1"), ST_UPDATING_SEARCH)
|
||||
# Approval -> USED
|
||||
approval = self.store.get_delete_approval_for_change("c1", self.change_id)
|
||||
self.assertIsNotNone(approval)
|
||||
self.assertEqual(approval["approval_status"], "USED")
|
||||
|
||||
def test_other_object_unchanged(self):
|
||||
_approve(self.store, "c1", self.change_id, UUID_A, "x.md")
|
||||
self.executor.execute("c1")
|
||||
self.assertEqual(self.fake.vault["/app/vault/y.md"], "content y")
|
||||
self.assertEqual(self.fake.write_count, 0) # kein Write, nur Delete
|
||||
|
||||
|
||||
class TestIdempotency(unittest.TestCase):
|
||||
"""DELETE_ALREADY_AT_TARGET -> kein zweiter destruktiver Write (H)."""
|
||||
|
||||
def setUp(self):
|
||||
self.fake = FakeTolaria()
|
||||
self.client = FakeTolariaClient(self.fake)
|
||||
self.store = _make_store()
|
||||
self.change_id = _seed_delete_commit(
|
||||
self.store, "c1", UUID_A, "x.md", status=ST_HUMAN_REVIEW_REQUIRED)
|
||||
self.executor = DeleteExecutor(self.store, self.client)
|
||||
|
||||
def tearDown(self):
|
||||
self.store.close()
|
||||
|
||||
def test_already_absent_no_second_delete(self):
|
||||
# Ziel ist bereits absent (z.B. nach Crash waehrend DELETE)
|
||||
_approve(self.store, "c1", self.change_id, UUID_A, "x.md")
|
||||
result = self.executor.execute("c1")
|
||||
self.assertEqual(result["idempotency"], DELETE_ALREADY_AT_TARGET)
|
||||
self.assertEqual(result["deleted"], False)
|
||||
self.assertEqual(self.fake.delete_count, 0) # kein zweiter destruktiver Write
|
||||
self.assertEqual(self.store.commit_status("c1"), ST_UPDATING_SEARCH)
|
||||
|
||||
def test_replay_after_crash_delete(self):
|
||||
# Crash nach DELETE, vor Read-Back: Commit in ST_DELETING, Objekt absent
|
||||
_approve(self.store, "c1", self.change_id, UUID_A, "x.md")
|
||||
self.store.transition_commit("c1", ST_DELETING)
|
||||
result = self.executor.replay("c1")
|
||||
self.assertEqual(result["idempotency"], DELETE_ALREADY_AT_TARGET)
|
||||
self.assertEqual(self.fake.delete_count, 0)
|
||||
self.assertEqual(self.store.commit_status("c1"), ST_UPDATING_SEARCH)
|
||||
|
||||
def test_replay_after_crash_before_delete(self):
|
||||
# Crash nach Approval, vor DELETE: Commit in ST_DELETE_APPROVED, Objekt noch da
|
||||
self.fake.vault["/app/vault/x.md"] = "content"
|
||||
_approve(self.store, "c1", self.change_id, UUID_A, "x.md")
|
||||
result = self.executor.replay("c1")
|
||||
self.assertEqual(result["deleted"], True)
|
||||
self.assertEqual(self.fake.delete_count, 1)
|
||||
self.assertEqual(self.store.commit_status("c1"), ST_UPDATING_SEARCH)
|
||||
|
||||
def test_replay_after_crash_during_delete_object_still_there(self):
|
||||
# Crash waehrend DELETE, Objekt noch da -> DELETE erneut (Approval gueltig)
|
||||
self.fake.vault["/app/vault/x.md"] = "content"
|
||||
_approve(self.store, "c1", self.change_id, UUID_A, "x.md")
|
||||
self.store.transition_commit("c1", ST_DELETING)
|
||||
result = self.executor.replay("c1")
|
||||
self.assertEqual(result["deleted"], True)
|
||||
self.assertEqual(self.fake.delete_count, 1)
|
||||
self.assertEqual(self.store.commit_status("c1"), ST_UPDATING_SEARCH)
|
||||
|
||||
|
||||
class TestRestartPersistence(unittest.TestCase):
|
||||
"""Restart nach Approval -> Approval erhalten (G)."""
|
||||
|
||||
def test_approval_persists_across_restart(self):
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="c5del_restart_"), "c5a.db")
|
||||
store1 = C5AStore(db)
|
||||
change_id = _seed_delete_commit(
|
||||
store1, "c1", UUID_A, "x.md", status=ST_HUMAN_REVIEW_REQUIRED)
|
||||
_approve(store1, "c1", change_id, UUID_A, "x.md")
|
||||
store1.close()
|
||||
|
||||
# Restart: neue Store-Instanz auf derselben DB
|
||||
store2 = C5AStore(db)
|
||||
approval = store2.get_delete_approval_for_change("c1", change_id)
|
||||
self.assertIsNotNone(approval)
|
||||
self.assertEqual(approval["approval_status"], "APPROVED")
|
||||
self.assertEqual(store2.commit_status("c1"), ST_DELETE_APPROVED)
|
||||
store2.close()
|
||||
|
||||
|
||||
class TestNoAutomaticApproval(unittest.TestCase):
|
||||
"""Keine automatische Approval-Erzeugung (B/C/N)."""
|
||||
|
||||
def setUp(self):
|
||||
self.fake = FakeTolaria()
|
||||
self.client = FakeTolariaClient(self.fake)
|
||||
self.store = _make_store()
|
||||
self.change_id = _seed_delete_commit(
|
||||
self.store, "c1", UUID_A, "x.md", status=ST_HUMAN_REVIEW_REQUIRED)
|
||||
self.executor = DeleteExecutor(self.store, self.client)
|
||||
|
||||
def tearDown(self):
|
||||
self.store.close()
|
||||
|
||||
def test_no_approval_created_by_executor(self):
|
||||
# Executor erzeugt NIE eine Approval; ohne Approval -> BLOCKED
|
||||
with self.assertRaises(DeleteExecutionError):
|
||||
self.executor.execute("c1")
|
||||
self.assertEqual(self.store.list_delete_approvals("c1"), [])
|
||||
self.assertEqual(self.fake.delete_count, 0)
|
||||
|
||||
def test_human_review_remains_default(self):
|
||||
# Neuer DELETE-Request landet in HUMAN_REVIEW_REQUIRED (nicht DELETE_APPROVED)
|
||||
self.assertEqual(self.store.commit_status("c1"), ST_HUMAN_REVIEW_REQUIRED)
|
||||
|
||||
|
||||
class TestMultiCommit(unittest.TestCase):
|
||||
"""Multi-Commit: Approval A darf DELETE B niemals autorisieren (F)."""
|
||||
|
||||
def setUp(self):
|
||||
self.fake = FakeTolaria()
|
||||
self.client = FakeTolariaClient(self.fake)
|
||||
self.store = _make_store()
|
||||
self.fake.vault["/app/vault/a.md"] = "content a"
|
||||
self.fake.vault["/app/vault/b.md"] = "content b"
|
||||
self.change_a = _seed_delete_commit(
|
||||
self.store, "cA", UUID_A, "a.md", status=ST_HUMAN_REVIEW_REQUIRED)
|
||||
self.change_b = _seed_delete_commit(
|
||||
self.store, "cB", UUID_B, "b.md", status=ST_HUMAN_REVIEW_REQUIRED)
|
||||
self.executor = DeleteExecutor(self.store, self.client)
|
||||
|
||||
def tearDown(self):
|
||||
self.store.close()
|
||||
|
||||
def test_approval_a_never_authorizes_b(self):
|
||||
# Approval nur fuer cA/a.md
|
||||
_approve(self.store, "cA", self.change_a, UUID_A, "a.md")
|
||||
# Versuche cB zu loeschen -> BLOCKED (keine Approval fuer cB)
|
||||
with self.assertRaises(DeleteExecutionError):
|
||||
self.executor.execute("cB")
|
||||
self.assertEqual(self.fake.delete_count, 0)
|
||||
self.assertEqual(self.fake.vault["/app/vault/b.md"], "content b")
|
||||
# cA kann trotzdem ausgefuehrt werden
|
||||
result = self.executor.execute("cA")
|
||||
self.assertEqual(result["deleted"], True)
|
||||
self.assertEqual(self.fake.delete_count, 1)
|
||||
|
||||
|
||||
class TestSecretSafety(unittest.TestCase):
|
||||
"""Keine Secrets/Credentials in Approval oder Output (Mission §11)."""
|
||||
|
||||
def setUp(self):
|
||||
self.store = _make_store()
|
||||
self.change_id = _seed_delete_commit(
|
||||
self.store, "c1", UUID_A, "x.md", status=ST_HUMAN_REVIEW_REQUIRED)
|
||||
|
||||
def tearDown(self):
|
||||
self.store.close()
|
||||
|
||||
def test_approval_has_no_secrets(self):
|
||||
approval = _approve(self.store, "c1", self.change_id, UUID_A, "x.md")
|
||||
blob = str(approval)
|
||||
for secret in ("token", "password", "secret", "api_key", "credential"):
|
||||
self.assertNotIn(secret.lower(), blob.lower())
|
||||
|
||||
|
||||
class TestNoStateBypass(unittest.TestCase):
|
||||
"""Keine direkte APPLIED-Markierung durch Executor (M)."""
|
||||
|
||||
def setUp(self):
|
||||
self.fake = FakeTolaria()
|
||||
self.client = FakeTolariaClient(self.fake)
|
||||
self.store = _make_store()
|
||||
self.fake.vault["/app/vault/x.md"] = "content"
|
||||
self.change_id = _seed_delete_commit(
|
||||
self.store, "c1", UUID_A, "x.md", status=ST_HUMAN_REVIEW_REQUIRED)
|
||||
self.executor = DeleteExecutor(self.store, self.client)
|
||||
|
||||
def tearDown(self):
|
||||
self.store.close()
|
||||
|
||||
def test_executor_never_marks_applied(self):
|
||||
_approve(self.store, "c1", self.change_id, UUID_A, "x.md")
|
||||
result = self.executor.execute("c1")
|
||||
# Executor geht nur bis UPDATING_SEARCH, NIE APPLIED
|
||||
self.assertEqual(result["status"], ST_UPDATING_SEARCH)
|
||||
self.assertEqual(self.store.commit_status("c1"), ST_UPDATING_SEARCH)
|
||||
self.assertNotEqual(self.store.commit_status("c1"), ST_APPLIED)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
suite = unittest.defaultTestLoader.loadTestsFromModule(sys.modules[__name__])
|
||||
runner = unittest.TextTestRunner(verbosity=2)
|
||||
result = runner.run(suite)
|
||||
return 0 if result.wasSuccessful() else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
420
tolaria/c5-sync-service/test_c5_delete_fresh_checker.py
Normal file
420
tolaria/c5-sync-service/test_c5_delete_fresh_checker.py
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Red Queen — C5 DELETE-Execution FRESH CHECKER (Todo 12).
|
||||
|
||||
Unabhaengiger, adversarialer Checker. Versucht AUSDRUECKLICH, den
|
||||
Human-Gated-DELETE-Contract zu brechen. Nutzt NUR die Produktionsmodule
|
||||
(rq_c5a/rq_c5c/rq_c5_delete/rq_c5d) + isolierte Fakes — KEINE Maker-Testdateien,
|
||||
KEINE Maker-Argumentation. Jeder Check ist ein eigenstaendiger Angriffsversuch.
|
||||
|
||||
Angriffsvektoren (Todo 12):
|
||||
wrong commit / wrong object / wrong path / wrong nonce
|
||||
missing approval / used approval / stale approval
|
||||
already absent / unexpected drift / crash after delete
|
||||
restart / search mismatch
|
||||
cross-object approval / cross-commit approval
|
||||
path traversal / arbitrary path deletion
|
||||
no auto-approval / no SQL bypass / no direct APPLIED bypass
|
||||
DELETE bleibt HUMAN_GATED
|
||||
|
||||
Ergebnis: PASS (kein Defekt) oder FAIL (Defekt reproduziert).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from rq_c5a import (
|
||||
C5AStore, ST_READY, ST_HUMAN_REVIEW_REQUIRED, ST_DELETE_APPROVED,
|
||||
ST_DELETING, ST_UPDATING_SEARCH, ST_VERIFYING_SEARCH, ST_APPLIED,
|
||||
RC_DELETE_APPROVAL_MISSING, RC_DELETE_APPROVAL_MISMATCH,
|
||||
RC_UNEXPECTED_TOLARIA_DRIFT,
|
||||
OP_DELETE_REQUEST, OP_CREATE,
|
||||
)
|
||||
from rq_c5c import TolariaClient, TolariaUnavailableError, RC_TOLARIA_UNAVAILABLE, VAULT_PREFIX
|
||||
from rq_c5_delete import (
|
||||
DeleteExecutor, DeleteApprovalError, DeleteExecutionError,
|
||||
DELETE_ALREADY_AT_TARGET,
|
||||
)
|
||||
from rq_c5d import C5DEngine, SearchSourceBuilder, SearchClient
|
||||
from rq_c5b import KnowledgeScope
|
||||
|
||||
UUID_A = "object/77b02661-d67b-4bae-b612-01d1287cea6b"
|
||||
UUID_B = "object/48bd264f-607b-15f1-5f73-3e922af9b19d"
|
||||
|
||||
|
||||
def _fm(id_: str, **extra) -> str:
|
||||
lines = ["---", "knowledge_schema: 1", f"id: {id_}"]
|
||||
for k, v in extra.items():
|
||||
if isinstance(v, list):
|
||||
lines.append(f"{k}: [{', '.join(v)}]")
|
||||
else:
|
||||
lines.append(f"{k}: {v}")
|
||||
lines.append("---")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
class FakeTolaria:
|
||||
def __init__(self):
|
||||
self.vault: Dict[str, str] = {}
|
||||
self.delete_count = 0
|
||||
self.unavailable = False
|
||||
|
||||
def read(self, p: str) -> Optional[str]:
|
||||
if self.unavailable:
|
||||
raise TolariaUnavailableError("down", RC_TOLARIA_UNAVAILABLE)
|
||||
return self.vault.get(p)
|
||||
|
||||
def write(self, p: str, c: str) -> Dict[str, Any]:
|
||||
self.vault[p] = c
|
||||
return {"ok": True}
|
||||
|
||||
def delete(self, p: str) -> Dict[str, Any]:
|
||||
if p in self.vault:
|
||||
del self.vault[p]
|
||||
self.delete_count += 1
|
||||
return {"ok": True}
|
||||
|
||||
def list(self, p: str = "/app/vault") -> List[Dict[str, Any]]:
|
||||
return [{"path": x} for x in self.vault]
|
||||
|
||||
|
||||
class FakeTolariaClient(TolariaClient):
|
||||
def __init__(self, fake: FakeTolaria):
|
||||
super().__init__(base_url="http://fake")
|
||||
self.fake = fake
|
||||
|
||||
def read(self, vault_path: str) -> Optional[str]:
|
||||
return self.fake.read(vault_path)
|
||||
|
||||
def write(self, vault_path: str, content: str) -> Dict[str, Any]:
|
||||
return self.fake.write(vault_path, content)
|
||||
|
||||
def delete(self, vault_path: str) -> Dict[str, Any]:
|
||||
return self.fake.delete(vault_path)
|
||||
|
||||
def list(self, vault_path: str = "/app/vault") -> List[Dict[str, Any]]:
|
||||
return self.fake.list(vault_path)
|
||||
|
||||
|
||||
class FakeSearch:
|
||||
def __init__(self, source_path: str):
|
||||
self.source_path = source_path
|
||||
self.rebuild_count = 0
|
||||
self.health_override: Optional[Dict[str, Any]] = None
|
||||
self.indexed_ids: List[str] = []
|
||||
self.indexed_paths: List[str] = []
|
||||
|
||||
def _load(self) -> Dict[str, Any]:
|
||||
with open(self.source_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def rebuild(self) -> Dict[str, Any]:
|
||||
self.rebuild_count += 1
|
||||
src = self._load()
|
||||
objs = src.get("objects", [])
|
||||
self.indexed_ids = [o["id"] for o in objs if o.get("id")]
|
||||
self.indexed_paths = [o["path"] for o in objs]
|
||||
return {"status": "ok", "indexed": len(objs)}
|
||||
|
||||
def health(self) -> Dict[str, Any]:
|
||||
if self.health_override is not None:
|
||||
return self.health_override
|
||||
return {
|
||||
"index_built": True, "object_count": len(self.indexed_ids),
|
||||
"failed_objects": [], "integrity_ok": True,
|
||||
"indexed_object_ids": self.indexed_ids,
|
||||
"indexed_paths": self.indexed_paths,
|
||||
"secret_blocked_objects": 0,
|
||||
}
|
||||
|
||||
|
||||
class FakeSearchClient(SearchClient):
|
||||
def __init__(self, fake: FakeSearch):
|
||||
super().__init__(base_url="http://fake-search")
|
||||
self.fake = fake
|
||||
|
||||
def rebuild(self) -> Dict[str, Any]:
|
||||
return self.fake.rebuild()
|
||||
|
||||
def health(self) -> Dict[str, Any]:
|
||||
return self.fake.health()
|
||||
|
||||
|
||||
def _make_store() -> C5AStore:
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="c5checker_db_"), "c5a.db")
|
||||
return C5AStore(db)
|
||||
|
||||
|
||||
def _seed_delete(store: C5AStore, sha: str, oid: str, path: str,
|
||||
status: str = ST_HUMAN_REVIEW_REQUIRED) -> int:
|
||||
store.upsert_commit({
|
||||
"commit_sha": sha, "parent_sha": "p", "sequence": 1,
|
||||
"status": status, "retry_count": 0,
|
||||
})
|
||||
oc = store.add_object_change({
|
||||
"commit_sha": sha, "object_id": oid, "operation": OP_DELETE_REQUEST,
|
||||
"path_before": path, "path_after": None,
|
||||
})
|
||||
if oc is None or oc.get("id") is None:
|
||||
raise RuntimeError("keine id")
|
||||
return oc["id"]
|
||||
|
||||
|
||||
def _approve(store: C5AStore, sha: str, cid: int, oid: str, path: str) -> Dict[str, Any]:
|
||||
return store.create_delete_approval(
|
||||
workflow_commit=sha, object_change_id=cid, object_id=oid,
|
||||
path=path, approved_by="human:christian")
|
||||
|
||||
|
||||
class CheckerFixture:
|
||||
def __init__(self):
|
||||
self.store = _make_store()
|
||||
self.fake = FakeTolaria()
|
||||
self.client = FakeTolariaClient(self.fake)
|
||||
self.tmpdir = tempfile.mkdtemp(prefix="c5checker_src_")
|
||||
self.source_path = os.path.join(self.tmpdir, "index_source.json")
|
||||
self.builder = SearchSourceBuilder(self.client, self.source_path,
|
||||
scope=KnowledgeScope(VAULT_PREFIX))
|
||||
self.search = FakeSearch(self.source_path)
|
||||
self.search_client = FakeSearchClient(self.search)
|
||||
self.engine = C5DEngine(self.store, search=self.search_client,
|
||||
source_builder=self.builder)
|
||||
|
||||
def seed(self, objs: Dict[str, str]) -> None:
|
||||
for p, c in objs.items():
|
||||
self.fake.vault[p] = c
|
||||
|
||||
def cleanup(self) -> None:
|
||||
import shutil
|
||||
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
class TestFreshChecker(unittest.TestCase):
|
||||
"""Adversarialer Checker: versucht den Contract zu brechen."""
|
||||
|
||||
def setUp(self):
|
||||
self.fx = CheckerFixture()
|
||||
self.fx.seed({
|
||||
"/app/vault/a.md": _fm(UUID_A, title="A"),
|
||||
"/app/vault/b.md": _fm(UUID_B, title="B"),
|
||||
})
|
||||
|
||||
def tearDown(self):
|
||||
self.fx.cleanup()
|
||||
|
||||
def _exec(self) -> DeleteExecutor:
|
||||
return DeleteExecutor(self.fx.store, self.fx.client)
|
||||
|
||||
# -- wrong commit / object / path / nonce --------------------------------
|
||||
|
||||
def test_wrong_commit_breaks(self):
|
||||
"""Cross-commit: Approval fuer c2 darf c1 nicht freigeben."""
|
||||
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
|
||||
_approve(self.fx.store, "c2", cid, UUID_A, "a.md")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
with self.assertRaises(DeleteApprovalError):
|
||||
self._exec().execute("c1")
|
||||
self.assertEqual(self.fx.fake.delete_count, 0)
|
||||
|
||||
def test_wrong_object_breaks(self):
|
||||
"""Cross-object: Approval fuer B darf A nicht freigeben."""
|
||||
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
|
||||
_approve(self.fx.store, "c1", cid, UUID_B, "a.md")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
with self.assertRaises(DeleteApprovalError):
|
||||
self._exec().execute("c1")
|
||||
self.assertEqual(self.fx.fake.delete_count, 0)
|
||||
|
||||
def test_wrong_path_breaks(self):
|
||||
"""Cross-path: Approval fuer b.md darf a.md nicht freigeben."""
|
||||
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
|
||||
_approve(self.fx.store, "c1", cid, UUID_A, "b.md")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
with self.assertRaises(DeleteApprovalError):
|
||||
self._exec().execute("c1")
|
||||
self.assertEqual(self.fx.fake.delete_count, 0)
|
||||
|
||||
def test_wrong_nonce_breaks(self):
|
||||
"""Manipulierter nonce darf keinen zusaetzlichen Pfad oeffnen."""
|
||||
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
|
||||
_approve(self.fx.store, "c1", cid, UUID_A, "a.md")
|
||||
# nonce wird nicht als Autorisierung genutzt; Approval bleibt korrekt
|
||||
# gebunden. Der Checker verifiziert, dass nonce keinen Replay oeffnet.
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
r = self._exec().execute("c1")
|
||||
self.assertEqual(r["status"], ST_UPDATING_SEARCH)
|
||||
self.assertEqual(self.fx.fake.delete_count, 1)
|
||||
|
||||
# -- missing / used / stale approval ------------------------------------
|
||||
|
||||
def test_missing_approval_breaks(self):
|
||||
"""Kein Delete ohne persistierte Approval."""
|
||||
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
with self.assertRaises(DeleteApprovalError) as ctx:
|
||||
self._exec().execute("c1")
|
||||
self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISSING)
|
||||
self.assertEqual(self.fx.fake.delete_count, 0)
|
||||
|
||||
def test_used_approval_breaks(self):
|
||||
"""USED-Approval darf nicht erneut einen DELETE autorisieren."""
|
||||
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
|
||||
_approve(self.fx.store, "c1", cid, UUID_A, "a.md")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
self._exec().execute("c1")
|
||||
# Approval ist USED. Neuer Commit c2 mit gleicher Approval-id -> MISSING.
|
||||
self.fx.store.upsert_commit({
|
||||
"commit_sha": "c2", "parent_sha": "p", "sequence": 2,
|
||||
"status": ST_DELETE_APPROVED, "retry_count": 0,
|
||||
})
|
||||
cid2 = self.fx.store.add_object_change({
|
||||
"commit_sha": "c2", "object_id": UUID_A, "operation": OP_DELETE_REQUEST,
|
||||
"path_before": "a.md", "path_after": None,
|
||||
})
|
||||
self.assertIsNotNone(cid2)
|
||||
with self.assertRaises(DeleteApprovalError):
|
||||
self._exec().execute("c2")
|
||||
self.assertEqual(self.fx.fake.delete_count, 1)
|
||||
|
||||
def test_stale_approval_breaks(self):
|
||||
"""Stale Approval (Commit nicht DELETE_APPROVED) -> kein DELETE."""
|
||||
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
|
||||
_approve(self.fx.store, "c1", cid, UUID_A, "a.md")
|
||||
# Commit bleibt HUMAN_REVIEW_REQUIRED (nicht DELETE_APPROVED)
|
||||
with self.assertRaises(DeleteExecutionError):
|
||||
self._exec().execute("c1")
|
||||
self.assertEqual(self.fx.fake.delete_count, 0)
|
||||
|
||||
# -- already absent / unexpected drift / crash --------------------------
|
||||
|
||||
def test_already_absent_no_second_delete(self):
|
||||
"""Bereits absent -> kein zweiter destruktiver Write."""
|
||||
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
|
||||
_approve(self.fx.store, "c1", cid, UUID_A, "a.md")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
del self.fx.fake.vault["/app/vault/a.md"]
|
||||
r = self._exec().execute("c1")
|
||||
self.assertEqual(r["idempotency"], DELETE_ALREADY_AT_TARGET)
|
||||
self.assertEqual(self.fx.fake.delete_count, 0)
|
||||
|
||||
def test_unexpected_drift_breaks(self):
|
||||
"""Unerwartete neue Objekte nach DELETE -> FAIL CLOSED."""
|
||||
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
|
||||
_approve(self.fx.store, "c1", cid, UUID_A, "a.md")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
# Nach dem DELETE fuegt der Fake ein neues Objekt hinzu (Drift).
|
||||
orig_delete = self.fx.fake.delete
|
||||
|
||||
def _delete_with_drift(p: str) -> Dict[str, Any]:
|
||||
r = orig_delete(p)
|
||||
self.fx.fake.vault["/app/vault/evil.md"] = _fm(UUID_B, title="EVIL")
|
||||
return r
|
||||
|
||||
self.fx.fake.delete = _delete_with_drift
|
||||
with self.assertRaises(DeleteExecutionError):
|
||||
self._exec().execute("c1")
|
||||
self.assertEqual(self.fx.store.commit_status("c1"), ST_HUMAN_REVIEW_REQUIRED)
|
||||
|
||||
def test_crash_after_delete_replay(self):
|
||||
"""Crash nach DELETE: Replay -> DELETE_ALREADY_AT_TARGET, kein 2. Delete."""
|
||||
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
|
||||
_approve(self.fx.store, "c1", cid, UUID_A, "a.md")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
self._exec().execute("c1")
|
||||
# Commit ist ST_UPDATING_SEARCH. Replay -> nichts zu tun.
|
||||
r = self._exec().replay("c1")
|
||||
self.assertEqual(r["idempotency"], "ALREADY_AT_TARGET")
|
||||
self.assertEqual(self.fx.fake.delete_count, 1)
|
||||
|
||||
def test_restart_after_approval(self):
|
||||
"""Restart nach Approval: Approval persistent, DELETE ausfuehrbar."""
|
||||
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
|
||||
_approve(self.fx.store, "c1", cid, UUID_A, "a.md")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
store2 = C5AStore(self.fx.store.db_path)
|
||||
r = DeleteExecutor(store2, self.fx.client).execute("c1")
|
||||
self.assertEqual(r["status"], ST_UPDATING_SEARCH)
|
||||
self.assertEqual(self.fx.fake.delete_count, 1)
|
||||
|
||||
# -- search mismatch ----------------------------------------------------
|
||||
|
||||
def test_search_mismatch_no_applied(self):
|
||||
"""Search-Mismatch -> KEIN APPLIED."""
|
||||
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
|
||||
_approve(self.fx.store, "c1", cid, UUID_A, "a.md")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
self._exec().execute("c1")
|
||||
# Stale Index mit geloeschtem Objekt
|
||||
self.fx.search.health_override = {
|
||||
"index_built": True, "object_count": 2,
|
||||
"failed_objects": [], "integrity_ok": True,
|
||||
"indexed_object_ids": [UUID_A, UUID_B],
|
||||
"indexed_paths": ["a.md", "b.md"],
|
||||
}
|
||||
r = self.fx.engine.apply_commit("c1")
|
||||
self.assertNotEqual(r["status"], ST_APPLIED)
|
||||
|
||||
# -- path traversal / arbitrary path ------------------------------------
|
||||
|
||||
def test_path_traversal_breaks(self):
|
||||
"""Path-Traversal -> BLOCK, kein DELETE."""
|
||||
cid = _seed_delete(self.fx.store, "c1", UUID_A, "../../etc/passwd")
|
||||
_approve(self.fx.store, "c1", cid, UUID_A, "../../etc/passwd")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
with self.assertRaises(DeleteExecutionError):
|
||||
self._exec().execute("c1")
|
||||
self.assertEqual(self.fx.fake.delete_count, 0)
|
||||
|
||||
def test_arbitrary_path_no_object_change(self):
|
||||
"""Freie Object-ID/Pfad ohne ObjectChange kann keinen Delete triggern."""
|
||||
# Kein ObjectChange angelegt. Executor findet keinen DELETE-Change.
|
||||
self.fx.store.upsert_commit({
|
||||
"commit_sha": "c1", "parent_sha": "p", "sequence": 1,
|
||||
"status": ST_DELETE_APPROVED, "retry_count": 0,
|
||||
})
|
||||
with self.assertRaises(DeleteApprovalError):
|
||||
self._exec().execute("c1")
|
||||
self.assertEqual(self.fx.fake.delete_count, 0)
|
||||
|
||||
# -- no auto-approval / no SQL bypass / no APPLIED bypass ---------------
|
||||
|
||||
def test_no_auto_approval(self):
|
||||
"""Kein Auto-Approval: ohne CLI-approve gibt es keine Approval."""
|
||||
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
approvals = self.fx.store.list_delete_approvals("c1")
|
||||
self.assertEqual(len(approvals), 0)
|
||||
with self.assertRaises(DeleteApprovalError):
|
||||
self._exec().execute("c1")
|
||||
|
||||
def test_no_direct_applied_bypass(self):
|
||||
"""Kein direkter APPLIED-Bypass: Executor setzt nie APPLIED."""
|
||||
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
|
||||
_approve(self.fx.store, "c1", cid, UUID_A, "a.md")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
r = self._exec().execute("c1")
|
||||
self.assertEqual(r["status"], ST_UPDATING_SEARCH)
|
||||
self.assertNotEqual(self.fx.store.commit_status("c1"), ST_APPLIED)
|
||||
|
||||
def test_delete_stays_human_gated(self):
|
||||
"""DELETE bleibt HUMAN_GATED: neuer DELETE-Request -> HUMAN_REVIEW_REQUIRED."""
|
||||
# C5B klassifiziert DELETE -> HUMAN_REVIEW_REQUIRED (kein Auto-Approval).
|
||||
# Der Executor verlangt ST_DELETE_APPROVED; ohne explizite Approval
|
||||
# bleibt der Commit HUMAN_REVIEW_REQUIRED.
|
||||
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
|
||||
self.assertEqual(self.fx.store.commit_status("c1"), ST_HUMAN_REVIEW_REQUIRED)
|
||||
with self.assertRaises(DeleteExecutionError):
|
||||
self._exec().execute("c1")
|
||||
self.assertEqual(self.fx.fake.delete_count, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
660
tolaria/c5-sync-service/test_c5_delete_integration.py
Normal file
660
tolaria/c5-sync-service/test_c5_delete_integration.py
Normal file
|
|
@ -0,0 +1,660 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Red Queen — C5 DELETE-Execution INTEGRATION Testsuite (Todo 7/8/9).
|
||||
|
||||
Testet den VOLLEN Post-Delete-Search-Pfad mit ECHTEM SearchSourceBuilder
|
||||
(kein Fake-Source-Builder) + FakeSearch, der aus der echten Source-Datei
|
||||
indexiert. Isolierte Fixture (temp-DB, temp-Source, FakeTolaria). KEINE
|
||||
produktiven Writes. KEINE produktiven Endpoint-Probes.
|
||||
|
||||
Abgedeckte Faelle (Todo 7 SEARCH CONTRACT + Todo 8 TESTPLAN + Todo 9):
|
||||
erfolgreiches Delete: vorher N Objekte, nachher N-1
|
||||
geloeschte object_id fehlt exakt im Search
|
||||
geloeschter path fehlt exakt im Search
|
||||
alle anderen IDs/Pfade unveraendert
|
||||
Search-Mismatch -> KEIN APPLIED
|
||||
stale Search -> KEIN APPLIED
|
||||
failed/stale/secret-blocked Health -> KEIN APPLIED
|
||||
Search Exact-Set nach DELETE
|
||||
Search object_count N -> N-1
|
||||
falsche Approval nonce -> BLOCK
|
||||
Approval USED -> nicht erneut nutzbar
|
||||
Approval fuer falschen Path -> BLOCK
|
||||
Approval fuer falsche object_id -> BLOCK
|
||||
Approval fuer falschen Commit -> BLOCK
|
||||
ObjectChange kein DELETE -> BLOCK
|
||||
Ziel vor Delete unerwartet veraendert -> DRIFT/HUMAN GATE
|
||||
DELETE_ALREADY_AT_TARGET -> kein zweiter DELETE
|
||||
Restart nach Approval
|
||||
Restart nach erfolgreichem Delete vor Search
|
||||
Restart nach Search vor APPLIED
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from rq_c5a import (
|
||||
C5AStore, ST_READY, ST_HUMAN_REVIEW_REQUIRED, ST_DELETE_APPROVED,
|
||||
ST_DELETING, ST_UPDATING_SEARCH, ST_VERIFYING_SEARCH, ST_APPLIED,
|
||||
ST_RETRY_PENDING, ST_DEAD,
|
||||
RC_DELETE_APPROVAL_MISSING, RC_DELETE_APPROVAL_MISMATCH,
|
||||
RC_UNEXPECTED_TOLARIA_DRIFT, RC_SEARCH_REBUILD_FAILURE,
|
||||
OP_DELETE_REQUEST, OP_CREATE, OP_CONTENT_UPDATE,
|
||||
)
|
||||
from rq_c5c import TolariaClient, TolariaUnavailableError, RC_TOLARIA_UNAVAILABLE, VAULT_PREFIX
|
||||
from rq_c5_delete import (
|
||||
DeleteExecutor, DeleteApprovalError, DeleteExecutionError,
|
||||
DELETE_ALREADY_AT_TARGET,
|
||||
)
|
||||
from rq_c5d import (
|
||||
SearchClient, C5DEngine, SearchSourceBuilder, verify_integrity,
|
||||
SearchError, SearchUnavailableError, SearchIntegrityError,
|
||||
SearchRebuildError, SearchMalformedResponseError,
|
||||
)
|
||||
from rq_c5b import KnowledgeScope
|
||||
|
||||
UUID_A = "object/77b02661-d67b-4bae-b612-01d1287cea6b"
|
||||
UUID_B = "object/48bd264f-607b-15f1-5f73-3e922af9b19d"
|
||||
UUID_C = "object/9c3f2a11-4d5e-4f6a-8b7c-1a2b3c4d5e6f"
|
||||
|
||||
|
||||
def _fm(id_: str, **extra) -> str:
|
||||
lines = ["---", "knowledge_schema: 1", f"id: {id_}"]
|
||||
for k, v in extra.items():
|
||||
if isinstance(v, list):
|
||||
lines.append(f"{k}: [{', '.join(v)}]")
|
||||
else:
|
||||
lines.append(f"{k}: {v}")
|
||||
lines.append("---")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Isolierte Fakes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class FakeTolaria:
|
||||
"""In-Memory Fake der Tolaria-Vault-API (read/write/delete/list)."""
|
||||
|
||||
def __init__(self):
|
||||
self.vault: Dict[str, str] = {}
|
||||
self.write_count = 0
|
||||
self.delete_count = 0
|
||||
self.fail_next_delete = False
|
||||
self.unavailable = False
|
||||
|
||||
def read(self, vault_path: str) -> Optional[str]:
|
||||
if self.unavailable:
|
||||
raise TolariaUnavailableError("Tolaria down", RC_TOLARIA_UNAVAILABLE)
|
||||
return self.vault.get(vault_path)
|
||||
|
||||
def write(self, vault_path: str, content: str) -> Dict[str, Any]:
|
||||
if self.unavailable:
|
||||
raise TolariaUnavailableError("Tolaria down", RC_TOLARIA_UNAVAILABLE)
|
||||
self.vault[vault_path] = content
|
||||
self.write_count += 1
|
||||
return {"ok": True}
|
||||
|
||||
def delete(self, vault_path: str) -> Dict[str, Any]:
|
||||
if self.unavailable:
|
||||
raise TolariaUnavailableError("Tolaria down", RC_TOLARIA_UNAVAILABLE)
|
||||
if self.fail_next_delete:
|
||||
self.fail_next_delete = False
|
||||
raise TolariaUnavailableError("delete timeout", RC_TOLARIA_UNAVAILABLE)
|
||||
if vault_path in self.vault:
|
||||
del self.vault[vault_path]
|
||||
self.delete_count += 1
|
||||
return {"ok": True}
|
||||
|
||||
def list(self, vault_path: str = "/app/vault") -> List[Dict[str, Any]]:
|
||||
return [{"path": p} for p in self.vault]
|
||||
|
||||
|
||||
class FakeTolariaClient(TolariaClient):
|
||||
"""TolariaClient, der gegen FakeTolaria statt HTTP arbeitet."""
|
||||
|
||||
def __init__(self, fake: FakeTolaria):
|
||||
super().__init__(base_url="http://fake")
|
||||
self.fake = fake
|
||||
|
||||
def read(self, vault_path: str) -> Optional[str]:
|
||||
return self.fake.read(vault_path)
|
||||
|
||||
def write(self, vault_path: str, content: str) -> Dict[str, Any]:
|
||||
return self.fake.write(vault_path, content)
|
||||
|
||||
def delete(self, vault_path: str) -> Dict[str, Any]:
|
||||
return self.fake.delete(vault_path)
|
||||
|
||||
def list(self, vault_path: str = "/app/vault") -> List[Dict[str, Any]]:
|
||||
return self.fake.list(vault_path)
|
||||
|
||||
|
||||
class FakeSearch:
|
||||
"""
|
||||
In-Memory Fake der Search-API, der beim rebuild() die ECHTE Source-Datei
|
||||
liest (die der echte SearchSourceBuilder geschrieben hat) und daraus den
|
||||
Index aufbaut. health() liefert die indexierten Objekte zurueck.
|
||||
Konfigurierbare Fehler (rebuild_error, health_error, health_override).
|
||||
"""
|
||||
|
||||
def __init__(self, source_path: str):
|
||||
self.source_path = source_path
|
||||
self.rebuild_count = 0
|
||||
self.health_count = 0
|
||||
self.rebuild_error: Optional[Exception] = None
|
||||
self.health_error: Optional[Exception] = None
|
||||
self.health_override: Optional[Dict[str, Any]] = None
|
||||
self.indexed_ids: List[str] = []
|
||||
self.indexed_paths: List[str] = []
|
||||
self.indexed_count = 0
|
||||
|
||||
def _load_source(self) -> Dict[str, Any]:
|
||||
with open(self.source_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def rebuild(self) -> Dict[str, Any]:
|
||||
self.rebuild_count += 1
|
||||
if self.rebuild_error:
|
||||
raise self.rebuild_error
|
||||
# Indexiere aus der ECHTEN Source-Datei (deterministisch).
|
||||
src = self._load_source()
|
||||
objs = src.get("objects", [])
|
||||
self.indexed_ids = [o["id"] for o in objs if o.get("id")]
|
||||
self.indexed_paths = [o["path"] for o in objs]
|
||||
self.indexed_count = len(objs)
|
||||
return {"status": "ok", "indexed": self.indexed_count}
|
||||
|
||||
def health(self) -> Dict[str, Any]:
|
||||
self.health_count += 1
|
||||
if self.health_error:
|
||||
raise self.health_error
|
||||
if self.health_override is not None:
|
||||
return self.health_override
|
||||
return {
|
||||
"index_built": True,
|
||||
"object_count": self.indexed_count,
|
||||
"failed_objects": [],
|
||||
"integrity_ok": True,
|
||||
"supported_modes": ["exact", "keyword", "metadata"],
|
||||
"index_version": 1,
|
||||
"source_head": "test",
|
||||
"secret_blocked_objects": 0,
|
||||
"indexed_object_ids": self.indexed_ids,
|
||||
"indexed_paths": self.indexed_paths,
|
||||
}
|
||||
|
||||
|
||||
class FakeSearchClient(SearchClient):
|
||||
"""SearchClient, der gegen FakeSearch statt HTTP arbeitet."""
|
||||
|
||||
def __init__(self, fake: FakeSearch):
|
||||
super().__init__(base_url="http://fake-search")
|
||||
self.fake = fake
|
||||
|
||||
def rebuild(self) -> Dict[str, Any]:
|
||||
return self.fake.rebuild()
|
||||
|
||||
def health(self) -> Dict[str, Any]:
|
||||
return self.fake.health()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test-Helfer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_store() -> C5AStore:
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="c5delint_db_"), "c5a.db")
|
||||
return C5AStore(db)
|
||||
|
||||
|
||||
def _seed_delete_commit(store: C5AStore, sha: str, oid: str, path: str,
|
||||
status: str = ST_HUMAN_REVIEW_REQUIRED) -> int:
|
||||
"""Legt einen Commit + DELETE-ObjectChange an. Gibt die ObjectChange-id zurueck."""
|
||||
store.upsert_commit({
|
||||
"commit_sha": sha, "parent_sha": "p", "sequence": 1,
|
||||
"status": status, "retry_count": 0,
|
||||
})
|
||||
oc = store.add_object_change({
|
||||
"commit_sha": sha, "object_id": oid, "operation": OP_DELETE_REQUEST,
|
||||
"path_before": path, "path_after": None,
|
||||
})
|
||||
if oc is None or oc.get("id") is None:
|
||||
raise RuntimeError("add_object_change lieferte keine id")
|
||||
return oc["id"]
|
||||
|
||||
|
||||
def _approve(store: C5AStore, sha: str, change_id: int, oid: str, path: str,
|
||||
nonce: Optional[str] = None) -> Dict[str, Any]:
|
||||
return store.create_delete_approval(
|
||||
workflow_commit=sha, object_change_id=change_id, object_id=oid,
|
||||
path=path, approved_by="human:christian", approval_nonce=nonce,
|
||||
)
|
||||
|
||||
|
||||
class IntegrationFixture:
|
||||
"""Voller isolierter Pfad: FakeTolaria + echter SearchSourceBuilder + FakeSearch."""
|
||||
|
||||
def __init__(self):
|
||||
self.store = _make_store()
|
||||
self.fake = FakeTolaria()
|
||||
self.client = FakeTolariaClient(self.fake)
|
||||
self.tmpdir = tempfile.mkdtemp(prefix="c5delint_src_")
|
||||
self.source_path = os.path.join(self.tmpdir, "index_source.json")
|
||||
self.builder = SearchSourceBuilder(
|
||||
self.client, self.source_path, scope=KnowledgeScope(VAULT_PREFIX))
|
||||
self.search = FakeSearch(self.source_path)
|
||||
self.search_client = FakeSearchClient(self.search)
|
||||
self.engine = C5DEngine(
|
||||
self.store, search=self.search_client, source_builder=self.builder)
|
||||
|
||||
def seed_vault(self, objects: Dict[str, str]) -> None:
|
||||
"""Befuellt den Fake-Vault mit {vault_path: content}."""
|
||||
for p, c in objects.items():
|
||||
self.fake.vault[p] = c
|
||||
|
||||
def cleanup(self) -> None:
|
||||
import shutil
|
||||
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Todo 7 — SEARCH CONTRACT: Post-Delete-Search-Pfad
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSearchContract(unittest.TestCase):
|
||||
"""Beweist den vollen Post-Delete-Search-Pfad bis APPLIED."""
|
||||
|
||||
def setUp(self):
|
||||
self.fx = IntegrationFixture()
|
||||
# Vault mit 3 Objekten (A, B, C) befuellen.
|
||||
self.fx.seed_vault({
|
||||
"/app/vault/a.md": _fm(UUID_A, title="A"),
|
||||
"/app/vault/b.md": _fm(UUID_B, title="B"),
|
||||
"/app/vault/c.md": _fm(UUID_C, title="C"),
|
||||
})
|
||||
|
||||
def tearDown(self):
|
||||
self.fx.cleanup()
|
||||
|
||||
def _run_full_delete(self, sha: str, oid: str, path: str) -> Dict[str, Any]:
|
||||
"""Voller Pfad: Approval -> Executor -> C5D -> APPLIED."""
|
||||
change_id = _seed_delete_commit(self.fx.store, sha, oid, path)
|
||||
_approve(self.fx.store, sha, change_id, oid, path)
|
||||
self.fx.store.transition_commit(sha, ST_DELETE_APPROVED)
|
||||
executor = DeleteExecutor(self.fx.store, self.fx.client)
|
||||
exec_result = executor.execute(sha)
|
||||
self.assertEqual(exec_result["status"], ST_UPDATING_SEARCH)
|
||||
# C5D uebernimmt den Search-Pfad
|
||||
return self.fx.engine.apply_commit(sha)
|
||||
|
||||
def test_successful_delete_n_to_n_minus_1(self):
|
||||
"""Erfolgreiches Delete: vorher N Objekte, nachher N-1."""
|
||||
self.assertEqual(len(self.fx.fake.vault), 3)
|
||||
result = self._run_full_delete("c1", UUID_A, "a.md")
|
||||
self.assertEqual(result["status"], ST_APPLIED)
|
||||
self.assertEqual(len(self.fx.fake.vault), 2)
|
||||
self.assertEqual(self.fx.search.indexed_count, 2)
|
||||
|
||||
def test_deleted_object_id_absent_exactly(self):
|
||||
"""Geloeschte object_id fehlt exakt im Search."""
|
||||
self._run_full_delete("c1", UUID_A, "a.md")
|
||||
self.assertNotIn(UUID_A, self.fx.search.indexed_ids)
|
||||
self.assertIn(UUID_B, self.fx.search.indexed_ids)
|
||||
self.assertIn(UUID_C, self.fx.search.indexed_ids)
|
||||
|
||||
def test_deleted_path_absent_exactly(self):
|
||||
"""Geloeschter path fehlt exakt im Search."""
|
||||
self._run_full_delete("c1", UUID_A, "a.md")
|
||||
self.assertNotIn("a.md", self.fx.search.indexed_paths)
|
||||
self.assertIn("b.md", self.fx.search.indexed_paths)
|
||||
self.assertIn("c.md", self.fx.search.indexed_paths)
|
||||
|
||||
def test_other_ids_paths_unchanged(self):
|
||||
"""Alle anderen IDs/Pfade unveraendert."""
|
||||
self._run_full_delete("c1", UUID_A, "a.md")
|
||||
self.assertEqual(set(self.fx.search.indexed_ids), {UUID_B, UUID_C})
|
||||
self.assertEqual(set(self.fx.search.indexed_paths), {"b.md", "c.md"})
|
||||
|
||||
def test_search_mismatch_no_applied(self):
|
||||
"""Search-Mismatch (stale Index mit geloeschtem Objekt) -> KEIN APPLIED."""
|
||||
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
|
||||
_approve(self.fx.store, "c1", change_id, UUID_A, "a.md")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
executor = DeleteExecutor(self.fx.store, self.fx.client)
|
||||
executor.execute("c1")
|
||||
# Stale Search: Index enthaelt das geloeschte Objekt noch (kein Rebuild).
|
||||
# health_override simuliert einen Index, der das geloeschte Objekt
|
||||
# weiterhin enthaelt -> Exact-Set-Mismatch -> KEIN APPLIED.
|
||||
self.fx.search.health_override = {
|
||||
"index_built": True,
|
||||
"object_count": 3,
|
||||
"failed_objects": [],
|
||||
"integrity_ok": True,
|
||||
"indexed_object_ids": [UUID_A, UUID_B, UUID_C],
|
||||
"indexed_paths": ["a.md", "b.md", "c.md"],
|
||||
}
|
||||
result = self.fx.engine.apply_commit("c1")
|
||||
self.assertNotEqual(result["status"], ST_APPLIED)
|
||||
self.assertEqual(self.fx.store.commit_status("c1"), ST_HUMAN_REVIEW_REQUIRED)
|
||||
|
||||
def test_stale_search_no_applied(self):
|
||||
"""Stale Search (Source-Build ok, aber Index nicht aktualisiert) -> KEIN APPLIED."""
|
||||
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
|
||||
_approve(self.fx.store, "c1", change_id, UUID_A, "a.md")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
executor = DeleteExecutor(self.fx.store, self.fx.client)
|
||||
executor.execute("c1")
|
||||
# Rebuild liefert stale Index (Source-Datei nicht gelesen -> leer).
|
||||
self.fx.search.health_override = {
|
||||
"index_built": True,
|
||||
"object_count": 3,
|
||||
"failed_objects": [],
|
||||
"integrity_ok": True,
|
||||
"indexed_object_ids": [UUID_A, UUID_B, UUID_C],
|
||||
"indexed_paths": ["a.md", "b.md", "c.md"],
|
||||
}
|
||||
result = self.fx.engine.apply_commit("c1")
|
||||
self.assertNotEqual(result["status"], ST_APPLIED)
|
||||
|
||||
def test_failed_health_no_applied(self):
|
||||
"""Failed Health (integrity_ok=false) -> KEIN APPLIED."""
|
||||
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
|
||||
_approve(self.fx.store, "c1", change_id, UUID_A, "a.md")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
executor = DeleteExecutor(self.fx.store, self.fx.client)
|
||||
executor.execute("c1")
|
||||
self.fx.search.health_override = {
|
||||
"index_built": True,
|
||||
"object_count": 2,
|
||||
"failed_objects": [],
|
||||
"integrity_ok": False,
|
||||
"indexed_object_ids": [UUID_B, UUID_C],
|
||||
"indexed_paths": ["b.md", "c.md"],
|
||||
}
|
||||
result = self.fx.engine.apply_commit("c1")
|
||||
self.assertNotEqual(result["status"], ST_APPLIED)
|
||||
|
||||
def test_secret_blocked_health_no_applied(self):
|
||||
"""Secret-blocked Health -> KEIN APPLIED."""
|
||||
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
|
||||
_approve(self.fx.store, "c1", change_id, UUID_A, "a.md")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
executor = DeleteExecutor(self.fx.store, self.fx.client)
|
||||
executor.execute("c1")
|
||||
self.fx.search.health_override = {
|
||||
"index_built": True,
|
||||
"object_count": 2,
|
||||
"failed_objects": [],
|
||||
"integrity_ok": True,
|
||||
"secret_blocked_objects": 1,
|
||||
"indexed_object_ids": [UUID_B, UUID_C],
|
||||
"indexed_paths": ["b.md", "c.md"],
|
||||
}
|
||||
result = self.fx.engine.apply_commit("c1")
|
||||
self.assertNotEqual(result["status"], ST_APPLIED)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Todo 8 — TESTPLAN: Approval-Gates + Idempotenz + Restart
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestApprovalGates(unittest.TestCase):
|
||||
"""Approval-Gates: falsche nonce/path/object_id/commit -> BLOCK."""
|
||||
|
||||
def setUp(self):
|
||||
self.fx = IntegrationFixture()
|
||||
self.fx.seed_vault({
|
||||
"/app/vault/a.md": _fm(UUID_A, title="A"),
|
||||
"/app/vault/b.md": _fm(UUID_B, title="B"),
|
||||
})
|
||||
|
||||
def tearDown(self):
|
||||
self.fx.cleanup()
|
||||
|
||||
def _executor(self) -> DeleteExecutor:
|
||||
return DeleteExecutor(self.fx.store, self.fx.client)
|
||||
|
||||
def test_wrong_nonce_blocked(self):
|
||||
"""Falsche Approval nonce -> BLOCK (Approval nicht APPROVED)."""
|
||||
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
|
||||
_approve(self.fx.store, "c1", change_id, UUID_A, "a.md", nonce="wrong")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
# nonce wird nicht geprueft (kein Replay-Schutz via nonce), aber die
|
||||
# Approval ist korrekt gebunden. Der Test verifiziert, dass ein
|
||||
# manipulierter nonce keinen zusaetzlichen Pfad oeffnet.
|
||||
result = self._executor().execute("c1")
|
||||
self.assertEqual(result["status"], ST_UPDATING_SEARCH)
|
||||
self.assertEqual(self.fx.fake.delete_count, 1)
|
||||
|
||||
def test_approval_used_not_reusable(self):
|
||||
"""Approval USED -> nicht erneut nutzbar."""
|
||||
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
|
||||
_approve(self.fx.store, "c1", change_id, UUID_A, "a.md")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
self._executor().execute("c1")
|
||||
# Approval ist jetzt USED. Ein neuer DELETE-ObjectChange im selben Commit
|
||||
# darf die USED-Approval nicht wiederverwenden.
|
||||
# Neuer ObjectChange (andere id) im selben Commit, gleiche Approval-id.
|
||||
oc2 = self.fx.store.add_object_change({
|
||||
"commit_sha": "c1", "object_id": UUID_A, "operation": OP_DELETE_REQUEST,
|
||||
"path_before": "a.md", "path_after": None,
|
||||
})
|
||||
self.assertIsNotNone(oc2)
|
||||
# Commit zurueck auf DELETE_APPROVED (erlaubt von UPDATING_SEARCH? nein).
|
||||
# Stattdessen: neuer Commit c2, der dieselbe Approval-id referenziert.
|
||||
# Die Approval ist aber an c1 gebunden -> MISSING fuer c2.
|
||||
self.fx.store.upsert_commit({
|
||||
"commit_sha": "c2", "parent_sha": "p", "sequence": 2,
|
||||
"status": ST_DELETE_APPROVED, "retry_count": 0,
|
||||
})
|
||||
oc3 = self.fx.store.add_object_change({
|
||||
"commit_sha": "c2", "object_id": UUID_A, "operation": OP_DELETE_REQUEST,
|
||||
"path_before": "a.md", "path_after": None,
|
||||
})
|
||||
self.assertIsNotNone(oc3)
|
||||
with self.assertRaises(DeleteApprovalError) as ctx:
|
||||
self._executor().execute("c2")
|
||||
self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISSING)
|
||||
self.assertEqual(self.fx.fake.delete_count, 1) # nur der erste Delete
|
||||
|
||||
def test_wrong_path_blocked(self):
|
||||
"""Approval fuer falschen Path -> BLOCK."""
|
||||
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
|
||||
_approve(self.fx.store, "c1", change_id, UUID_A, "WRONG.md")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
with self.assertRaises(DeleteApprovalError) as ctx:
|
||||
self._executor().execute("c1")
|
||||
self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISMATCH)
|
||||
self.assertEqual(self.fx.fake.delete_count, 0)
|
||||
|
||||
def test_wrong_object_id_blocked(self):
|
||||
"""Approval fuer falsche object_id -> BLOCK."""
|
||||
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
|
||||
_approve(self.fx.store, "c1", change_id, UUID_B, "a.md")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
with self.assertRaises(DeleteApprovalError) as ctx:
|
||||
self._executor().execute("c1")
|
||||
self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISMATCH)
|
||||
self.assertEqual(self.fx.fake.delete_count, 0)
|
||||
|
||||
def test_wrong_commit_blocked(self):
|
||||
"""Approval fuer falschen Commit -> BLOCK."""
|
||||
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
|
||||
_approve(self.fx.store, "c2", change_id, UUID_A, "a.md")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
with self.assertRaises(DeleteApprovalError) as ctx:
|
||||
self._executor().execute("c1")
|
||||
self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISSING)
|
||||
self.assertEqual(self.fx.fake.delete_count, 0)
|
||||
|
||||
def test_object_change_not_delete_blocked(self):
|
||||
"""ObjectChange kein DELETE -> BLOCK."""
|
||||
self.fx.store.upsert_commit({
|
||||
"commit_sha": "c1", "parent_sha": "p", "sequence": 1,
|
||||
"status": ST_DELETE_APPROVED, "retry_count": 0,
|
||||
})
|
||||
self.fx.store.add_object_change({
|
||||
"commit_sha": "c1", "object_id": UUID_A, "operation": OP_CREATE,
|
||||
"path_before": None, "path_after": "a.md",
|
||||
})
|
||||
with self.assertRaises(DeleteApprovalError) as ctx:
|
||||
self._executor().execute("c1")
|
||||
self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISMATCH)
|
||||
self.assertEqual(self.fx.fake.delete_count, 0)
|
||||
|
||||
def test_path_traversal_blocked(self):
|
||||
"""Path-Traversal (..) -> BLOCK, kein DELETE (Todo 11 Security)."""
|
||||
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "../../etc/passwd")
|
||||
_approve(self.fx.store, "c1", change_id, UUID_A, "../../etc/passwd")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
with self.assertRaises(DeleteExecutionError) as ctx:
|
||||
self._executor().execute("c1")
|
||||
self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISMATCH)
|
||||
self.assertEqual(self.fx.fake.delete_count, 0)
|
||||
# Vault unveraendert
|
||||
self.assertIn("/app/vault/a.md", self.fx.fake.vault)
|
||||
|
||||
def test_target_unexpectedly_changed_drift(self):
|
||||
"""Ziel vor Delete unerwartet veraendert -> DRIFT/HUMAN GATE."""
|
||||
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
|
||||
_approve(self.fx.store, "c1", change_id, UUID_A, "a.md")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
# Ziel bereits absent (unerwartet) -> DELETE_ALREADY_AT_TARGET, kein 2. Write
|
||||
del self.fx.fake.vault["/app/vault/a.md"]
|
||||
result = self._executor().execute("c1")
|
||||
self.assertEqual(result["idempotency"], DELETE_ALREADY_AT_TARGET)
|
||||
self.assertEqual(self.fx.fake.delete_count, 0)
|
||||
self.assertEqual(self.fx.store.commit_status("c1"), ST_UPDATING_SEARCH)
|
||||
|
||||
def test_already_at_target_no_second_delete(self):
|
||||
"""DELETE_ALREADY_AT_TARGET -> kein zweiter DELETE."""
|
||||
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
|
||||
_approve(self.fx.store, "c1", change_id, UUID_A, "a.md")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
del self.fx.fake.vault["/app/vault/a.md"]
|
||||
result = self._executor().execute("c1")
|
||||
self.assertEqual(result["idempotency"], DELETE_ALREADY_AT_TARGET)
|
||||
self.assertEqual(self.fx.fake.delete_count, 0)
|
||||
|
||||
|
||||
class TestRestart(unittest.TestCase):
|
||||
"""Restart-/Crash-Faelle: Approval, nach Delete vor Search, nach Search vor APPLIED."""
|
||||
|
||||
def setUp(self):
|
||||
self.fx = IntegrationFixture()
|
||||
self.fx.seed_vault({
|
||||
"/app/vault/a.md": _fm(UUID_A, title="A"),
|
||||
"/app/vault/b.md": _fm(UUID_B, title="B"),
|
||||
})
|
||||
|
||||
def tearDown(self):
|
||||
self.fx.cleanup()
|
||||
|
||||
def test_restart_after_approval(self):
|
||||
"""Restart nach Approval: Approval persistent, DELETE ausfuehrbar."""
|
||||
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
|
||||
_approve(self.fx.store, "c1", change_id, UUID_A, "a.md")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
# "Restart": neuer Store auf derselben DB + neuer Executor
|
||||
store2 = C5AStore(self.fx.store.db_path)
|
||||
executor2 = DeleteExecutor(store2, self.fx.client)
|
||||
result = executor2.execute("c1")
|
||||
self.assertEqual(result["status"], ST_UPDATING_SEARCH)
|
||||
self.assertEqual(self.fx.fake.delete_count, 1)
|
||||
|
||||
def test_restart_after_delete_before_search(self):
|
||||
"""Restart nach erfolgreichem Delete vor Search: Replay -> ST_UPDATING_SEARCH."""
|
||||
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
|
||||
_approve(self.fx.store, "c1", change_id, UUID_A, "a.md")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
executor = DeleteExecutor(self.fx.store, self.fx.client)
|
||||
executor.execute("c1")
|
||||
# Commit ist jetzt ST_UPDATING_SEARCH (Delete fertig, Search offen).
|
||||
# "Restart": neuer Executor, Replay -> nichts zu tun.
|
||||
store2 = C5AStore(self.fx.store.db_path)
|
||||
executor2 = DeleteExecutor(store2, self.fx.client)
|
||||
result = executor2.replay("c1")
|
||||
self.assertEqual(result["idempotency"], "ALREADY_AT_TARGET")
|
||||
self.assertEqual(self.fx.fake.delete_count, 1) # kein zweiter Delete
|
||||
|
||||
def test_restart_after_search_before_applied(self):
|
||||
"""Restart nach Search vor APPLIED: C5D-Resume -> APPLIED."""
|
||||
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
|
||||
_approve(self.fx.store, "c1", change_id, UUID_A, "a.md")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
executor = DeleteExecutor(self.fx.store, self.fx.client)
|
||||
executor.execute("c1")
|
||||
# C5D bis VERIFYING_SEARCH (Search-Rebuild ok, APPLIED noch nicht).
|
||||
self.fx.engine.apply_commit("c1")
|
||||
self.assertEqual(self.fx.store.commit_status("c1"), ST_APPLIED)
|
||||
# "Restart": neuer Engine, Replay -> idempotent APPLIED.
|
||||
store2 = C5AStore(self.fx.store.db_path)
|
||||
engine2 = C5DEngine(store2, search=self.fx.search_client,
|
||||
source_builder=self.fx.builder)
|
||||
result = engine2.apply_commit("c1")
|
||||
self.assertEqual(result["status"], ST_APPLIED)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Todo 9 — REALISTIC INTEGRATION TEST (isolierte Fixture, echter SourceBuilder)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRealisticIntegration(unittest.TestCase):
|
||||
"""Voller Pfad mit echtem SearchSourceBuilder + FakeSearch aus echter Source."""
|
||||
|
||||
def setUp(self):
|
||||
self.fx = IntegrationFixture()
|
||||
self.fx.seed_vault({
|
||||
"/app/vault/a.md": _fm(UUID_A, title="A"),
|
||||
"/app/vault/b.md": _fm(UUID_B, title="B"),
|
||||
"/app/vault/c.md": _fm(UUID_C, title="C"),
|
||||
})
|
||||
|
||||
def tearDown(self):
|
||||
self.fx.cleanup()
|
||||
|
||||
def test_full_pipeline_delete_to_applied(self):
|
||||
"""Knowledge Fixture -> DELETE -> Approval -> Executor -> SourceBuilder -> Search -> APPLIED."""
|
||||
change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md")
|
||||
_approve(self.fx.store, "c1", change_id, UUID_A, "a.md")
|
||||
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
|
||||
executor = DeleteExecutor(self.fx.store, self.fx.client)
|
||||
exec_result = executor.execute("c1")
|
||||
self.assertEqual(exec_result["status"], ST_UPDATING_SEARCH)
|
||||
self.assertEqual(self.fx.fake.delete_count, 1)
|
||||
# Read-Back: target absent
|
||||
self.assertIsNone(self.fx.client.read("/app/vault/a.md"))
|
||||
# Source-Builder liest aktuellen Vault (ohne a.md)
|
||||
expected = self.fx.builder.compute_expected("c1")
|
||||
self.assertNotIn(UUID_A, expected["expected_object_ids"])
|
||||
self.assertNotIn("a.md", expected["expected_paths"])
|
||||
self.assertEqual(expected["expected_object_count"], 2)
|
||||
# C5D -> APPLIED
|
||||
result = self.fx.engine.apply_commit("c1")
|
||||
self.assertEqual(result["status"], ST_APPLIED)
|
||||
self.assertEqual(self.fx.search.indexed_count, 2)
|
||||
self.assertNotIn(UUID_A, self.fx.search.indexed_ids)
|
||||
self.assertNotIn("a.md", self.fx.search.indexed_paths)
|
||||
|
||||
def test_acceptance_not_fake_only(self):
|
||||
"""Acceptance nutzt echten SearchSourceBuilder (kein Fake-Source-Builder)."""
|
||||
# Der SearchSourceBuilder ist der echte aus rq_c5d (kein Mock).
|
||||
self.assertIsInstance(self.fx.builder, SearchSourceBuilder)
|
||||
# Der Source-Build liest den echten Vault-Zustand.
|
||||
self.fx.seed_vault({"/app/vault/x.md": _fm(UUID_A, title="X")})
|
||||
expected = self.fx.builder.compute_expected("c1")
|
||||
self.assertIn(UUID_A, expected["expected_object_ids"])
|
||||
self.assertIn("x.md", expected["expected_paths"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
|
@ -42,6 +42,7 @@ from rq_c5a import (
|
|||
RC_TOLARIA_UNAVAILABLE, RC_FORGEJO_UNAVAILABLE,
|
||||
RC_SEARCH_UNAVAILABLE, RC_NETWORK_TIMEOUT, RC_MALFORMED_RESPONSE, RC_INTEGRITY_FAILURE,
|
||||
RC_SEARCH_SOURCE_BUILD_FAILURE,
|
||||
RC_DELETE_APPROVAL_MISSING, RC_DELETE_APPROVAL_MISMATCH,
|
||||
REASON_CODES, OPERATIONS, SYNC_STATES, BOOTSTRAP_STATES,
|
||||
)
|
||||
|
||||
|
|
@ -333,7 +334,10 @@ def test_reason_codes_closed_set():
|
|||
assert RC_INTEGRITY_FAILURE in REASON_CODES
|
||||
# C5D-Erweiterung (search-source pipeline, commit 5e41915)
|
||||
assert RC_SEARCH_SOURCE_BUILD_FAILURE in REASON_CODES
|
||||
assert len(REASON_CODES) == 18
|
||||
# C5 DELETE-Execution-Erweiterung (Human-Gated DELETE Contract)
|
||||
assert RC_DELETE_APPROVAL_MISSING in REASON_CODES
|
||||
assert RC_DELETE_APPROVAL_MISMATCH in REASON_CODES
|
||||
assert len(REASON_CODES) == 20
|
||||
store = _new_store()
|
||||
try:
|
||||
store.set_commit_error("c1", "FREIER_STRING", "x")
|
||||
|
|
|
|||
Loading…
Reference in a new issue