mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-08 07:13:53 +02:00
296 lines
11 KiB
Python
296 lines
11 KiB
Python
"""Sprint 6 — shutdown lifecycle.
|
|
|
|
Maps to IMPLEMENTATION_PLAN §7.1 Sprint 6 row:
|
|
"Shutdown requires active suspension. Appeal pauses execution timer.
|
|
Anti-stall: one appeal per shutdown, 48h window."
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from services.infonet.config import CONFIG
|
|
from services.infonet.gates import (
|
|
compute_shutdown_state,
|
|
compute_suspension_state,
|
|
paused_execution_remaining_sec,
|
|
validate_appeal_filing,
|
|
validate_shutdown_filing,
|
|
validate_suspend_filing,
|
|
)
|
|
from services.infonet.tests._gate_factory import (
|
|
make_appeal_file,
|
|
make_appeal_resolve,
|
|
make_gate_create,
|
|
make_gate_enter,
|
|
make_shutdown_execute,
|
|
make_shutdown_file,
|
|
make_shutdown_vote,
|
|
make_suspend_execute,
|
|
make_suspend_file,
|
|
make_unsuspend,
|
|
)
|
|
|
|
|
|
_SECOND = 1.0
|
|
_HOUR = 3600.0
|
|
_DAY = 86400.0
|
|
|
|
|
|
def _build(base_ts: float, gate_id: str = "g1") -> list:
|
|
return [
|
|
make_gate_create(gate_id, "creator", ts=base_ts, seq=1),
|
|
make_gate_enter(gate_id, "alice", ts=base_ts + 100, seq=2),
|
|
make_gate_enter(gate_id, "bob", ts=base_ts + 200, seq=3),
|
|
]
|
|
|
|
|
|
# ── Suspension lifecycle ────────────────────────────────────────────────
|
|
|
|
def test_unsuspended_gate_status_active():
|
|
base = 1_000_000.0
|
|
chain = _build(base)
|
|
state = compute_suspension_state("g1", chain, now=base + 1000)
|
|
assert state.status == "active"
|
|
|
|
|
|
def test_suspended_gate_status_suspended_until_window_elapses():
|
|
base = 1_000_000.0
|
|
chain = _build(base) + [
|
|
make_suspend_file("g1", "filer", "p1", ts=base + 1000, seq=10),
|
|
make_suspend_execute("g1", "p1", ts=base + 2000, seq=11),
|
|
]
|
|
duration_s = float(CONFIG["gate_suspend_duration_days"]) * _DAY
|
|
|
|
# Inside window.
|
|
state = compute_suspension_state("g1", chain, now=base + 2000 + 1000)
|
|
assert state.status == "suspended"
|
|
assert state.suspended_until == base + 2000 + duration_s
|
|
|
|
# After window elapses (auto-unsuspend even without explicit event).
|
|
state = compute_suspension_state("g1", chain, now=base + 2000 + duration_s + 1)
|
|
assert state.status == "active"
|
|
|
|
|
|
def test_explicit_unsuspend_returns_to_active():
|
|
base = 1_000_000.0
|
|
chain = _build(base) + [
|
|
make_suspend_file("g1", "filer", "p1", ts=base + 1000, seq=10),
|
|
make_suspend_execute("g1", "p1", ts=base + 2000, seq=11),
|
|
make_unsuspend("g1", ts=base + 3000, seq=12),
|
|
]
|
|
state = compute_suspension_state("g1", chain, now=base + 4000)
|
|
assert state.status == "active"
|
|
|
|
|
|
def test_validate_suspend_rejects_empty_reason():
|
|
base = 1_000_000.0
|
|
chain = _build(base)
|
|
decision = validate_suspend_filing(
|
|
"g1", "filer", reason="", evidence_hashes=["e1"],
|
|
chain=chain, now=base + 1000,
|
|
)
|
|
assert not decision.accepted
|
|
assert decision.reason == "reason_empty"
|
|
|
|
|
|
def test_validate_suspend_rejects_no_evidence():
|
|
base = 1_000_000.0
|
|
chain = _build(base)
|
|
decision = validate_suspend_filing(
|
|
"g1", "filer", reason="abuse", evidence_hashes=[],
|
|
chain=chain, now=base + 1000,
|
|
)
|
|
assert not decision.accepted
|
|
assert decision.reason == "evidence_required"
|
|
|
|
|
|
def test_validate_suspend_rejects_already_suspended():
|
|
base = 1_000_000.0
|
|
chain = _build(base) + [
|
|
make_suspend_file("g1", "f1", "p1", ts=base + 1000, seq=10),
|
|
make_suspend_execute("g1", "p1", ts=base + 2000, seq=11),
|
|
]
|
|
decision = validate_suspend_filing(
|
|
"g1", "f2", reason="abuse", evidence_hashes=["e1"],
|
|
chain=chain, now=base + 3000,
|
|
)
|
|
assert not decision.accepted
|
|
assert decision.reason == "already_suspended"
|
|
|
|
|
|
def test_validate_suspend_rejects_filer_cooldown():
|
|
base = 1_000_000.0
|
|
chain = _build(base)
|
|
decision = validate_suspend_filing(
|
|
"g1", "filer", reason="abuse", evidence_hashes=["e1"],
|
|
chain=chain, now=base + 1000,
|
|
filer_cooldown_until=base + 5000,
|
|
)
|
|
assert not decision.accepted
|
|
assert decision.reason == "filer_cooldown_active"
|
|
|
|
|
|
# ── Shutdown requires active suspension ─────────────────────────────────
|
|
|
|
def test_shutdown_filing_rejected_when_gate_not_suspended():
|
|
base = 1_000_000.0
|
|
chain = _build(base)
|
|
decision = validate_shutdown_filing(
|
|
"g1", "filer", reason="bad", evidence_hashes=["e1"],
|
|
chain=chain, now=base + 1000,
|
|
)
|
|
assert not decision.accepted
|
|
assert decision.reason == "gate_not_suspended"
|
|
|
|
|
|
def test_shutdown_filing_accepted_when_suspended():
|
|
base = 1_000_000.0
|
|
chain = _build(base) + [
|
|
make_suspend_file("g1", "f1", "sp1", ts=base + 1000, seq=10),
|
|
make_suspend_execute("g1", "sp1", ts=base + 2000, seq=11),
|
|
]
|
|
decision = validate_shutdown_filing(
|
|
"g1", "filer", reason="still bad", evidence_hashes=["e1"],
|
|
chain=chain, now=base + 3000,
|
|
)
|
|
assert decision.accepted
|
|
|
|
|
|
def test_shutdown_filing_rejected_when_already_shutdown():
|
|
base = 1_000_000.0
|
|
chain = _build(base) + [
|
|
make_suspend_file("g1", "f1", "sp1", ts=base + 1000, seq=10),
|
|
make_suspend_execute("g1", "sp1", ts=base + 2000, seq=11),
|
|
make_shutdown_file("g1", "f2", "shp1", ts=base + 3000, seq=20),
|
|
make_shutdown_vote("g1", "shp1", "passed", ts=base + 4000, seq=21),
|
|
make_shutdown_execute("g1", "shp1", ts=base + 5000, seq=22),
|
|
]
|
|
decision = validate_shutdown_filing(
|
|
"g1", "filer", reason="too late", evidence_hashes=["e1"],
|
|
chain=chain, now=base + 6000,
|
|
)
|
|
assert not decision.accepted
|
|
assert decision.reason == "gate_already_shutdown"
|
|
|
|
|
|
def test_shutdown_state_executing_after_vote_passes():
|
|
base = 1_000_000.0
|
|
chain = _build(base) + [
|
|
make_suspend_file("g1", "f1", "sp1", ts=base + 1000, seq=10),
|
|
make_suspend_execute("g1", "sp1", ts=base + 2000, seq=11),
|
|
make_shutdown_file("g1", "f2", "shp1", ts=base + 3000, seq=20),
|
|
make_shutdown_vote("g1", "shp1", "passed", ts=base + 4000, seq=21),
|
|
]
|
|
state = compute_shutdown_state("g1", chain, now=base + 4500)
|
|
assert state.has_pending
|
|
assert state.pending_status == "executing"
|
|
delay_s = float(CONFIG["gate_shutdown_execution_delay_days"]) * _DAY
|
|
assert state.execution_at == base + 4000 + delay_s
|
|
|
|
|
|
# ── Appeal pauses timer + anti-stall ────────────────────────────────────
|
|
|
|
def test_appeal_filing_pauses_execution_timer():
|
|
base = 1_000_000.0
|
|
chain = _build(base) + [
|
|
make_suspend_file("g1", "f1", "sp1", ts=base + 1000, seq=10),
|
|
make_suspend_execute("g1", "sp1", ts=base + 2000, seq=11),
|
|
make_shutdown_file("g1", "f2", "shp1", ts=base + 3000, seq=20),
|
|
make_shutdown_vote("g1", "shp1", "passed", ts=base + 4000, seq=21),
|
|
make_appeal_file("g1", "shp1", "filer", "ap1",
|
|
ts=base + 4000 + _HOUR, seq=22),
|
|
]
|
|
state = compute_shutdown_state("g1", chain, now=base + 4000 + 2 * _HOUR)
|
|
assert state.pending_status == "appealed"
|
|
assert state.execution_at is None # paused
|
|
|
|
|
|
def test_appeal_outside_48h_window_rejected():
|
|
base = 1_000_000.0
|
|
chain = _build(base) + [
|
|
make_suspend_file("g1", "f1", "sp1", ts=base + 1000, seq=10),
|
|
make_suspend_execute("g1", "sp1", ts=base + 2000, seq=11),
|
|
make_shutdown_file("g1", "f2", "shp1", ts=base + 3000, seq=20),
|
|
make_shutdown_vote("g1", "shp1", "passed", ts=base + 4000, seq=21),
|
|
]
|
|
window_s = float(CONFIG["gate_shutdown_appeal_window_hours"]) * _HOUR
|
|
too_late = base + 4000 + window_s + 1
|
|
decision = validate_appeal_filing(
|
|
"g1", "shp1", "filer",
|
|
reason="appeal", evidence_hashes=["e1"],
|
|
chain=chain, now=too_late,
|
|
)
|
|
assert not decision.accepted
|
|
assert decision.reason == "appeal_window_expired"
|
|
|
|
|
|
def test_one_appeal_per_shutdown_anti_stall():
|
|
base = 1_000_000.0
|
|
chain = _build(base) + [
|
|
make_suspend_file("g1", "f1", "sp1", ts=base + 1000, seq=10),
|
|
make_suspend_execute("g1", "sp1", ts=base + 2000, seq=11),
|
|
make_shutdown_file("g1", "f2", "shp1", ts=base + 3000, seq=20),
|
|
make_shutdown_vote("g1", "shp1", "passed", ts=base + 4000, seq=21),
|
|
make_appeal_file("g1", "shp1", "filer", "ap1",
|
|
ts=base + 4000 + _HOUR, seq=22),
|
|
]
|
|
decision = validate_appeal_filing(
|
|
"g1", "shp1", "filer2",
|
|
reason="another appeal", evidence_hashes=["e2"],
|
|
chain=chain, now=base + 4000 + 2 * _HOUR,
|
|
)
|
|
assert not decision.accepted
|
|
assert decision.reason == "appeal_already_filed"
|
|
|
|
|
|
def test_paused_execution_remaining_sec_correct():
|
|
base = 1_000_000.0
|
|
chain = _build(base) + [
|
|
make_suspend_file("g1", "f1", "sp1", ts=base + 1000, seq=10),
|
|
make_suspend_execute("g1", "sp1", ts=base + 2000, seq=11),
|
|
make_shutdown_file("g1", "f2", "shp1", ts=base + 3000, seq=20),
|
|
make_shutdown_vote("g1", "shp1", "passed", ts=base + 4000, seq=21),
|
|
]
|
|
delay_s = float(CONFIG["gate_shutdown_execution_delay_days"]) * _DAY
|
|
appeal_at = base + 4000 + 24 * _HOUR # 1 day into the 7-day execution window
|
|
remaining = paused_execution_remaining_sec("shp1", chain, appeal_filed_at=appeal_at)
|
|
expected = (base + 4000 + delay_s) - appeal_at
|
|
assert abs(remaining - expected) < 1e-6
|
|
|
|
|
|
def test_appeal_resolve_voided_status_terminal():
|
|
base = 1_000_000.0
|
|
chain = _build(base) + [
|
|
make_suspend_file("g1", "f1", "sp1", ts=base + 1000, seq=10),
|
|
make_suspend_execute("g1", "sp1", ts=base + 2000, seq=11),
|
|
make_shutdown_file("g1", "f2", "shp1", ts=base + 3000, seq=20),
|
|
make_shutdown_vote("g1", "shp1", "passed", ts=base + 4000, seq=21),
|
|
make_appeal_file("g1", "shp1", "filer", "ap1",
|
|
ts=base + 4000 + _HOUR, seq=22),
|
|
make_appeal_resolve("g1", "ap1", "shp1", "voided_shutdown",
|
|
ts=base + 4000 + 7 * _DAY, seq=23),
|
|
]
|
|
state = compute_shutdown_state("g1", chain, now=base + 5_000_000.0)
|
|
# Petition is no longer pending (voided_appeal is terminal).
|
|
assert not state.has_pending
|
|
|
|
|
|
def test_appeal_resolve_resumed_uses_new_execution_at():
|
|
base = 1_000_000.0
|
|
appeal_at = base + 4000 + _HOUR
|
|
resume_at = base + 4000 + 8 * _DAY # arbitrary later moment
|
|
new_execution_at = resume_at + 6 * _DAY
|
|
chain = _build(base) + [
|
|
make_suspend_file("g1", "f1", "sp1", ts=base + 1000, seq=10),
|
|
make_suspend_execute("g1", "sp1", ts=base + 2000, seq=11),
|
|
make_shutdown_file("g1", "f2", "shp1", ts=base + 3000, seq=20),
|
|
make_shutdown_vote("g1", "shp1", "passed", ts=base + 4000, seq=21),
|
|
make_appeal_file("g1", "shp1", "filer", "ap1",
|
|
ts=appeal_at, seq=22),
|
|
make_appeal_resolve("g1", "ap1", "shp1", "resumed",
|
|
ts=resume_at, seq=23,
|
|
resumed_execution_at=new_execution_at),
|
|
]
|
|
state = compute_shutdown_state("g1", chain, now=resume_at + 1)
|
|
assert state.pending_status == "executing"
|
|
assert state.execution_at == new_execution_at
|