mirror of
https://github.com/syntrex-lab/gomcp.git
synced 2026-04-24 20:06:21 +02:00
443 lines
12 KiB
Go
443 lines
12 KiB
Go
// 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)
|
||
}
|
||
}
|