gomcp/internal/application/resilience/preservation_test.go

440 lines
12 KiB
Go
Raw Normal View History

package resilience
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"testing"
"time"
)
// --- Mock action function ---
type modeActionLog struct {
calls []struct {
mode EmergencyMode
action string
}
failAction string // if set, this action will fail
}
func newModeActionLog() *modeActionLog {
return &modeActionLog{}
}
func (m *modeActionLog) execute(mode EmergencyMode, action string, _ map[string]interface{}) error {
m.calls = append(m.calls, struct {
mode EmergencyMode
action string
}{mode, action})
if m.failAction == action {
return errActionFailed
}
return nil
}
var errActionFailed = &actionError{"simulated failure"}
type actionError struct{ msg string }
func (e *actionError) Error() string { return e.msg }
// --- Preservation Engine Tests ---
// SP-01: Safe mode activation.
func TestPreservation_SP01_SafeMode(t *testing.T) {
log := newModeActionLog()
pe := NewPreservationEngine(log.execute)
err := pe.ActivateMode(ModeSafe, "quorum lost (3/6 offline)", "auto")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pe.CurrentMode() != ModeSafe {
t.Errorf("expected SAFE, got %s", pe.CurrentMode())
}
activation := pe.Activation()
if activation == nil {
t.Fatal("expected activation details")
}
if !activation.AutoExit {
t.Error("safe mode should have auto-exit enabled")
}
// Should have executed safe mode actions.
if len(log.calls) == 0 {
t.Error("expected mode actions to be executed")
}
// First action should be disable_non_essential_services.
if log.calls[0].action != "disable_non_essential_services" {
t.Errorf("expected first action disable_non_essential_services, got %s", log.calls[0].action)
}
}
// SP-02: Lockdown mode activation.
func TestPreservation_SP02_LockdownMode(t *testing.T) {
log := newModeActionLog()
pe := NewPreservationEngine(log.execute)
err := pe.ActivateMode(ModeLockdown, "binary tampering detected", "auto")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pe.CurrentMode() != ModeLockdown {
t.Errorf("expected LOCKDOWN, got %s", pe.CurrentMode())
}
// Should have network isolation action.
foundIsolate := false
for _, c := range log.calls {
if c.action == "isolate_from_network" {
foundIsolate = true
}
}
if !foundIsolate {
t.Error("expected isolate_from_network in lockdown actions")
}
}
// SP-03: Apoptosis mode activation.
func TestPreservation_SP03_ApoptosisMode(t *testing.T) {
log := newModeActionLog()
pe := NewPreservationEngine(log.execute)
err := pe.ActivateMode(ModeApoptosis, "rootkit detected", "architect:admin")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pe.CurrentMode() != ModeApoptosis {
t.Errorf("expected APOPTOSIS, got %s", pe.CurrentMode())
}
// Should have graceful_shutdown action.
foundShutdown := false
for _, c := range log.calls {
if c.action == "graceful_shutdown" {
foundShutdown = true
}
}
if !foundShutdown {
t.Error("expected graceful_shutdown in apoptosis actions")
}
// Cannot deactivate apoptosis.
err = pe.DeactivateMode("architect:admin")
if err == nil {
t.Error("expected error deactivating apoptosis")
}
}
// SP-04: Invalid transition (downgrade).
func TestPreservation_SP04_InvalidTransition(t *testing.T) {
log := newModeActionLog()
pe := NewPreservationEngine(log.execute)
pe.ActivateMode(ModeLockdown, "test", "auto")
// Can't downgrade from LOCKDOWN to SAFE.
err := pe.ActivateMode(ModeSafe, "test downgrade", "auto")
if err == nil {
t.Error("expected error on downgrade from LOCKDOWN to SAFE")
}
}
// SP-05: Escalation (SAFE → LOCKDOWN → APOPTOSIS).
func TestPreservation_SP05_Escalation(t *testing.T) {
log := newModeActionLog()
pe := NewPreservationEngine(log.execute)
pe.ActivateMode(ModeSafe, "quorum lost", "auto")
if pe.CurrentMode() != ModeSafe {
t.Fatal("expected SAFE")
}
pe.ActivateMode(ModeLockdown, "compromise detected", "auto")
if pe.CurrentMode() != ModeLockdown {
t.Fatal("expected LOCKDOWN")
}
pe.ActivateMode(ModeApoptosis, "rootkit", "auto")
if pe.CurrentMode() != ModeApoptosis {
t.Fatal("expected APOPTOSIS")
}
}
// SP-06: Safe mode auto-exit.
func TestPreservation_SP06_AutoExit(t *testing.T) {
log := newModeActionLog()
pe := NewPreservationEngine(log.execute)
pe.ActivateMode(ModeSafe, "test", "auto")
// Not yet time.
if pe.ShouldAutoExit() {
t.Error("should not auto-exit immediately")
}
// Fast-forward activation's auto_exit_at.
pe.mu.Lock()
pe.activation.AutoExitAt = time.Now().Add(-1 * time.Second)
pe.mu.Unlock()
if !pe.ShouldAutoExit() {
t.Error("should auto-exit after timer expired")
}
}
// SP-07: Manual deactivation of safe mode.
func TestPreservation_SP07_ManualDeactivate(t *testing.T) {
log := newModeActionLog()
pe := NewPreservationEngine(log.execute)
pe.ActivateMode(ModeSafe, "test", "auto")
err := pe.DeactivateMode("architect:admin")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pe.CurrentMode() != ModeNone {
t.Errorf("expected NONE, got %s", pe.CurrentMode())
}
}
// SP-08: Lockdown deactivation.
func TestPreservation_SP08_LockdownDeactivate(t *testing.T) {
log := newModeActionLog()
pe := NewPreservationEngine(log.execute)
pe.ActivateMode(ModeLockdown, "test", "auto")
err := pe.DeactivateMode("architect:admin")
if err != nil {
t.Fatalf("lockdown deactivation should succeed: %v", err)
}
}
// SP-09: History audit log.
func TestPreservation_SP09_AuditHistory(t *testing.T) {
log := newModeActionLog()
pe := NewPreservationEngine(log.execute)
pe.ActivateMode(ModeSafe, "test", "auto")
pe.DeactivateMode("admin")
history := pe.History()
if len(history) == 0 {
t.Error("expected audit history entries")
}
// Last entry should be deactivation.
last := history[len(history)-1]
if last.Action != "deactivated" {
t.Errorf("expected deactivated, got %s", last.Action)
}
}
// SP-10: Action failure in non-apoptosis mode aborts.
func TestPreservation_SP10_ActionFailure(t *testing.T) {
log := newModeActionLog()
log.failAction = "disable_non_essential_services"
pe := NewPreservationEngine(log.execute)
err := pe.ActivateMode(ModeSafe, "test", "auto")
if err == nil {
t.Error("expected error when safe mode action fails")
}
// Mode should not have changed due to failure.
if pe.CurrentMode() != ModeNone {
t.Errorf("expected NONE after failed activation, got %s", pe.CurrentMode())
}
}
// SP-10b: Action failure in apoptosis mode continues.
func TestPreservation_SP10b_ApoptosisActionFailure(t *testing.T) {
log := newModeActionLog()
log.failAction = "graceful_shutdown"
pe := NewPreservationEngine(log.execute)
// Apoptosis should continue despite action failures.
err := pe.ActivateMode(ModeApoptosis, "rootkit", "auto")
if err != nil {
t.Fatalf("apoptosis should not fail on action errors: %v", err)
}
if pe.CurrentMode() != ModeApoptosis {
t.Errorf("expected APOPTOSIS, got %s", pe.CurrentMode())
}
}
// Test ModeNone activation rejected.
func TestPreservation_ModeNoneRejected(t *testing.T) {
pe := NewPreservationEngine(func(_ EmergencyMode, _ string, _ map[string]interface{}) error { return nil })
err := pe.ActivateMode(ModeNone, "test", "auto")
if err == nil {
t.Error("expected error activating ModeNone")
}
}
// Test deactivate when already NONE.
func TestPreservation_DeactivateNone(t *testing.T) {
pe := NewPreservationEngine(func(_ EmergencyMode, _ string, _ map[string]interface{}) error { return nil })
err := pe.DeactivateMode("admin")
if err != nil {
t.Errorf("deactivating NONE should be no-op: %v", err)
}
}
// Test ShouldAutoExit when not in safe mode.
func TestPreservation_AutoExitNotSafe(t *testing.T) {
pe := NewPreservationEngine(func(_ EmergencyMode, _ string, _ map[string]interface{}) error { return nil })
if pe.ShouldAutoExit() {
t.Error("should not auto-exit when mode is NONE")
}
}
// --- Integrity Verifier Tests ---
// SP-04 (ТЗ): Binary integrity check — hash mismatch.
func TestIntegrity_BinaryMismatch(t *testing.T) {
tmpDir := t.TempDir()
binPath := filepath.Join(tmpDir, "test-binary")
os.WriteFile(binPath, []byte("original content"), 0o644)
// Calculate correct hash.
h := sha256.Sum256([]byte("original content"))
correctHash := hex.EncodeToString(h[:])
iv := NewIntegrityVerifier([]byte("test-key"))
iv.RegisterBinary(binPath, correctHash)
// Verify (should pass).
report := iv.VerifyAll()
if report.Overall != IntegrityVerified {
t.Errorf("expected VERIFIED, got %s", report.Overall)
}
// Tamper with the binary.
os.WriteFile(binPath, []byte("tampered content"), 0o644)
// Verify (should fail).
report = iv.VerifyAll()
if report.Overall != IntegrityCompromised {
t.Errorf("expected COMPROMISED, got %s", report.Overall)
}
bs := report.Binaries[binPath]
if bs.Status != IntegrityCompromised {
t.Errorf("expected binary COMPROMISED, got %s", bs.Status)
}
}
// Binary not found.
func TestIntegrity_BinaryNotFound(t *testing.T) {
iv := NewIntegrityVerifier([]byte("test-key"))
iv.RegisterBinary("/nonexistent/binary", "abc123")
report := iv.VerifyAll()
bs := report.Binaries["/nonexistent/binary"]
if bs.Status != IntegrityUnknown {
t.Errorf("expected UNKNOWN for missing binary, got %s", bs.Status)
}
}
// Config HMAC computation.
func TestIntegrity_ConfigHMAC(t *testing.T) {
tmpDir := t.TempDir()
cfgPath := filepath.Join(tmpDir, "config.yaml")
os.WriteFile(cfgPath, []byte("server:\n port: 8080"), 0o644)
iv := NewIntegrityVerifier([]byte("hmac-key"))
iv.RegisterConfig(cfgPath)
report := iv.VerifyAll()
cs := report.Configs[cfgPath]
if !cs.Valid {
t.Errorf("expected valid config, got error: %s", cs.Error)
}
if cs.CurrentHMAC == "" {
t.Error("expected non-empty HMAC")
}
}
// Config file unreadable.
func TestIntegrity_ConfigUnreadable(t *testing.T) {
iv := NewIntegrityVerifier([]byte("key"))
iv.RegisterConfig("/nonexistent/config.yaml")
report := iv.VerifyAll()
cs := report.Configs["/nonexistent/config.yaml"]
if cs.Valid {
t.Error("expected invalid for unreadable config")
}
}
// Decision chain — file does not exist (OK, no chain yet).
func TestIntegrity_ChainNotExist(t *testing.T) {
iv := NewIntegrityVerifier([]byte("key"))
iv.SetChainPath("/nonexistent/decisions.log")
report := iv.VerifyAll()
if report.Chain == nil {
t.Fatal("expected chain status")
}
if !report.Chain.Valid {
t.Error("nonexistent chain should be valid (no entries)")
}
}
// Decision chain — file exists.
func TestIntegrity_ChainExists(t *testing.T) {
tmpDir := t.TempDir()
chainPath := filepath.Join(tmpDir, "decisions.log")
os.WriteFile(chainPath, []byte("entry1\nentry2\n"), 0o644)
iv := NewIntegrityVerifier([]byte("key"))
iv.SetChainPath(chainPath)
report := iv.VerifyAll()
if report.Chain == nil {
t.Fatal("expected chain status")
}
if !report.Chain.Valid {
t.Error("expected valid chain")
}
}
// LastReport.
func TestIntegrity_LastReport(t *testing.T) {
iv := NewIntegrityVerifier([]byte("key"))
if iv.LastReport() != nil {
t.Error("expected nil before first verify")
}
iv.VerifyAll()
if iv.LastReport() == nil {
t.Error("expected report after verify")
}
}
// Pluggable integrity check in PreservationEngine.
func TestPreservation_IntegrityCheck(t *testing.T) {
pe := NewPreservationEngine(func(_ EmergencyMode, _ string, _ map[string]interface{}) error { return nil })
// Default: no integrity fn → VERIFIED.
report := pe.CheckIntegrity()
if report.Overall != IntegrityVerified {
t.Errorf("expected VERIFIED, got %s", report.Overall)
}
// Set custom checker.
pe.SetIntegrityCheck(func() IntegrityReport {
return IntegrityReport{Overall: IntegrityCompromised, Timestamp: time.Now()}
})
report = pe.CheckIntegrity()
if report.Overall != IntegrityCompromised {
t.Errorf("expected COMPROMISED from custom checker, got %s", report.Overall)
}
}