Files
Benjamin Admin c7651796c9
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
feat(iace): integrate ISO 12100 machine risk model with 4-factor assessment
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>
2026-03-15 23:13:41 +01:00

387 lines
11 KiB
Go

package iace
import (
"testing"
"github.com/google/uuid"
)
func TestGetBuiltinHazardLibrary_EntryCount(t *testing.T) {
entries := GetBuiltinHazardLibrary()
if len(entries) < 150 {
t.Fatalf("GetBuiltinHazardLibrary returned %d entries, want at least 150", len(entries))
}
}
func TestGetBuiltinHazardLibrary_AllBuiltinAndNoTenant(t *testing.T) {
entries := GetBuiltinHazardLibrary()
for i, e := range entries {
if !e.IsBuiltin {
t.Errorf("entries[%d] (%s): IsBuiltin = false, want true", i, e.Name)
}
if e.TenantID != nil {
t.Errorf("entries[%d] (%s): TenantID = %v, want nil", i, e.Name, e.TenantID)
}
}
}
func TestGetBuiltinHazardLibrary_UniqueNonZeroUUIDs(t *testing.T) {
entries := GetBuiltinHazardLibrary()
seen := make(map[uuid.UUID]string)
for i, e := range entries {
if e.ID == uuid.Nil {
t.Errorf("entries[%d] (%s): ID is zero UUID", i, e.Name)
}
if prev, exists := seen[e.ID]; exists {
t.Errorf("entries[%d] (%s): duplicate UUID %s, same as %q", i, e.Name, e.ID, prev)
}
seen[e.ID] = e.Name
}
}
func TestGetBuiltinHazardLibrary_AllCategoriesPresent(t *testing.T) {
entries := GetBuiltinHazardLibrary()
// All expected categories (original AI/SW + extended ISO 12100 physical)
expectedCategories := map[string]bool{
// Original AI/cyber categories
"false_classification": false,
"timing_error": false,
"data_poisoning": false,
"model_drift": false,
"sensor_spoofing": false,
"communication_failure": false,
"unauthorized_access": false,
"firmware_corruption": false,
"safety_boundary_violation": false,
"mode_confusion": false,
"unintended_bias": false,
"update_failure": false,
// Extended SW/HW categories
"software_fault": false,
"hmi_error": false,
"mechanical_hazard": false,
"electrical_hazard": false,
"thermal_hazard": false,
"emc_hazard": false,
"configuration_error": false,
"safety_function_failure": false,
"logging_audit_failure": false,
"integration_error": false,
"environmental_hazard": false,
"maintenance_hazard": false,
// ISO 12100 physical categories
"pneumatic_hydraulic": false,
"noise_vibration": false,
"ergonomic": false,
"material_environmental": false,
}
for _, e := range entries {
if _, ok := expectedCategories[e.Category]; !ok {
t.Errorf("unexpected category %q in entry %q", e.Category, e.Name)
}
expectedCategories[e.Category] = true
}
for cat, found := range expectedCategories {
if !found {
t.Errorf("expected category %q not found in any entry", cat)
}
}
}
func TestGetBuiltinHazardLibrary_CategoryCounts(t *testing.T) {
entries := GetBuiltinHazardLibrary()
actualCounts := make(map[string]int)
for _, e := range entries {
actualCounts[e.Category]++
}
// Each category must have at least 1 entry
for cat, count := range actualCounts {
if count < 1 {
t.Errorf("category %q: count = %d, want >= 1", cat, count)
}
}
// ISO 12100 physical categories must have substantial coverage
minCounts := map[string]int{
"mechanical_hazard": 10,
"electrical_hazard": 6,
"thermal_hazard": 4,
"pneumatic_hydraulic": 6,
"noise_vibration": 4,
}
for cat, minCount := range minCounts {
if actualCounts[cat] < minCount {
t.Errorf("category %q: count = %d, want >= %d", cat, actualCounts[cat], minCount)
}
}
}
func TestGetBuiltinHazardLibrary_SeverityRange(t *testing.T) {
entries := GetBuiltinHazardLibrary()
for i, e := range entries {
if e.DefaultSeverity < 1 || e.DefaultSeverity > 5 {
t.Errorf("entries[%d] (%s): DefaultSeverity = %d, want 1-5", i, e.Name, e.DefaultSeverity)
}
}
}
func TestGetBuiltinHazardLibrary_ProbabilityRange(t *testing.T) {
entries := GetBuiltinHazardLibrary()
for i, e := range entries {
if e.DefaultProbability < 1 || e.DefaultProbability > 5 {
t.Errorf("entries[%d] (%s): DefaultProbability = %d, want 1-5", i, e.Name, e.DefaultProbability)
}
}
}
func TestGetBuiltinHazardLibrary_ExposureAndAvoidanceRange(t *testing.T) {
entries := GetBuiltinHazardLibrary()
for i, e := range entries {
if e.DefaultExposure < 0 || e.DefaultExposure > 5 {
t.Errorf("entries[%d] (%s): DefaultExposure = %d, want 0-5", i, e.Name, e.DefaultExposure)
}
if e.DefaultAvoidance < 0 || e.DefaultAvoidance > 5 {
t.Errorf("entries[%d] (%s): DefaultAvoidance = %d, want 0-5", i, e.Name, e.DefaultAvoidance)
}
}
}
func TestGetBuiltinHazardLibrary_NonEmptyFields(t *testing.T) {
entries := GetBuiltinHazardLibrary()
for i, e := range entries {
if e.Name == "" {
t.Errorf("entries[%d]: Name is empty", i)
}
if e.Category == "" {
t.Errorf("entries[%d] (%s): Category is empty", i, e.Name)
}
if len(e.ApplicableComponentTypes) == 0 {
t.Errorf("entries[%d] (%s): ApplicableComponentTypes is empty", i, e.Name)
}
}
}
func TestHazardUUID_Deterministic(t *testing.T) {
tests := []struct {
category string
index int
}{
{"false_classification", 1},
{"timing_error", 2},
{"data_poisoning", 1},
{"mechanical_hazard", 7},
{"pneumatic_hydraulic", 1},
}
for _, tt := range tests {
t.Run(tt.category, func(t *testing.T) {
id1 := hazardUUID(tt.category, tt.index)
id2 := hazardUUID(tt.category, tt.index)
if id1 != id2 {
t.Errorf("hazardUUID(%q, %d) not deterministic: %s != %s", tt.category, tt.index, id1, id2)
}
if id1 == uuid.Nil {
t.Errorf("hazardUUID(%q, %d) returned zero UUID", tt.category, tt.index)
}
})
}
}
func TestHazardUUID_DifferentInputsProduceDifferentUUIDs(t *testing.T) {
tests := []struct {
cat1 string
idx1 int
cat2 string
idx2 int
}{
{"false_classification", 1, "false_classification", 2},
{"false_classification", 1, "timing_error", 1},
{"mechanical_hazard", 7, "mechanical_hazard", 8},
{"pneumatic_hydraulic", 1, "noise_vibration", 1},
}
for _, tt := range tests {
name := tt.cat1 + "_" + tt.cat2
t.Run(name, func(t *testing.T) {
id1 := hazardUUID(tt.cat1, tt.idx1)
id2 := hazardUUID(tt.cat2, tt.idx2)
if id1 == id2 {
t.Errorf("hazardUUID(%q,%d) == hazardUUID(%q,%d): %s", tt.cat1, tt.idx1, tt.cat2, tt.idx2, id1)
}
})
}
}
func TestGetBuiltinHazardLibrary_CreatedAtSet(t *testing.T) {
entries := GetBuiltinHazardLibrary()
for i, e := range entries {
if e.CreatedAt.IsZero() {
t.Errorf("entries[%d] (%s): CreatedAt is zero", i, e.Name)
}
}
}
func TestGetBuiltinHazardLibrary_DescriptionPresent(t *testing.T) {
entries := GetBuiltinHazardLibrary()
for i, e := range entries {
if e.Description == "" {
t.Errorf("entries[%d] (%s): Description is empty", i, e.Name)
}
}
}
func TestGetBuiltinHazardLibrary_SuggestedMitigationsPresent(t *testing.T) {
entries := GetBuiltinHazardLibrary()
for i, e := range entries {
if e.SuggestedMitigations == nil || len(e.SuggestedMitigations) == 0 {
t.Errorf("entries[%d] (%s): SuggestedMitigations is nil/empty", i, e.Name)
}
}
}
func TestGetBuiltinHazardLibrary_ApplicableComponentTypesAreValid(t *testing.T) {
entries := GetBuiltinHazardLibrary()
validTypes := map[string]bool{
string(ComponentTypeSoftware): true,
string(ComponentTypeFirmware): true,
string(ComponentTypeAIModel): true,
string(ComponentTypeHMI): true,
string(ComponentTypeSensor): true,
string(ComponentTypeActuator): true,
string(ComponentTypeController): true,
string(ComponentTypeNetwork): true,
string(ComponentTypeMechanical): true,
string(ComponentTypeElectrical): true,
string(ComponentTypeOther): true,
}
for i, e := range entries {
for _, ct := range e.ApplicableComponentTypes {
if !validTypes[ct] {
t.Errorf("entries[%d] (%s): invalid component type %q in ApplicableComponentTypes", i, e.Name, ct)
}
}
}
}
func TestGetBuiltinHazardLibrary_ConsistentAcrossCalls(t *testing.T) {
entries1 := GetBuiltinHazardLibrary()
entries2 := GetBuiltinHazardLibrary()
if len(entries1) != len(entries2) {
t.Fatalf("inconsistent lengths: %d vs %d", len(entries1), len(entries2))
}
for i := range entries1 {
if entries1[i].ID != entries2[i].ID {
t.Errorf("entries[%d]: ID mismatch across calls: %s vs %s", i, entries1[i].ID, entries2[i].ID)
}
if entries1[i].Name != entries2[i].Name {
t.Errorf("entries[%d]: Name mismatch across calls: %q vs %q", i, entries1[i].Name, entries2[i].Name)
}
}
}
// TestGetProtectiveMeasureLibrary_EntryCount verifies the protective measures library size
func TestGetProtectiveMeasureLibrary_EntryCount(t *testing.T) {
entries := GetProtectiveMeasureLibrary()
if len(entries) < 150 {
t.Fatalf("GetProtectiveMeasureLibrary returned %d entries, want at least 150", len(entries))
}
}
// TestGetProtectiveMeasureLibrary_AllTypesPresent verifies all 3 reduction types exist
func TestGetProtectiveMeasureLibrary_AllTypesPresent(t *testing.T) {
entries := GetProtectiveMeasureLibrary()
typeCounts := map[string]int{}
for _, e := range entries {
typeCounts[e.ReductionType]++
}
for _, rt := range []string{"design", "information"} {
if typeCounts[rt] < 10 {
t.Errorf("reduction type %q: count = %d, want >= 10", rt, typeCounts[rt])
}
}
// Accept both "protection" and "protective" naming
protCount := typeCounts["protection"] + typeCounts["protective"]
if protCount < 10 {
t.Errorf("reduction type protection/protective: count = %d, want >= 10", protCount)
}
}
// TestGetProtectiveMeasureLibrary_UniqueIDs verifies all measure IDs are unique
func TestGetProtectiveMeasureLibrary_UniqueIDs(t *testing.T) {
entries := GetProtectiveMeasureLibrary()
seen := make(map[string]string)
for i, e := range entries {
if e.ID == "" {
t.Errorf("entries[%d] (%s): ID is empty", i, e.Name)
continue
}
if prev, exists := seen[e.ID]; exists {
t.Errorf("entries[%d] (%s): duplicate ID %q, same as %q", i, e.Name, e.ID, prev)
}
seen[e.ID] = e.Name
}
}
// TestGetProtectiveMeasureLibrary_NonEmptyFields verifies required fields are filled
func TestGetProtectiveMeasureLibrary_NonEmptyFields(t *testing.T) {
entries := GetProtectiveMeasureLibrary()
for i, e := range entries {
if e.Name == "" {
t.Errorf("entries[%d]: Name is empty", i)
}
if e.Description == "" {
t.Errorf("entries[%d] (%s): Description is empty", i, e.Name)
}
if e.ReductionType == "" {
t.Errorf("entries[%d] (%s): ReductionType is empty", i, e.Name)
}
}
}
// TestGetProtectiveMeasureLibrary_ValidReductionTypes verifies reduction types are valid
func TestGetProtectiveMeasureLibrary_ValidReductionTypes(t *testing.T) {
entries := GetProtectiveMeasureLibrary()
validTypes := map[string]bool{"design": true, "protection": true, "protective": true, "information": true}
for i, e := range entries {
if !validTypes[e.ReductionType] {
t.Errorf("entries[%d] (%s): invalid ReductionType %q", i, e.Name, e.ReductionType)
}
}
}
// TestGetControlsLibrary_EntryCount verifies the controls library size
func TestGetControlsLibrary_EntryCount(t *testing.T) {
entries := GetControlsLibrary()
if len(entries) < 30 {
t.Fatalf("GetControlsLibrary returned %d entries, want at least 30", len(entries))
}
}