gomcp/internal/application/resilience/preservation_test.go

443 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright 2026 Syntrex Lab. All rights reserved.
// Use of this source code is governed by an Apache-2.0 license
// that can be found in the LICENSE file.
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)
}
}