All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 36s
CI/CD / test-python-backend-compliance (push) Successful in 36s
CI/CD / test-python-document-crawler (push) Successful in 22s
CI/CD / test-python-dsms-gateway (push) Successful in 18s
CI/CD / validate-canonical-controls (push) Successful in 12s
CI/CD / Deploy (push) Successful in 2s
Add dual-mode risk engine: legacy S×E×P (avoidance=0) and ISO mode S×F×P×A (avoidance>=1) with new thresholds (low/medium/high/very_high/not_acceptable). - 150+ hazard library entries across 28 categories incl. physical hazards (mechanical, electrical, thermal, pneumatic/hydraulic, noise/vibration, ergonomic, material/environmental) - 160-entry protective measures library with 3-step hierarchy validation (design → protective → information) - 25 lifecycle phases, 20 affected person roles, 50 evidence types - 10 verification methods (expanded from 7) - New API endpoints: lifecycle-phases, roles, evidence-types, protective-measures-library, validate-mitigation-hierarchy - DB migrations 018+019 for extended schema - Frontend: 4-slider risk assessment, hierarchy warnings, measures library modal - MkDocs wiki updated with ISO mode docs and legal notice (no norm text) All content uses original wording — norms referenced as methodology only. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1173 lines
39 KiB
Go
1173 lines
39 KiB
Go
package iace
|
||
|
||
import (
|
||
"math"
|
||
"testing"
|
||
)
|
||
|
||
// ============================================================================
|
||
// Helper
|
||
// ============================================================================
|
||
|
||
const floatTolerance = 1e-9
|
||
|
||
func almostEqual(a, b float64) bool {
|
||
return math.Abs(a-b) < floatTolerance
|
||
}
|
||
|
||
// ============================================================================
|
||
// 1. CalculateInherentRisk — S × E × P
|
||
// ============================================================================
|
||
|
||
func TestCalculateInherentRisk_BasicCases(t *testing.T) {
|
||
e := NewRiskEngine()
|
||
|
||
tests := []struct {
|
||
name string
|
||
s, ex, p int
|
||
expected float64
|
||
}{
|
||
// Minimum
|
||
{"min 1×1×1", 1, 1, 1, 1},
|
||
// Maximum
|
||
{"max 5×5×5", 5, 5, 5, 125},
|
||
// Single factor high
|
||
{"5×1×1", 5, 1, 1, 5},
|
||
{"1×5×1", 1, 5, 1, 5},
|
||
{"1×1×5", 1, 1, 5, 5},
|
||
// Typical mid-range
|
||
{"3×3×3", 3, 3, 3, 27},
|
||
{"2×4×3", 2, 4, 3, 24},
|
||
{"4×2×5", 4, 2, 5, 40},
|
||
// Boundary at thresholds
|
||
{"3×5×5 = 75 (critical threshold)", 3, 5, 5, 75},
|
||
{"2×4×5 = 40 (high threshold)", 2, 4, 5, 40},
|
||
{"3×5×1 = 15 (medium threshold)", 3, 5, 1, 15},
|
||
{"5×1×1 = 5 (low threshold)", 5, 1, 1, 5},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
result := e.CalculateInherentRisk(tt.s, tt.ex, tt.p, 0)
|
||
if !almostEqual(result, tt.expected) {
|
||
t.Errorf("CalculateInherentRisk(%d, %d, %d) = %v, want %v", tt.s, tt.ex, tt.p, result, tt.expected)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestCalculateInherentRisk_Clamping(t *testing.T) {
|
||
e := NewRiskEngine()
|
||
|
||
tests := []struct {
|
||
name string
|
||
s, ex, p int
|
||
expected float64
|
||
}{
|
||
{"below min clamped to 1", 0, 0, 0, 1},
|
||
{"negative clamped to 1", -5, -3, -1, 1},
|
||
{"above max clamped to 5", 10, 8, 6, 125},
|
||
{"mixed out-of-range", 0, 10, 3, 15}, // clamp(0,1,5)=1, clamp(10,1,5)=5, clamp(3,1,5)=3 → 1*5*3=15
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
result := e.CalculateInherentRisk(tt.s, tt.ex, tt.p, 0)
|
||
if !almostEqual(result, tt.expected) {
|
||
t.Errorf("CalculateInherentRisk(%d, %d, %d) = %v, want %v", tt.s, tt.ex, tt.p, result, tt.expected)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// Full S×E×P coverage: verify all 125 combinations produce correct multiplication.
|
||
func TestCalculateInherentRisk_FullCoverage(t *testing.T) {
|
||
e := NewRiskEngine()
|
||
|
||
for s := 1; s <= 5; s++ {
|
||
for ex := 1; ex <= 5; ex++ {
|
||
for p := 1; p <= 5; p++ {
|
||
expected := float64(s * ex * p)
|
||
result := e.CalculateInherentRisk(s, ex, p, 0)
|
||
if !almostEqual(result, expected) {
|
||
t.Errorf("CalculateInherentRisk(%d, %d, %d) = %v, want %v", s, ex, p, result, expected)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// 2. CalculateControlEffectiveness
|
||
// C_eff = min(1, 0.2*(maturity/4.0) + 0.5*coverage + 0.3*testEvidence)
|
||
// ============================================================================
|
||
|
||
func TestCalculateControlEffectiveness(t *testing.T) {
|
||
e := NewRiskEngine()
|
||
|
||
tests := []struct {
|
||
name string
|
||
maturity int
|
||
coverage float64
|
||
testEvidence float64
|
||
expected float64
|
||
}{
|
||
// All zeros → 0
|
||
{"all zero", 0, 0.0, 0.0, 0.0},
|
||
// All max → min(1, 0.2*1 + 0.5*1 + 0.3*1) = min(1, 1.0) = 1.0
|
||
{"all max", 4, 1.0, 1.0, 1.0},
|
||
// Only maturity max → 0.2 * (4/4) = 0.2
|
||
{"maturity only", 4, 0.0, 0.0, 0.2},
|
||
// Only coverage max → 0.5
|
||
{"coverage only", 0, 1.0, 0.0, 0.5},
|
||
// Only test evidence max → 0.3
|
||
{"evidence only", 0, 0.0, 1.0, 0.3},
|
||
// Half maturity → 0.2 * (2/4) = 0.1
|
||
{"half maturity", 2, 0.0, 0.0, 0.1},
|
||
// Typical mid-range: maturity=2, coverage=0.6, evidence=0.4
|
||
// 0.2*(2/4) + 0.5*0.6 + 0.3*0.4 = 0.1 + 0.3 + 0.12 = 0.52
|
||
{"typical mid", 2, 0.6, 0.4, 0.52},
|
||
// High values exceeding 1.0 should be capped
|
||
// maturity=4, coverage=1.0, evidence=1.0 → 0.2+0.5+0.3 = 1.0
|
||
{"capped at 1.0", 4, 1.0, 1.0, 1.0},
|
||
// maturity=3, coverage=0.8, evidence=0.9
|
||
// 0.2*(3/4) + 0.5*0.8 + 0.3*0.9 = 0.15 + 0.4 + 0.27 = 0.82
|
||
{"high controls", 3, 0.8, 0.9, 0.82},
|
||
// maturity=1, coverage=0.2, evidence=0.1
|
||
// 0.2*(1/4) + 0.5*0.2 + 0.3*0.1 = 0.05 + 0.1 + 0.03 = 0.18
|
||
{"low controls", 1, 0.2, 0.1, 0.18},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
result := e.CalculateControlEffectiveness(tt.maturity, tt.coverage, tt.testEvidence)
|
||
if !almostEqual(result, tt.expected) {
|
||
t.Errorf("CalculateControlEffectiveness(%d, %v, %v) = %v, want %v",
|
||
tt.maturity, tt.coverage, tt.testEvidence, result, tt.expected)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestCalculateControlEffectiveness_Clamping(t *testing.T) {
|
||
e := NewRiskEngine()
|
||
|
||
tests := []struct {
|
||
name string
|
||
maturity int
|
||
coverage float64
|
||
testEvidence float64
|
||
expected float64
|
||
}{
|
||
// Maturity below 0 → clamped to 0
|
||
{"maturity below zero", -1, 0.5, 0.5, 0.5*0.5 + 0.3*0.5},
|
||
// Maturity above 4 → clamped to 4
|
||
{"maturity above max", 10, 0.0, 0.0, 0.2},
|
||
// Coverage below 0 → clamped to 0
|
||
{"coverage below zero", 0, -0.5, 0.0, 0.0},
|
||
// Coverage above 1 → clamped to 1
|
||
{"coverage above max", 0, 2.0, 0.0, 0.5},
|
||
// Evidence below 0 → clamped to 0
|
||
{"evidence below zero", 0, 0.0, -1.0, 0.0},
|
||
// Evidence above 1 → clamped to 1
|
||
{"evidence above max", 0, 0.0, 5.0, 0.3},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
result := e.CalculateControlEffectiveness(tt.maturity, tt.coverage, tt.testEvidence)
|
||
if !almostEqual(result, tt.expected) {
|
||
t.Errorf("CalculateControlEffectiveness(%d, %v, %v) = %v, want %v",
|
||
tt.maturity, tt.coverage, tt.testEvidence, result, tt.expected)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// 3. CalculateResidualRisk — R_residual = S × E × P × (1 - C_eff)
|
||
// ============================================================================
|
||
|
||
func TestCalculateResidualRisk(t *testing.T) {
|
||
e := NewRiskEngine()
|
||
|
||
tests := []struct {
|
||
name string
|
||
s, ex, p int
|
||
cEff float64
|
||
expected float64
|
||
}{
|
||
// No controls → residual = inherent
|
||
{"no controls", 5, 5, 5, 0.0, 125.0},
|
||
// Perfect controls → residual = 0
|
||
{"perfect controls", 5, 5, 5, 1.0, 0.0},
|
||
// Half effectiveness
|
||
{"half controls 3×3×3", 3, 3, 3, 0.5, 13.5},
|
||
// Typical scenario: inherent=40, cEff=0.6 → residual=16
|
||
{"typical 2×4×5 cEff=0.6", 2, 4, 5, 0.6, 16.0},
|
||
// Low risk with some controls
|
||
{"low 1×2×3 cEff=0.3", 1, 2, 3, 0.3, 4.2},
|
||
// High risk with strong controls
|
||
{"high 5×4×4 cEff=0.82", 5, 4, 4, 0.82, 14.4},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
result := e.CalculateResidualRisk(tt.s, tt.ex, tt.p, tt.cEff)
|
||
if !almostEqual(result, tt.expected) {
|
||
t.Errorf("CalculateResidualRisk(%d, %d, %d, %v) = %v, want %v",
|
||
tt.s, tt.ex, tt.p, tt.cEff, result, tt.expected)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// 4. DetermineRiskLevel — threshold classification
|
||
// ============================================================================
|
||
|
||
func TestDetermineRiskLevel(t *testing.T) {
|
||
e := NewRiskEngine()
|
||
|
||
tests := []struct {
|
||
name string
|
||
residual float64
|
||
expected RiskLevel
|
||
}{
|
||
// Critical: >= 75
|
||
{"critical at 75", 75.0, RiskLevelCritical},
|
||
{"critical at 125", 125.0, RiskLevelCritical},
|
||
{"critical at 100", 100.0, RiskLevelCritical},
|
||
// High: >= 40
|
||
{"high at 40", 40.0, RiskLevelHigh},
|
||
{"high at 74.9", 74.9, RiskLevelHigh},
|
||
{"high at 50", 50.0, RiskLevelHigh},
|
||
// Medium: >= 15
|
||
{"medium at 15", 15.0, RiskLevelMedium},
|
||
{"medium at 39.9", 39.9, RiskLevelMedium},
|
||
{"medium at 27", 27.0, RiskLevelMedium},
|
||
// Low: >= 5
|
||
{"low at 5", 5.0, RiskLevelLow},
|
||
{"low at 14.9", 14.9, RiskLevelLow},
|
||
{"low at 10", 10.0, RiskLevelLow},
|
||
// Negligible: < 5
|
||
{"negligible at 4.9", 4.9, RiskLevelNegligible},
|
||
{"negligible at 0", 0.0, RiskLevelNegligible},
|
||
{"negligible at 1", 1.0, RiskLevelNegligible},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
result := e.DetermineRiskLevel(tt.residual)
|
||
if result != tt.expected {
|
||
t.Errorf("DetermineRiskLevel(%v) = %v, want %v", tt.residual, result, tt.expected)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// 5. IsAcceptable — ALARP principle
|
||
// ============================================================================
|
||
|
||
func TestIsAcceptable(t *testing.T) {
|
||
e := NewRiskEngine()
|
||
|
||
tests := []struct {
|
||
name string
|
||
residual float64
|
||
allReduction bool
|
||
justification bool
|
||
wantAcceptable bool
|
||
wantReason string
|
||
}{
|
||
// Below 15 → always acceptable
|
||
{"residual 14.9 always ok", 14.9, false, false, true, "Restrisiko unter Schwellwert"},
|
||
{"residual 0 always ok", 0.0, false, false, true, "Restrisiko unter Schwellwert"},
|
||
{"residual 10 always ok", 10.0, false, false, true, "Restrisiko unter Schwellwert"},
|
||
// 15-39.9 with all reduction + justification → ALARP
|
||
{"ALARP 20 all+just", 20.0, true, true, true, "ALARP-Prinzip: Restrisiko akzeptabel mit vollstaendiger Risikominderung"},
|
||
{"ALARP 39.9 all+just", 39.9, true, true, true, "ALARP-Prinzip: Restrisiko akzeptabel mit vollstaendiger Risikominderung"},
|
||
{"ALARP 15 all+just", 15.0, true, true, true, "ALARP-Prinzip: Restrisiko akzeptabel mit vollstaendiger Risikominderung"},
|
||
// 15-39.9 without all reduction → NOT acceptable
|
||
{"no reduction 20", 20.0, false, true, false, "Restrisiko zu hoch - blockiert CE-Export"},
|
||
// 15-39.9 without justification → NOT acceptable
|
||
{"no justification 20", 20.0, true, false, false, "Restrisiko zu hoch - blockiert CE-Export"},
|
||
// 15-39.9 without either → NOT acceptable
|
||
{"neither 30", 30.0, false, false, false, "Restrisiko zu hoch - blockiert CE-Export"},
|
||
// >= 40 → NEVER acceptable
|
||
{"residual 40 blocked", 40.0, true, true, false, "Restrisiko zu hoch - blockiert CE-Export"},
|
||
{"residual 75 blocked", 75.0, true, true, false, "Restrisiko zu hoch - blockiert CE-Export"},
|
||
{"residual 125 blocked", 125.0, true, true, false, "Restrisiko zu hoch - blockiert CE-Export"},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
acceptable, reason := e.IsAcceptable(tt.residual, tt.allReduction, tt.justification)
|
||
if acceptable != tt.wantAcceptable {
|
||
t.Errorf("IsAcceptable(%v, %v, %v) acceptable = %v, want %v",
|
||
tt.residual, tt.allReduction, tt.justification, acceptable, tt.wantAcceptable)
|
||
}
|
||
if reason != tt.wantReason {
|
||
t.Errorf("IsAcceptable(%v, %v, %v) reason = %q, want %q",
|
||
tt.residual, tt.allReduction, tt.justification, reason, tt.wantReason)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// 6. CalculateCompletenessScore
|
||
// ============================================================================
|
||
|
||
func TestCalculateCompletenessScore(t *testing.T) {
|
||
e := NewRiskEngine()
|
||
|
||
tests := []struct {
|
||
name string
|
||
passedReq, totalReq, passedRec, totalRec, passedOpt, totalOpt int
|
||
expected float64
|
||
}{
|
||
// All passed
|
||
{"all passed", 20, 20, 5, 5, 3, 3, 100.0},
|
||
// Nothing passed
|
||
{"nothing passed", 0, 20, 0, 5, 0, 3, 0.0},
|
||
// Only required fully passed
|
||
{"only required", 20, 20, 0, 5, 0, 3, 80.0},
|
||
// Only recommended fully passed
|
||
{"only recommended", 0, 20, 5, 5, 0, 3, 15.0},
|
||
// Only optional fully passed
|
||
{"only optional", 0, 20, 0, 5, 3, 3, 5.0},
|
||
// Half required, no others
|
||
{"half required", 10, 20, 0, 5, 0, 3, 40.0},
|
||
// All zero totals → 0 (division by zero safety)
|
||
{"all zero totals", 0, 0, 0, 0, 0, 0, 0.0},
|
||
// Typical: 18/20 req + 3/5 rec + 1/3 opt
|
||
// (18/20)*80 + (3/5)*15 + (1/3)*5 = 72 + 9 + 1.6667 = 82.6667
|
||
{"typical", 18, 20, 3, 5, 1, 3, 72.0 + 9.0 + 5.0/3.0},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
result := e.CalculateCompletenessScore(
|
||
tt.passedReq, tt.totalReq, tt.passedRec, tt.totalRec, tt.passedOpt, tt.totalOpt)
|
||
if !almostEqual(result, tt.expected) {
|
||
t.Errorf("CalculateCompletenessScore(%d/%d, %d/%d, %d/%d) = %v, want %v",
|
||
tt.passedReq, tt.totalReq, tt.passedRec, tt.totalRec, tt.passedOpt, tt.totalOpt,
|
||
result, tt.expected)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// 7. ComputeRisk — integration test
|
||
// ============================================================================
|
||
|
||
func TestComputeRisk_ValidInput(t *testing.T) {
|
||
e := NewRiskEngine()
|
||
|
||
tests := []struct {
|
||
name string
|
||
input RiskComputeInput
|
||
wantInherent float64
|
||
wantCEff float64
|
||
wantResidual float64
|
||
wantLevel RiskLevel
|
||
wantAcceptable bool
|
||
}{
|
||
{
|
||
name: "no controls high risk",
|
||
input: RiskComputeInput{
|
||
Severity: 5, Exposure: 5, Probability: 5,
|
||
ControlMaturity: 0, ControlCoverage: 0, TestEvidence: 0,
|
||
},
|
||
wantInherent: 125,
|
||
wantCEff: 0,
|
||
wantResidual: 125,
|
||
wantLevel: RiskLevelCritical,
|
||
wantAcceptable: false,
|
||
},
|
||
{
|
||
name: "perfect controls zero residual",
|
||
input: RiskComputeInput{
|
||
Severity: 5, Exposure: 5, Probability: 5,
|
||
ControlMaturity: 4, ControlCoverage: 1.0, TestEvidence: 1.0,
|
||
},
|
||
wantInherent: 125,
|
||
wantCEff: 1.0,
|
||
wantResidual: 0,
|
||
wantLevel: RiskLevelNegligible,
|
||
wantAcceptable: true,
|
||
},
|
||
{
|
||
name: "medium risk acceptable",
|
||
input: RiskComputeInput{
|
||
Severity: 2, Exposure: 2, Probability: 2,
|
||
ControlMaturity: 2, ControlCoverage: 0.5, TestEvidence: 0.5,
|
||
// C_eff = 0.2*(2/4) + 0.5*0.5 + 0.3*0.5 = 0.1 + 0.25 + 0.15 = 0.5
|
||
// inherent = 8, residual = 8 * 0.5 = 4 → negligible → acceptable
|
||
},
|
||
wantInherent: 8,
|
||
wantCEff: 0.5,
|
||
wantResidual: 4,
|
||
wantLevel: RiskLevelNegligible,
|
||
wantAcceptable: true,
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
result, err := e.ComputeRisk(tt.input)
|
||
if err != nil {
|
||
t.Fatalf("ComputeRisk returned error: %v", err)
|
||
}
|
||
if !almostEqual(result.InherentRisk, tt.wantInherent) {
|
||
t.Errorf("InherentRisk = %v, want %v", result.InherentRisk, tt.wantInherent)
|
||
}
|
||
if !almostEqual(result.ControlEffectiveness, tt.wantCEff) {
|
||
t.Errorf("ControlEffectiveness = %v, want %v", result.ControlEffectiveness, tt.wantCEff)
|
||
}
|
||
if !almostEqual(result.ResidualRisk, tt.wantResidual) {
|
||
t.Errorf("ResidualRisk = %v, want %v", result.ResidualRisk, tt.wantResidual)
|
||
}
|
||
if result.RiskLevel != tt.wantLevel {
|
||
t.Errorf("RiskLevel = %v, want %v", result.RiskLevel, tt.wantLevel)
|
||
}
|
||
if result.IsAcceptable != tt.wantAcceptable {
|
||
t.Errorf("IsAcceptable = %v, want %v", result.IsAcceptable, tt.wantAcceptable)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestComputeRisk_InvalidInput(t *testing.T) {
|
||
e := NewRiskEngine()
|
||
|
||
tests := []struct {
|
||
name string
|
||
input RiskComputeInput
|
||
}{
|
||
{"severity zero", RiskComputeInput{Severity: 0, Exposure: 3, Probability: 3}},
|
||
{"exposure zero", RiskComputeInput{Severity: 3, Exposure: 0, Probability: 3}},
|
||
{"probability zero", RiskComputeInput{Severity: 3, Exposure: 3, Probability: 0}},
|
||
{"all zero", RiskComputeInput{Severity: 0, Exposure: 0, Probability: 0}},
|
||
{"negative values", RiskComputeInput{Severity: -1, Exposure: -2, Probability: -3}},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
result, err := e.ComputeRisk(tt.input)
|
||
if err == nil {
|
||
t.Errorf("ComputeRisk expected error for input %+v, got result %+v", tt.input, result)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// 8. Golden Test Suite — 10 Referenzmaschinen (Industrial Machine Scenarios)
|
||
//
|
||
// Each machine scenario tests the full pipeline:
|
||
// Inherent Risk → Control Effectiveness → Residual Risk → Risk Level → Acceptability
|
||
//
|
||
// The control parameters reflect realistic mitigation states for each machine type.
|
||
// These serve as regression tests: if any threshold or formula changes,
|
||
// these tests will catch the impact immediately.
|
||
// ============================================================================
|
||
|
||
// referenceMachine defines a complete end-to-end test scenario for a real machine type.
|
||
type referenceMachine struct {
|
||
name string
|
||
description string
|
||
|
||
// Inherent risk factors (pre-mitigation)
|
||
severity int // 1-5
|
||
exposure int // 1-5
|
||
probability int // 1-5
|
||
|
||
// Control parameters (mitigation state)
|
||
controlMaturity int // 0-4
|
||
controlCoverage float64 // 0-1
|
||
testEvidence float64 // 0-1
|
||
hasJustification bool
|
||
|
||
// Expected outputs
|
||
expectedInherentRisk float64
|
||
expectedCEff float64
|
||
expectedResidualRisk float64
|
||
expectedRiskLevel RiskLevel
|
||
expectedAcceptable bool
|
||
}
|
||
|
||
// computeExpectedCEff calculates the expected control effectiveness for documentation/verification.
|
||
func computeExpectedCEff(maturity int, coverage, testEvidence float64) float64 {
|
||
cEff := 0.2*(float64(maturity)/4.0) + 0.5*coverage + 0.3*testEvidence
|
||
if cEff > 1.0 {
|
||
return 1.0
|
||
}
|
||
return cEff
|
||
}
|
||
|
||
func getReferenceMachines() []referenceMachine {
|
||
return []referenceMachine{
|
||
// ---------------------------------------------------------------
|
||
// 1. Industrieroboter-Zelle mit Schutzzaun
|
||
// 6-Achs-Roboter, Materialhandling, Schutzzaun + Lichtschranke.
|
||
// Hauptgefaehrdung: Quetschung/Kollision bei Betreten.
|
||
// Hohe Massnahmen: Sicherheits-SPS, zweikanalige Tuerueberwachung.
|
||
// ---------------------------------------------------------------
|
||
{
|
||
name: "Industrieroboter-Zelle",
|
||
description: "6-Achs-Roboter mit Schutzzaun, Quetsch-/Kollisionsgefahr",
|
||
severity: 5, // Lebensgefaehrlich
|
||
exposure: 3, // Regelmaessiger Zugang (Wartung)
|
||
probability: 4, // Wahrscheinlich bei offenem Zugang
|
||
controlMaturity: 3,
|
||
controlCoverage: 0.8,
|
||
testEvidence: 0.7,
|
||
hasJustification: false,
|
||
// Inherent: 5*3*4 = 60
|
||
// C_eff: 0.2*(3/4) + 0.5*0.8 + 0.3*0.7 = 0.15 + 0.4 + 0.21 = 0.76
|
||
// Residual: 60 * (1-0.76) = 60 * 0.24 = 14.4
|
||
// Level: medium (>=5, <15) → actually 14.4 < 15 → low
|
||
// Acceptable: 14.4 < 15 → yes
|
||
expectedInherentRisk: 60,
|
||
expectedCEff: 0.76,
|
||
expectedResidualRisk: 60 * (1 - 0.76),
|
||
expectedRiskLevel: RiskLevelLow,
|
||
expectedAcceptable: true,
|
||
},
|
||
// ---------------------------------------------------------------
|
||
// 2. CNC-Fraesmaschine mit automatischem Werkzeugwechsel
|
||
// Werkzeugbruch, Spaeneflug. Vollschutzkabine + Drehzahlueberwachung.
|
||
// ---------------------------------------------------------------
|
||
{
|
||
name: "CNC-Fraesmaschine",
|
||
description: "CNC mit Werkzeugwechsel, Werkzeugbruch/Spaeneflug",
|
||
severity: 4, // Schwere Verletzung
|
||
exposure: 3, // Bediener steht regelmaessig davor
|
||
probability: 3, // Moeglich
|
||
controlMaturity: 3,
|
||
controlCoverage: 0.9,
|
||
testEvidence: 0.8,
|
||
hasJustification: true,
|
||
// Inherent: 4*3*3 = 36
|
||
// C_eff: 0.2*(3/4) + 0.5*0.9 + 0.3*0.8 = 0.15 + 0.45 + 0.24 = 0.84
|
||
// Residual: 36 * 0.16 = 5.76
|
||
// Level: low (>=5, <15)
|
||
// Acceptable: 5.76 < 15 → yes
|
||
expectedInherentRisk: 36,
|
||
expectedCEff: 0.84,
|
||
expectedResidualRisk: 36 * (1 - 0.84),
|
||
expectedRiskLevel: RiskLevelLow,
|
||
expectedAcceptable: true,
|
||
},
|
||
// ---------------------------------------------------------------
|
||
// 3. Verpackungsmaschine mit Schneideeinheit
|
||
// Foerderband + Klinge. Schnittverletzung.
|
||
// Zweihandbedienung, Sicherheitsrelais, Abdeckung.
|
||
// ---------------------------------------------------------------
|
||
{
|
||
name: "Verpackungsmaschine",
|
||
description: "Foerderband + Schneideeinheit, Schnittverletzungsgefahr",
|
||
severity: 4, // Schwere Schnittverletzung
|
||
exposure: 4, // Dauerbetrieb mit Bediener
|
||
probability: 3, // Moeglich
|
||
controlMaturity: 2,
|
||
controlCoverage: 0.7,
|
||
testEvidence: 0.5,
|
||
hasJustification: true,
|
||
// Inherent: 4*4*3 = 48
|
||
// C_eff: 0.2*(2/4) + 0.5*0.7 + 0.3*0.5 = 0.1 + 0.35 + 0.15 = 0.6
|
||
// Residual: 48 * 0.4 = 19.2
|
||
// Level: medium (>=15, <40)
|
||
// Acceptable: 19.2 >= 15, allReduction=false (ComputeRisk default) → NOT acceptable
|
||
expectedInherentRisk: 48,
|
||
expectedCEff: 0.6,
|
||
expectedResidualRisk: 48 * (1 - 0.6),
|
||
expectedRiskLevel: RiskLevelMedium,
|
||
expectedAcceptable: false, // ComputeRisk sets allReductionStepsApplied=false
|
||
},
|
||
// ---------------------------------------------------------------
|
||
// 4. Automatisierte Pressanlage
|
||
// Quetschung im Pressbereich. Hoechste Gefaehrdung.
|
||
// Lichtvorhang, Kat-4-Steuerung, mechanische Verriegelung.
|
||
// ---------------------------------------------------------------
|
||
{
|
||
name: "Pressanlage",
|
||
description: "Automatische Presse, Quetschgefahr im Pressbereich",
|
||
severity: 5, // Toedlich
|
||
exposure: 4, // Bediener staendig im Bereich
|
||
probability: 4, // Wahrscheinlich ohne Schutz
|
||
controlMaturity: 4,
|
||
controlCoverage: 0.9,
|
||
testEvidence: 0.9,
|
||
hasJustification: true,
|
||
// Inherent: 5*4*4 = 80
|
||
// C_eff: 0.2*(4/4) + 0.5*0.9 + 0.3*0.9 = 0.2 + 0.45 + 0.27 = 0.92
|
||
// Residual: 80 * 0.08 = 6.4
|
||
// Level: low (>=5, <15)
|
||
// Acceptable: 6.4 < 15 → yes
|
||
expectedInherentRisk: 80,
|
||
expectedCEff: 0.92,
|
||
expectedResidualRisk: 80 * (1 - 0.92),
|
||
expectedRiskLevel: RiskLevelLow,
|
||
expectedAcceptable: true,
|
||
},
|
||
// ---------------------------------------------------------------
|
||
// 5. Lasergravur-Anlage (Klasse 4)
|
||
// Augenverletzung durch Laserstrahl.
|
||
// Geschlossene Kabine, Interlock.
|
||
// ---------------------------------------------------------------
|
||
{
|
||
name: "Lasergravur-Anlage",
|
||
description: "Klasse-4-Laser, Augenverletzungsgefahr",
|
||
severity: 5, // Irreversible Augenschaeden
|
||
exposure: 2, // Selten direkter Zugang
|
||
probability: 2, // Selten bei geschlossener Kabine
|
||
controlMaturity: 3,
|
||
controlCoverage: 0.95,
|
||
testEvidence: 0.8,
|
||
hasJustification: false,
|
||
// Inherent: 5*2*2 = 20
|
||
// C_eff: 0.2*(3/4) + 0.5*0.95 + 0.3*0.8 = 0.15 + 0.475 + 0.24 = 0.865
|
||
// Residual: 20 * 0.135 = 2.7
|
||
// Level: negligible (<5)
|
||
// Acceptable: 2.7 < 15 → yes
|
||
expectedInherentRisk: 20,
|
||
expectedCEff: 0.865,
|
||
expectedResidualRisk: 20 * (1 - 0.865),
|
||
expectedRiskLevel: RiskLevelNegligible,
|
||
expectedAcceptable: true,
|
||
},
|
||
// ---------------------------------------------------------------
|
||
// 6. Fahrerloses Transportsystem (AGV)
|
||
// Kollision mit Personen in Produktionshalle.
|
||
// Laserscanner, Not-Aus, Geschwindigkeitsbegrenzung.
|
||
// ---------------------------------------------------------------
|
||
{
|
||
name: "AGV (Fahrerloses Transportsystem)",
|
||
description: "Autonomes Fahrzeug, Kollisionsgefahr mit Personen",
|
||
severity: 4, // Schwere Verletzung
|
||
exposure: 4, // Dauerhaft Personen in der Naehe
|
||
probability: 3, // Moeglich in offener Umgebung
|
||
controlMaturity: 3,
|
||
controlCoverage: 0.7,
|
||
testEvidence: 0.6,
|
||
hasJustification: true,
|
||
// Inherent: 4*4*3 = 48
|
||
// C_eff: 0.2*(3/4) + 0.5*0.7 + 0.3*0.6 = 0.15 + 0.35 + 0.18 = 0.68
|
||
// Residual: 48 * 0.32 = 15.36
|
||
// Level: medium (>=15, <40)
|
||
// Acceptable: 15.36 >= 15, allReduction=false → NOT acceptable
|
||
expectedInherentRisk: 48,
|
||
expectedCEff: 0.68,
|
||
expectedResidualRisk: 48 * (1 - 0.68),
|
||
expectedRiskLevel: RiskLevelMedium,
|
||
expectedAcceptable: false, // ComputeRisk sets allReductionStepsApplied=false
|
||
},
|
||
// ---------------------------------------------------------------
|
||
// 7. Abfuellanlage fuer Chemikalien
|
||
// Kontakt mit gefaehrlichem Medium.
|
||
// Geschlossene Leitungen, Leckageerkennung.
|
||
// ---------------------------------------------------------------
|
||
{
|
||
name: "Abfuellanlage Chemikalien",
|
||
description: "Chemikalien-Abfuellung, Kontaktgefahr",
|
||
severity: 4, // Schwere Veraetzung
|
||
exposure: 2, // Gelegentlicher Zugang
|
||
probability: 2, // Selten bei geschlossenen Leitungen
|
||
controlMaturity: 3,
|
||
controlCoverage: 0.85,
|
||
testEvidence: 0.7,
|
||
hasJustification: false,
|
||
// Inherent: 4*2*2 = 16
|
||
// C_eff: 0.2*(3/4) + 0.5*0.85 + 0.3*0.7 = 0.15 + 0.425 + 0.21 = 0.785
|
||
// Residual: 16 * 0.215 = 3.44
|
||
// Level: negligible (<5)
|
||
// Acceptable: 3.44 < 15 → yes
|
||
expectedInherentRisk: 16,
|
||
expectedCEff: 0.785,
|
||
expectedResidualRisk: 16 * (1 - 0.785),
|
||
expectedRiskLevel: RiskLevelNegligible,
|
||
expectedAcceptable: true,
|
||
},
|
||
// ---------------------------------------------------------------
|
||
// 8. Industrie-3D-Drucker (Metallpulver)
|
||
// Feinstaub-Inhalation, Explosionsgefahr.
|
||
// Absauganlage, Explosionsschutz.
|
||
// ---------------------------------------------------------------
|
||
{
|
||
name: "Industrie-3D-Drucker Metallpulver",
|
||
description: "Metallpulver-Drucker, Feinstaub und ATEX",
|
||
severity: 4, // Schwere Lungenschaeden / Explosion
|
||
exposure: 3, // Regelmaessig (Druckjob-Wechsel)
|
||
probability: 3, // Moeglich
|
||
controlMaturity: 2,
|
||
controlCoverage: 0.6,
|
||
testEvidence: 0.5,
|
||
hasJustification: true,
|
||
// Inherent: 4*3*3 = 36
|
||
// C_eff: 0.2*(2/4) + 0.5*0.6 + 0.3*0.5 = 0.1 + 0.3 + 0.15 = 0.55
|
||
// Residual: 36 * 0.45 = 16.2
|
||
// Level: medium (>=15, <40)
|
||
// Acceptable: 16.2 >= 15, allReduction=false → NOT acceptable
|
||
expectedInherentRisk: 36,
|
||
expectedCEff: 0.55,
|
||
expectedResidualRisk: 36 * (1 - 0.55),
|
||
expectedRiskLevel: RiskLevelMedium,
|
||
expectedAcceptable: false,
|
||
},
|
||
// ---------------------------------------------------------------
|
||
// 9. Automatisches Hochregallager
|
||
// Absturz von Lasten, Regalbediengeraet.
|
||
// Lastsicherung, Sensoren, regelmaessige Wartung.
|
||
// ---------------------------------------------------------------
|
||
{
|
||
name: "Hochregallager",
|
||
description: "Automatisches Regallager, Lastabsturzgefahr",
|
||
severity: 5, // Toedlich bei Absturz schwerer Lasten
|
||
exposure: 2, // Selten (automatisiert, Wartungszugang)
|
||
probability: 2, // Selten bei ordnungsgemaessem Betrieb
|
||
controlMaturity: 3,
|
||
controlCoverage: 0.8,
|
||
testEvidence: 0.7,
|
||
hasJustification: false,
|
||
// Inherent: 5*2*2 = 20
|
||
// C_eff: 0.2*(3/4) + 0.5*0.8 + 0.3*0.7 = 0.15 + 0.4 + 0.21 = 0.76
|
||
// Residual: 20 * 0.24 = 4.8
|
||
// Level: negligible (<5)
|
||
// Acceptable: 4.8 < 15 → yes
|
||
expectedInherentRisk: 20,
|
||
expectedCEff: 0.76,
|
||
expectedResidualRisk: 20 * (1 - 0.76),
|
||
expectedRiskLevel: RiskLevelNegligible,
|
||
expectedAcceptable: true,
|
||
},
|
||
// ---------------------------------------------------------------
|
||
// 10. KI-Bildverarbeitung Qualitaetskontrolle
|
||
// Fehlklassifikation → sicherheitsrelevantes Bauteil wird freigegeben.
|
||
// Redundante Pruefung, Validierungsdatensatz, KI-Risikobeurteilung.
|
||
// AI Act relevant.
|
||
// ---------------------------------------------------------------
|
||
{
|
||
name: "KI-Qualitaetskontrolle",
|
||
description: "KI-Vision fuer Bauteilpruefung, Fehlklassifikationsgefahr (AI Act relevant)",
|
||
severity: 4, // Fehlerhaftes Sicherheitsbauteil im Feld
|
||
exposure: 5, // Kontinuierlich (jedes Bauteil)
|
||
probability: 3, // Moeglich (ML nie 100% korrekt)
|
||
controlMaturity: 2,
|
||
controlCoverage: 0.5,
|
||
testEvidence: 0.6,
|
||
hasJustification: true,
|
||
// Inherent: 4*5*3 = 60
|
||
// C_eff: 0.2*(2/4) + 0.5*0.5 + 0.3*0.6 = 0.1 + 0.25 + 0.18 = 0.53
|
||
// Residual: 60 * 0.47 = 28.2
|
||
// Level: medium (>=15, <40)
|
||
// Acceptable: 28.2 >= 15, allReduction=false → NOT acceptable
|
||
expectedInherentRisk: 60,
|
||
expectedCEff: 0.53,
|
||
expectedResidualRisk: 60 * (1 - 0.53),
|
||
expectedRiskLevel: RiskLevelMedium,
|
||
expectedAcceptable: false,
|
||
},
|
||
}
|
||
}
|
||
|
||
func TestReferenceMachines_ComputeRisk(t *testing.T) {
|
||
engine := NewRiskEngine()
|
||
machines := getReferenceMachines()
|
||
|
||
for _, m := range machines {
|
||
t.Run(m.name, func(t *testing.T) {
|
||
input := RiskComputeInput{
|
||
Severity: m.severity,
|
||
Exposure: m.exposure,
|
||
Probability: m.probability,
|
||
ControlMaturity: m.controlMaturity,
|
||
ControlCoverage: m.controlCoverage,
|
||
TestEvidence: m.testEvidence,
|
||
HasJustification: m.hasJustification,
|
||
}
|
||
|
||
result, err := engine.ComputeRisk(input)
|
||
if err != nil {
|
||
t.Fatalf("ComputeRisk returned error: %v", err)
|
||
}
|
||
|
||
// Verify inherent risk
|
||
if !almostEqual(result.InherentRisk, m.expectedInherentRisk) {
|
||
t.Errorf("InherentRisk = %v, want %v", result.InherentRisk, m.expectedInherentRisk)
|
||
}
|
||
|
||
// Verify control effectiveness
|
||
if !almostEqual(result.ControlEffectiveness, m.expectedCEff) {
|
||
t.Errorf("ControlEffectiveness = %v, want %v", result.ControlEffectiveness, m.expectedCEff)
|
||
}
|
||
|
||
// Verify residual risk
|
||
if !almostEqual(result.ResidualRisk, m.expectedResidualRisk) {
|
||
t.Errorf("ResidualRisk = %v, want %v (diff: %v)",
|
||
result.ResidualRisk, m.expectedResidualRisk,
|
||
math.Abs(result.ResidualRisk-m.expectedResidualRisk))
|
||
}
|
||
|
||
// Verify risk level
|
||
if result.RiskLevel != m.expectedRiskLevel {
|
||
t.Errorf("RiskLevel = %v, want %v (residual=%v)",
|
||
result.RiskLevel, m.expectedRiskLevel, result.ResidualRisk)
|
||
}
|
||
|
||
// Verify acceptability
|
||
if result.IsAcceptable != m.expectedAcceptable {
|
||
t.Errorf("IsAcceptable = %v, want %v (residual=%v, level=%v)",
|
||
result.IsAcceptable, m.expectedAcceptable, result.ResidualRisk, result.RiskLevel)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestReferenceMachines_InherentRiskDistribution verifies that the 10 machines
|
||
// cover a meaningful range of inherent risk values (not all clustered).
|
||
func TestReferenceMachines_InherentRiskDistribution(t *testing.T) {
|
||
machines := getReferenceMachines()
|
||
|
||
var minRisk, maxRisk float64
|
||
minRisk = 999
|
||
levelCounts := map[RiskLevel]int{}
|
||
|
||
for _, m := range machines {
|
||
if m.expectedInherentRisk < minRisk {
|
||
minRisk = m.expectedInherentRisk
|
||
}
|
||
if m.expectedInherentRisk > maxRisk {
|
||
maxRisk = m.expectedInherentRisk
|
||
}
|
||
levelCounts[m.expectedRiskLevel]++
|
||
}
|
||
|
||
// Should span a meaningful range
|
||
if maxRisk-minRisk < 40 {
|
||
t.Errorf("Inherent risk range too narrow: [%v, %v], want spread >= 40", minRisk, maxRisk)
|
||
}
|
||
|
||
// Should cover at least 3 different risk levels
|
||
if len(levelCounts) < 3 {
|
||
t.Errorf("Only %d risk levels covered, want at least 3: %v", len(levelCounts), levelCounts)
|
||
}
|
||
}
|
||
|
||
// TestReferenceMachines_AcceptabilityMix verifies that the test suite has both
|
||
// acceptable and unacceptable outcomes.
|
||
func TestReferenceMachines_AcceptabilityMix(t *testing.T) {
|
||
machines := getReferenceMachines()
|
||
|
||
acceptableCount := 0
|
||
unacceptableCount := 0
|
||
for _, m := range machines {
|
||
if m.expectedAcceptable {
|
||
acceptableCount++
|
||
} else {
|
||
unacceptableCount++
|
||
}
|
||
}
|
||
|
||
if acceptableCount == 0 {
|
||
t.Error("No acceptable machines in test suite — need at least one")
|
||
}
|
||
if unacceptableCount == 0 {
|
||
t.Error("No unacceptable machines in test suite — need at least one")
|
||
}
|
||
|
||
t.Logf("Acceptability mix: %d acceptable, %d unacceptable out of %d machines",
|
||
acceptableCount, unacceptableCount, len(machines))
|
||
}
|
||
|
||
// ============================================================================
|
||
// 9. Edge Cases
|
||
// ============================================================================
|
||
|
||
func TestClamp(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
v, lo, hi int
|
||
expected int
|
||
}{
|
||
{"in range", 3, 1, 5, 3},
|
||
{"at low", 1, 1, 5, 1},
|
||
{"at high", 5, 1, 5, 5},
|
||
{"below low", 0, 1, 5, 1},
|
||
{"above high", 10, 1, 5, 5},
|
||
{"negative", -100, 0, 4, 0},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
result := clamp(tt.v, tt.lo, tt.hi)
|
||
if result != tt.expected {
|
||
t.Errorf("clamp(%d, %d, %d) = %d, want %d", tt.v, tt.lo, tt.hi, result, tt.expected)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// 10. ISO 12100 Mode — S × F × P × A (direct multiplication)
|
||
// ============================================================================
|
||
|
||
func TestCalculateInherentRisk_ISO12100Mode(t *testing.T) {
|
||
e := NewRiskEngine()
|
||
|
||
tests := []struct {
|
||
name string
|
||
s, f, p, a int
|
||
expected float64
|
||
}{
|
||
// Minimum: 1×1×1×1 = 1
|
||
{"min 1×1×1×1", 1, 1, 1, 1, 1},
|
||
// Maximum: 5×5×5×5 = 625
|
||
{"max 5×5×5×5", 5, 5, 5, 5, 625},
|
||
// Typical mid-range: 3×3×3×3 = 81
|
||
{"mid 3×3×3×3", 3, 3, 3, 3, 81},
|
||
// High severity, low avoidance: 5×3×4×1 = 60
|
||
{"high S low A", 5, 3, 4, 1, 60},
|
||
// All factors high: 4×4×4×4 = 256
|
||
{"high 4×4×4×4", 4, 4, 4, 4, 256},
|
||
// Low risk: 2×1×2×1 = 4
|
||
{"low risk", 2, 1, 2, 1, 4},
|
||
// At not_acceptable boundary: 5×5×3×5 = 375
|
||
{"above 300", 5, 5, 3, 5, 375},
|
||
// At very_high boundary: 4×3×4×4 = 192
|
||
{"very high range", 4, 3, 4, 4, 192},
|
||
// At high boundary: 3×3×3×3 = 81
|
||
{"high range", 3, 3, 3, 3, 81},
|
||
// At medium range: 2×3×3×2 = 36
|
||
{"medium range", 2, 3, 3, 2, 36},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
result := e.CalculateInherentRisk(tt.s, tt.f, tt.p, tt.a)
|
||
if !almostEqual(result, tt.expected) {
|
||
t.Errorf("CalculateInherentRisk(%d, %d, %d, %d) = %v, want %v",
|
||
tt.s, tt.f, tt.p, tt.a, result, tt.expected)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestDetermineRiskLevelISO(t *testing.T) {
|
||
e := NewRiskEngine()
|
||
|
||
tests := []struct {
|
||
name string
|
||
risk float64
|
||
expected RiskLevel
|
||
}{
|
||
// not_acceptable: > 300
|
||
{"not_acceptable at 301", 301, RiskLevelNotAcceptable},
|
||
{"not_acceptable at 625", 625, RiskLevelNotAcceptable},
|
||
{"not_acceptable at 400", 400, RiskLevelNotAcceptable},
|
||
// very_high: 151-300
|
||
{"very_high at 300", 300, RiskLevelVeryHigh},
|
||
{"very_high at 151", 151, RiskLevelVeryHigh},
|
||
{"very_high at 200", 200, RiskLevelVeryHigh},
|
||
// high: 61-150
|
||
{"high at 150", 150, RiskLevelHigh},
|
||
{"high at 61", 61, RiskLevelHigh},
|
||
{"high at 100", 100, RiskLevelHigh},
|
||
// medium: 21-60
|
||
{"medium at 60", 60, RiskLevelMedium},
|
||
{"medium at 21", 21, RiskLevelMedium},
|
||
{"medium at 40", 40, RiskLevelMedium},
|
||
// low: 1-20
|
||
{"low at 20", 20, RiskLevelLow},
|
||
{"low at 1", 1, RiskLevelLow},
|
||
{"low at 10", 10, RiskLevelLow},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
result := e.DetermineRiskLevelISO(tt.risk)
|
||
if result != tt.expected {
|
||
t.Errorf("DetermineRiskLevelISO(%v) = %v, want %v", tt.risk, result, tt.expected)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestAvoidanceBackwardCompat(t *testing.T) {
|
||
e := NewRiskEngine()
|
||
|
||
// With avoidance=0, formula must remain S×E×P (legacy mode)
|
||
tests := []struct {
|
||
s, ex, p int
|
||
expected float64
|
||
}{
|
||
{5, 5, 5, 125},
|
||
{3, 3, 3, 27},
|
||
{1, 1, 1, 1},
|
||
{4, 2, 5, 40},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
result := e.CalculateInherentRisk(tt.s, tt.ex, tt.p, 0)
|
||
if !almostEqual(result, tt.expected) {
|
||
t.Errorf("Legacy mode: CalculateInherentRisk(%d,%d,%d,0) = %v, want %v",
|
||
tt.s, tt.ex, tt.p, result, tt.expected)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestValidateProtectiveMeasureHierarchy(t *testing.T) {
|
||
e := NewRiskEngine()
|
||
|
||
tests := []struct {
|
||
name string
|
||
reductionType ReductionType
|
||
existing []Mitigation
|
||
wantWarnings int
|
||
}{
|
||
{
|
||
name: "design measure — no warning",
|
||
reductionType: ReductionTypeDesign,
|
||
existing: nil,
|
||
wantWarnings: 0,
|
||
},
|
||
{
|
||
name: "information without any — full warning",
|
||
reductionType: ReductionTypeInformation,
|
||
existing: nil,
|
||
wantWarnings: 1,
|
||
},
|
||
{
|
||
name: "information with design — no warning",
|
||
reductionType: ReductionTypeInformation,
|
||
existing: []Mitigation{
|
||
{ReductionType: ReductionTypeDesign, Status: MitigationStatusImplemented},
|
||
{ReductionType: ReductionTypeProtective, Status: MitigationStatusPlanned},
|
||
},
|
||
wantWarnings: 0,
|
||
},
|
||
{
|
||
name: "information with only protective — missing design warning",
|
||
reductionType: ReductionTypeInformation,
|
||
existing: []Mitigation{
|
||
{ReductionType: ReductionTypeProtective, Status: MitigationStatusImplemented},
|
||
},
|
||
wantWarnings: 1,
|
||
},
|
||
{
|
||
name: "information with rejected measures — full warning",
|
||
reductionType: ReductionTypeInformation,
|
||
existing: []Mitigation{
|
||
{ReductionType: ReductionTypeDesign, Status: MitigationStatusRejected},
|
||
{ReductionType: ReductionTypeProtective, Status: MitigationStatusRejected},
|
||
},
|
||
wantWarnings: 1,
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
warnings := e.ValidateProtectiveMeasureHierarchy(tt.reductionType, tt.existing)
|
||
if len(warnings) != tt.wantWarnings {
|
||
t.Errorf("got %d warnings, want %d: %v", len(warnings), tt.wantWarnings, warnings)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestComputeRisk_ISOMode(t *testing.T) {
|
||
e := NewRiskEngine()
|
||
|
||
// ISO mode: avoidance >= 1 → uses DetermineRiskLevelISO for inherent risk classification
|
||
tests := []struct {
|
||
name string
|
||
input RiskComputeInput
|
||
wantLevel RiskLevel
|
||
}{
|
||
{
|
||
name: "ISO low risk",
|
||
input: RiskComputeInput{
|
||
Severity: 2, Exposure: 1, Probability: 2, Avoidance: 1,
|
||
ControlMaturity: 2, ControlCoverage: 0.5, TestEvidence: 0.5,
|
||
},
|
||
wantLevel: RiskLevelLow, // 2×1×2×1 = 4 → low
|
||
},
|
||
{
|
||
name: "ISO medium risk",
|
||
input: RiskComputeInput{
|
||
Severity: 3, Exposure: 2, Probability: 3, Avoidance: 2,
|
||
ControlMaturity: 2, ControlCoverage: 0.5, TestEvidence: 0.5,
|
||
},
|
||
wantLevel: RiskLevelMedium, // 3×2×3×2 = 36 → medium
|
||
},
|
||
{
|
||
name: "ISO high risk",
|
||
input: RiskComputeInput{
|
||
Severity: 4, Exposure: 3, Probability: 3, Avoidance: 3,
|
||
ControlMaturity: 2, ControlCoverage: 0.5, TestEvidence: 0.5,
|
||
},
|
||
wantLevel: RiskLevelHigh, // 4×3×3×3 = 108 → high
|
||
},
|
||
{
|
||
name: "ISO very high risk",
|
||
input: RiskComputeInput{
|
||
Severity: 4, Exposure: 4, Probability: 4, Avoidance: 3,
|
||
ControlMaturity: 2, ControlCoverage: 0.5, TestEvidence: 0.5,
|
||
},
|
||
wantLevel: RiskLevelVeryHigh, // 4×4×4×3 = 192 → very_high
|
||
},
|
||
{
|
||
name: "ISO not acceptable",
|
||
input: RiskComputeInput{
|
||
Severity: 5, Exposure: 5, Probability: 4, Avoidance: 4,
|
||
ControlMaturity: 4, ControlCoverage: 1.0, TestEvidence: 1.0,
|
||
},
|
||
wantLevel: RiskLevelNotAcceptable, // 5×5×4×4 = 400 → not_acceptable
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
result, err := e.ComputeRisk(tt.input)
|
||
if err != nil {
|
||
t.Fatalf("ComputeRisk returned error: %v", err)
|
||
}
|
||
if result.RiskLevel != tt.wantLevel {
|
||
t.Errorf("RiskLevel = %v, want %v (inherent=%v)",
|
||
result.RiskLevel, tt.wantLevel, result.InherentRisk)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// 11. Edge Cases (continued)
|
||
// ============================================================================
|
||
|
||
func TestClampFloat(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
v, lo, hi float64
|
||
expected float64
|
||
}{
|
||
{"in range", 0.5, 0, 1, 0.5},
|
||
{"at low", 0, 0, 1, 0},
|
||
{"at high", 1.0, 0, 1, 1.0},
|
||
{"below low", -0.5, 0, 1, 0},
|
||
{"above high", 2.5, 0, 1, 1.0},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
result := clampFloat(tt.v, tt.lo, tt.hi)
|
||
if !almostEqual(result, tt.expected) {
|
||
t.Errorf("clampFloat(%v, %v, %v) = %v, want %v", tt.v, tt.lo, tt.hi, result, tt.expected)
|
||
}
|
||
})
|
||
}
|
||
}
|