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 38s
CI/CD / test-python-backend-compliance (push) Successful in 34s
CI/CD / test-python-document-crawler (push) Successful in 29s
CI/CD / test-python-dsms-gateway (push) Successful in 20s
CI/CD / validate-canonical-controls (push) Successful in 12s
CI/CD / Deploy (push) Successful in 2s
- Create iace_handler_test.go (22 tests): input validation for InitFromProfile, GenerateSingleSection, ExportTechFile, CheckCompleteness, getTenantID, CreateProject, ListProjects, Component CRUD handlers - Add error-handling tests to tech_file_generator_test.go: nil context, nil project, empty components/hazards/classifications/evidence, unknown section type, all 19 getSystemPrompt types, AI-specific section prompts - Add JSON export tests to document_export_test.go: valid output, empty project, nil project error, special character handling (German text, XML escapes) - Add iace-hazard-library.md to mkdocs.yml navigation - Add TipTap Rich-Text-Editor section to iace.md documentation Total: 181 tests passing (was 165), 0 failures Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
480 lines
14 KiB
Go
480 lines
14 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
func init() {
|
|
gin.SetMode(gin.TestMode)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helper: create a gin test context with optional JSON body, headers, params
|
|
// ============================================================================
|
|
|
|
func newTestContext(method, path string, body interface{}, headers map[string]string, params gin.Params) (*httptest.ResponseRecorder, *gin.Context) {
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
|
|
var reqBody *bytes.Reader
|
|
if body != nil {
|
|
b, _ := json.Marshal(body)
|
|
reqBody = bytes.NewReader(b)
|
|
} else {
|
|
reqBody = bytes.NewReader(nil)
|
|
}
|
|
|
|
c.Request, _ = http.NewRequest(method, path, reqBody)
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
for k, v := range headers {
|
|
c.Request.Header.Set(k, v)
|
|
}
|
|
if params != nil {
|
|
c.Params = params
|
|
}
|
|
|
|
return w, c
|
|
}
|
|
|
|
func parseResponse(w *httptest.ResponseRecorder) map[string]interface{} {
|
|
var resp map[string]interface{}
|
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
|
return resp
|
|
}
|
|
|
|
// ============================================================================
|
|
// InitFromProfile Tests
|
|
// ============================================================================
|
|
|
|
func TestInitFromProfile_InvalidProjectID_Returns400(t *testing.T) {
|
|
handler := &IACEHandler{}
|
|
|
|
w, c := newTestContext("POST", "/projects/not-a-uuid/init-from-profile", nil, nil, gin.Params{
|
|
{Key: "id", Value: "not-a-uuid"},
|
|
})
|
|
|
|
handler.InitFromProfile(c)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected 400, got %d", w.Code)
|
|
}
|
|
|
|
resp := parseResponse(w)
|
|
if resp["error"] == nil {
|
|
t.Error("Expected error message in response")
|
|
}
|
|
}
|
|
|
|
func TestInitFromProfile_EmptyProjectID_Returns400(t *testing.T) {
|
|
handler := &IACEHandler{}
|
|
|
|
w, c := newTestContext("POST", "/projects//init-from-profile", nil, nil, gin.Params{
|
|
{Key: "id", Value: ""},
|
|
})
|
|
|
|
handler.InitFromProfile(c)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestInitFromProfile_RequestBinding(t *testing.T) {
|
|
// Verify the InitFromProfileRequest properly binds company_profile and compliance_scope
|
|
body := `{
|
|
"company_profile": {"company_name": "TestCorp GmbH", "contact_email": "info@testcorp.de"},
|
|
"compliance_scope": {"machine_name": "Robot XY", "has_ai": true, "applicable_regulations": ["machinery_regulation"]}
|
|
}`
|
|
|
|
var parsed struct {
|
|
CompanyProfile json.RawMessage `json:"company_profile"`
|
|
ComplianceScope json.RawMessage `json:"compliance_scope"`
|
|
}
|
|
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
|
|
t.Fatalf("Failed to parse request body: %v", err)
|
|
}
|
|
|
|
if parsed.CompanyProfile == nil {
|
|
t.Error("company_profile should not be nil")
|
|
}
|
|
if parsed.ComplianceScope == nil {
|
|
t.Error("compliance_scope should not be nil")
|
|
}
|
|
|
|
// Verify scope parsing
|
|
var scope struct {
|
|
MachineName string `json:"machine_name"`
|
|
HasAI bool `json:"has_ai"`
|
|
ApplicableRegulations []string `json:"applicable_regulations"`
|
|
}
|
|
if err := json.Unmarshal(parsed.ComplianceScope, &scope); err != nil {
|
|
t.Fatalf("Failed to parse compliance_scope: %v", err)
|
|
}
|
|
if scope.MachineName != "Robot XY" {
|
|
t.Errorf("Expected machine_name 'Robot XY', got %q", scope.MachineName)
|
|
}
|
|
if !scope.HasAI {
|
|
t.Error("Expected has_ai to be true")
|
|
}
|
|
if len(scope.ApplicableRegulations) != 1 || scope.ApplicableRegulations[0] != "machinery_regulation" {
|
|
t.Errorf("Unexpected applicable_regulations: %v", scope.ApplicableRegulations)
|
|
}
|
|
|
|
// Verify profile parsing
|
|
var profile struct {
|
|
CompanyName string `json:"company_name"`
|
|
ContactEmail string `json:"contact_email"`
|
|
}
|
|
if err := json.Unmarshal(parsed.CompanyProfile, &profile); err != nil {
|
|
t.Fatalf("Failed to parse company_profile: %v", err)
|
|
}
|
|
if profile.CompanyName != "TestCorp GmbH" {
|
|
t.Errorf("Expected company_name 'TestCorp GmbH', got %q", profile.CompanyName)
|
|
}
|
|
if profile.ContactEmail != "info@testcorp.de" {
|
|
t.Errorf("Expected contact_email 'info@testcorp.de', got %q", profile.ContactEmail)
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// GenerateSingleSection Tests
|
|
// ============================================================================
|
|
|
|
func TestGenerateSingleSection_InvalidProjectID_Returns400(t *testing.T) {
|
|
handler := &IACEHandler{}
|
|
|
|
w, c := newTestContext("POST", "/projects/invalid/tech-file/risk_assessment_report/generate", nil, nil, gin.Params{
|
|
{Key: "id", Value: "invalid"},
|
|
{Key: "section", Value: "risk_assessment_report"},
|
|
})
|
|
|
|
handler.GenerateSingleSection(c)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected 400, got %d", w.Code)
|
|
}
|
|
|
|
resp := parseResponse(w)
|
|
errMsg, _ := resp["error"].(string)
|
|
if errMsg != "invalid project ID" {
|
|
t.Errorf("Expected 'invalid project ID' error, got %q", errMsg)
|
|
}
|
|
}
|
|
|
|
func TestGenerateSingleSection_EmptySectionType_Returns400(t *testing.T) {
|
|
handler := &IACEHandler{}
|
|
|
|
w, c := newTestContext("POST", "/projects/00000000-0000-0000-0000-000000000001/tech-file//generate", nil, nil, gin.Params{
|
|
{Key: "id", Value: "00000000-0000-0000-0000-000000000001"},
|
|
{Key: "section", Value: ""},
|
|
})
|
|
|
|
handler.GenerateSingleSection(c)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected 400, got %d", w.Code)
|
|
}
|
|
|
|
resp := parseResponse(w)
|
|
errMsg, _ := resp["error"].(string)
|
|
if errMsg != "section type required" {
|
|
t.Errorf("Expected 'section type required' error, got %q", errMsg)
|
|
}
|
|
}
|
|
|
|
func TestGenerateSingleSection_SectionTitleMapping(t *testing.T) {
|
|
// Verify the section title map covers all 19 section types
|
|
sectionTitles := map[string]string{
|
|
"general_description": "General Description of the Machinery",
|
|
"risk_assessment_report": "Risk Assessment Report",
|
|
"hazard_log_combined": "Combined Hazard Log",
|
|
"essential_requirements": "Essential Health and Safety Requirements",
|
|
"design_specifications": "Design Specifications and Drawings",
|
|
"test_reports": "Test Reports and Verification Results",
|
|
"standards_applied": "Applied Harmonised Standards",
|
|
"declaration_of_conformity": "EU Declaration of Conformity",
|
|
"component_list": "Component List",
|
|
"classification_report": "Regulatory Classification Report",
|
|
"mitigation_report": "Mitigation Measures Report",
|
|
"verification_report": "Verification Report",
|
|
"evidence_index": "Evidence Index",
|
|
"instructions_for_use": "Instructions for Use",
|
|
"monitoring_plan": "Post-Market Monitoring Plan",
|
|
"ai_intended_purpose": "AI System Intended Purpose",
|
|
"ai_model_description": "AI Model Description and Training Data",
|
|
"ai_risk_management": "AI Risk Management System",
|
|
"ai_human_oversight": "AI Human Oversight Measures",
|
|
}
|
|
|
|
if len(sectionTitles) != 19 {
|
|
t.Errorf("Expected 19 section types, got %d", len(sectionTitles))
|
|
}
|
|
|
|
for key, title := range sectionTitles {
|
|
if key == "" {
|
|
t.Error("Section key must not be empty")
|
|
}
|
|
if title == "" {
|
|
t.Errorf("Section title for %q must not be empty", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// ExportTechFile Tests
|
|
// ============================================================================
|
|
|
|
func TestExportTechFile_InvalidProjectID_Returns400(t *testing.T) {
|
|
handler := &IACEHandler{}
|
|
|
|
w, c := newTestContext("GET", "/projects/invalid/tech-file/export", nil, nil, gin.Params{
|
|
{Key: "id", Value: "invalid"},
|
|
})
|
|
|
|
handler.ExportTechFile(c)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestExportTechFile_FormatQueryParam(t *testing.T) {
|
|
// Verify that DefaultQuery correctly parses format parameter
|
|
tests := []struct {
|
|
name string
|
|
queryString string
|
|
expectedQuery string
|
|
}{
|
|
{"default json", "", "json"},
|
|
{"explicit pdf", "format=pdf", "pdf"},
|
|
{"explicit xlsx", "format=xlsx", "xlsx"},
|
|
{"explicit docx", "format=docx", "docx"},
|
|
{"explicit md", "format=md", "md"},
|
|
{"explicit json", "format=json", "json"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
url := "/projects/test/tech-file/export"
|
|
if tt.queryString != "" {
|
|
url += "?" + tt.queryString
|
|
}
|
|
c.Request, _ = http.NewRequest("GET", url, nil)
|
|
|
|
result := c.DefaultQuery("format", "json")
|
|
if result != tt.expectedQuery {
|
|
t.Errorf("Expected format %q, got %q", tt.expectedQuery, result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExportTechFile_ContentDispositionHeaders(t *testing.T) {
|
|
// Verify the header format for different export types
|
|
tests := []struct {
|
|
format string
|
|
contentType string
|
|
}{
|
|
{"pdf", "application/pdf"},
|
|
{"xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
|
|
{"docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
|
|
{"md", "text/markdown"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.format, func(t *testing.T) {
|
|
if tt.contentType == "" {
|
|
t.Errorf("Content-Type for format %q must not be empty", tt.format)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// CheckCompleteness Tests
|
|
// ============================================================================
|
|
|
|
func TestCheckCompleteness_InvalidProjectID_Returns400(t *testing.T) {
|
|
handler := &IACEHandler{}
|
|
|
|
w, c := newTestContext("POST", "/projects/invalid/completeness-check", nil, nil, gin.Params{
|
|
{Key: "id", Value: "not-valid-uuid"},
|
|
})
|
|
|
|
handler.CheckCompleteness(c)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// getTenantID Tests
|
|
// ============================================================================
|
|
|
|
func TestGetTenantID_MissingHeader_ReturnsError(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request, _ = http.NewRequest("GET", "/test", nil)
|
|
|
|
_, err := getTenantID(c)
|
|
if err == nil {
|
|
t.Error("Expected error for missing X-Tenant-Id header")
|
|
}
|
|
}
|
|
|
|
func TestGetTenantID_InvalidUUID_ReturnsError(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request, _ = http.NewRequest("GET", "/test", nil)
|
|
c.Request.Header.Set("X-Tenant-Id", "not-a-uuid")
|
|
|
|
_, err := getTenantID(c)
|
|
if err == nil {
|
|
t.Error("Expected error for invalid UUID in X-Tenant-Id header")
|
|
}
|
|
}
|
|
|
|
func TestGetTenantID_ValidUUID_ReturnsUUID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request, _ = http.NewRequest("GET", "/test", nil)
|
|
c.Request.Header.Set("X-Tenant-Id", "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e")
|
|
|
|
tid, err := getTenantID(c)
|
|
if err != nil {
|
|
t.Fatalf("Unexpected error: %v", err)
|
|
}
|
|
if tid.String() != "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" {
|
|
t.Errorf("Expected tenant ID '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e', got %q", tid.String())
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// CreateProject / ListProjects — Input Validation
|
|
// ============================================================================
|
|
|
|
func TestCreateProject_MissingTenantID_Returns400(t *testing.T) {
|
|
handler := &IACEHandler{}
|
|
|
|
w, c := newTestContext("POST", "/projects", map[string]string{"machine_name": "Test"}, nil, nil)
|
|
|
|
handler.CreateProject(c)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestListProjects_MissingTenantID_Returns400(t *testing.T) {
|
|
handler := &IACEHandler{}
|
|
|
|
w, c := newTestContext("GET", "/projects", nil, nil, nil)
|
|
|
|
handler.ListProjects(c)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Component Handlers — Input Validation
|
|
// ============================================================================
|
|
|
|
func TestCreateComponent_InvalidProjectID_Returns400(t *testing.T) {
|
|
handler := &IACEHandler{}
|
|
|
|
w, c := newTestContext("POST", "/projects/invalid/components", map[string]string{"name": "Test"}, nil, gin.Params{
|
|
{Key: "id", Value: "invalid"},
|
|
})
|
|
|
|
handler.CreateComponent(c)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestListComponents_InvalidProjectID_Returns400(t *testing.T) {
|
|
handler := &IACEHandler{}
|
|
|
|
w, c := newTestContext("GET", "/projects/invalid/components", nil, nil, gin.Params{
|
|
{Key: "id", Value: "invalid"},
|
|
})
|
|
|
|
handler.ListComponents(c)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestUpdateComponent_InvalidProjectID_Returns400(t *testing.T) {
|
|
handler := &IACEHandler{}
|
|
|
|
w, c := newTestContext("PUT", "/projects/invalid/components/abc", map[string]string{"name": "New"}, nil, gin.Params{
|
|
{Key: "id", Value: "invalid"},
|
|
{Key: "cid", Value: "00000000-0000-0000-0000-000000000001"},
|
|
})
|
|
|
|
handler.UpdateComponent(c)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestUpdateComponent_InvalidComponentID_Returns400(t *testing.T) {
|
|
handler := &IACEHandler{}
|
|
|
|
w, c := newTestContext("PUT", "/projects/00000000-0000-0000-0000-000000000001/components/invalid", nil, nil, gin.Params{
|
|
{Key: "id", Value: "00000000-0000-0000-0000-000000000001"},
|
|
{Key: "cid", Value: "invalid"},
|
|
})
|
|
|
|
handler.UpdateComponent(c)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestDeleteComponent_InvalidProjectID_Returns400(t *testing.T) {
|
|
handler := &IACEHandler{}
|
|
|
|
w, c := newTestContext("DELETE", "/projects/invalid/components/abc", nil, nil, gin.Params{
|
|
{Key: "id", Value: "invalid"},
|
|
{Key: "cid", Value: "00000000-0000-0000-0000-000000000001"},
|
|
})
|
|
|
|
handler.DeleteComponent(c)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestDeleteComponent_InvalidComponentID_Returns400(t *testing.T) {
|
|
handler := &IACEHandler{}
|
|
|
|
w, c := newTestContext("DELETE", "/projects/00000000-0000-0000-0000-000000000001/components/invalid", nil, nil, gin.Params{
|
|
{Key: "id", Value: "00000000-0000-0000-0000-000000000001"},
|
|
{Key: "cid", Value: "invalid"},
|
|
})
|
|
|
|
handler.DeleteComponent(c)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected 400, got %d", w.Code)
|
|
}
|
|
}
|