diff --git a/internal/controller/http/redfish/v1/firmware.go b/internal/controller/http/redfish/v1/firmware.go new file mode 100644 index 000000000..69b1e1603 --- /dev/null +++ b/internal/controller/http/redfish/v1/firmware.go @@ -0,0 +1,799 @@ +// Package v1 implements Redfish API v1 FirmwareInventory resources. +package v1 + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "net/http" + "reflect" + "time" + + "github.com/gin-gonic/gin" + + "github.com/device-management-toolkit/console/internal/usecase/devices" + "github.com/device-management-toolkit/console/pkg/logger" +) + +// Firmware-related constants +const ( + // Common string constants + unknownValue = "Unknown" + biosID = "BIOS" + sleepDurationMs = 100 + systemManufacturer = "System Manufacturer" +) + +// FirmwareInventoryCollection represents a Redfish FirmwareInventory collection +type FirmwareInventoryCollection struct { + ODataContext string `json:"@odata.context"` + ODataID string `json:"@odata.id"` + ODataType string `json:"@odata.type"` + ODataEtag string `json:"@odata.etag,omitempty"` + ID string `json:"Id"` + Name string `json:"Name"` + Description string `json:"Description"` + Members []FirmwareInventoryMember `json:"Members"` + MembersCount int `json:"Members@odata.count"` + Oem map[string]interface{} `json:"Oem,omitempty"` +} + +// FirmwareInventoryMember represents a member reference in the collection +type FirmwareInventoryMember struct { + ODataID string `json:"@odata.id"` +} + +// FirmwareInventory represents a single firmware inventory item +type FirmwareInventory struct { + ODataContext string `json:"@odata.context"` + ODataID string `json:"@odata.id"` + ODataType string `json:"@odata.type"` + ODataEtag string `json:"@odata.etag,omitempty"` + ID string `json:"Id"` + Name string `json:"Name"` + Description string `json:"Description"` + Version string `json:"Version"` + VersionString string `json:"VersionString,omitempty"` + Manufacturer string `json:"Manufacturer,omitempty"` + ReleaseDate string `json:"ReleaseDate,omitempty"` + SoftwareID string `json:"SoftwareId"` + Updateable bool `json:"Updateable"` + Status Status `json:"Status"` + Oem map[string]interface{} `json:"Oem,omitempty"` +} + +// Status represents the health status of firmware +type Status struct { + State string `json:"State"` + Health string `json:"Health"` +} + +// NewFirmwareRoutes registers Redfish FirmwareInventory routes for Systems +// It exposes: +// - GET /redfish/v1/Systems/:id/FirmwareInventory +// - GET /redfish/v1/Systems/:id/FirmwareInventory/:firmwareId +func NewFirmwareRoutes(systems *gin.RouterGroup, d devices.Feature, l logger.Interface) { + // Add firmware inventory routes to existing Systems group + systems.GET(":id/FirmwareInventory", getFirmwareInventoryCollectionHandler(d, l)) + systems.GET(":id/FirmwareInventory/:firmwareId", getFirmwareInventoryInstanceHandler(d, l)) + + // Register method-not-allowed handlers for FirmwareInventory collection + systems.POST(":id/FirmwareInventory", func(c *gin.Context) { + HTTPMethodNotAllowedError(c, "POST", "SoftwareInventoryCollection", "GET") + }) + systems.PUT(":id/FirmwareInventory", func(c *gin.Context) { + HTTPMethodNotAllowedError(c, "PUT", "SoftwareInventoryCollection", "GET") + }) + systems.PATCH(":id/FirmwareInventory", func(c *gin.Context) { + HTTPMethodNotAllowedError(c, "PATCH", "SoftwareInventoryCollection", "GET") + }) + systems.DELETE(":id/FirmwareInventory", func(c *gin.Context) { + HTTPMethodNotAllowedError(c, "DELETE", "SoftwareInventoryCollection", "GET") + }) + + // Register method-not-allowed handlers for FirmwareInventory instances + systems.POST(":id/FirmwareInventory/:firmwareId", func(c *gin.Context) { + HTTPMethodNotAllowedError(c, "POST", "SoftwareInventory", "GET") + }) + systems.PUT(":id/FirmwareInventory/:firmwareId", func(c *gin.Context) { + HTTPMethodNotAllowedError(c, "PUT", "SoftwareInventory", "GET") + }) + systems.PATCH(":id/FirmwareInventory/:firmwareId", func(c *gin.Context) { + HTTPMethodNotAllowedError(c, "PATCH", "SoftwareInventory", "GET") + }) + systems.DELETE(":id/FirmwareInventory/:firmwareId", func(c *gin.Context) { + HTTPMethodNotAllowedError(c, "DELETE", "SoftwareInventory", "GET") + }) + + l.Info("Registered Redfish FirmwareInventory routes under %s", systems.BasePath()) +} + +// generateETag creates an ETag for caching based on content +func generateETag(content string) string { + hash := sha256.Sum256([]byte(content)) + + return fmt.Sprintf(`W/"%x"`, hash) +} + +// parseBIOSInfo extracts BIOS version information from hardware info structure +func parseBIOSInfo(hwInfo interface{}) (version, versionString, manufacturer, releaseDate string) { + return extractBIOSDetails(hwInfo) +} + +// extractBIOSDetails handles the complex parsing logic for BIOS information +func extractBIOSDetails(hwInfo interface{}) (version, versionString, manufacturer, releaseDate string) { + // Set defaults + version = unknownValue + versionString = unknownValue + manufacturer = systemManufacturer + releaseDate = time.Now().UTC().Format("2006-01-02") // Fallback to current date if not found + + if hwInfo == nil { + return version, versionString, manufacturer, releaseDate + } + + // Parse the hardware info map structure + if hwInfoMap, ok := hwInfo.(map[string]interface{}); ok { + version, versionString, manufacturer, releaseDate = parseFromMap(hwInfoMap) + } + + return version, versionString, manufacturer, releaseDate +} + +// parseFromMap extracts BIOS info from map structure +func parseFromMap(hwInfoMap map[string]interface{}) (version, versionString, manufacturer, releaseDate string) { + version = unknownValue + versionString = unknownValue + manufacturer = systemManufacturer + releaseDate = time.Now().UTC().Format("2006-01-02") + + if biosElement, exists := hwInfoMap["CIM_BIOSElement"]; exists { + if biosMap, ok := biosElement.(map[string]interface{}); ok { + if response, exists := biosMap["response"]; exists { + version, versionString, manufacturer, releaseDate = parseResponse(response, version, versionString, manufacturer, releaseDate) + } + } + } + + return version, versionString, manufacturer, releaseDate +} + +// parseResponse handles both map and struct response types +func parseResponse(response interface{}, _, _, _, _ string) (version, versionString, manufacturer, releaseDate string) { + if responseMap, ok := response.(map[string]interface{}); ok { + return parseFromResponseMap(responseMap) + } + + return parseFromStruct(response) +} + +// parseFromResponseMap extracts BIOS info from response map +func parseFromResponseMap(responseMap map[string]interface{}) (version, versionString, manufacturer, releaseDate string) { + version = unknownValue + versionString = unknownValue + manufacturer = systemManufacturer + releaseDate = time.Now().UTC().Format("2006-01-02") + + // Extract BIOS version information + if ver, exists := responseMap["Version"]; exists { + if verStr, ok := ver.(string); ok && verStr != "" { + version = verStr + versionString = verStr + } + } + + // Extract BIOS manufacturer + if mfg, exists := responseMap["Manufacturer"]; exists { + if mfgStr, ok := mfg.(string); ok && mfgStr != "" { + manufacturer = mfgStr + } + } + + // Extract release date for enhanced version string + if releaseDateObj, exists := responseMap["ReleaseDate"]; exists { + releaseDate = extractReleaseDateFromMap(releaseDateObj, version, releaseDate) + + if version != unknownValue { + versionString = fmt.Sprintf("%s (Released: %s)", version, releaseDate) + } + } + + return version, versionString, manufacturer, releaseDate +} + +// extractReleaseDateFromMap extracts release date from nested map structure +func extractReleaseDateFromMap(releaseDateObj interface{}, _, defaultReleaseDate string) string { + releaseDateMap, ok := releaseDateObj.(map[string]interface{}) + if !ok { + return defaultReleaseDate + } + + dateTime, exists := releaseDateMap["DateTime"] + if !exists { + return defaultReleaseDate + } + + dateStr, ok := dateTime.(string) + if !ok || dateStr == "" { + return defaultReleaseDate + } + + // Parse the ISO date and extract just the date part (YYYY-MM-DD) + parsedTime, err := time.Parse(time.RFC3339, dateStr) + if err != nil { + return defaultReleaseDate + } + + return parsedTime.Format("2006-01-02") +} + +// parseFromStruct handles response as a struct using reflection +func parseFromStruct(response interface{}) (version, versionString, manufacturer, releaseDate string) { + version = unknownValue + versionString = unknownValue + manufacturer = systemManufacturer + releaseDate = time.Now().UTC().Format("2006-01-02") + + // Use reflection to extract fields from the struct + responseValue := reflect.ValueOf(response) + if responseValue.Kind() == reflect.Ptr { + responseValue = responseValue.Elem() + } + + if responseValue.Kind() == reflect.Struct { + return extractFieldsFromStruct(responseValue) + } + + return version, versionString, manufacturer, releaseDate +} + +// extractFieldsFromStruct processes struct fields to extract BIOS information +func extractFieldsFromStruct(responseValue reflect.Value) (version, versionString, manufacturer, releaseDate string) { + version = unknownValue + versionString = unknownValue + manufacturer = systemManufacturer + releaseDate = time.Now().UTC().Format("2006-01-02") + + responseType := responseValue.Type() + + // Look for Version, Manufacturer, and ReleaseDate fields + for i := 0; i < responseValue.NumField(); i++ { + field := responseType.Field(i) + fieldValue := responseValue.Field(i) + + switch field.Name { + case "Version": + version, versionString = extractVersionFromField(fieldValue) + case "Manufacturer": + manufacturer = extractManufacturerFromField(fieldValue) + case "ReleaseDate": + releaseDate = extractReleaseDateFromStruct(fieldValue, "", releaseDate) + if version != unknownValue { + versionString = fmt.Sprintf("%s (Released: %s)", version, releaseDate) + } + } + } + + return version, versionString, manufacturer, releaseDate +} + +// extractVersionFromField extracts version from a struct field +func extractVersionFromField(fieldValue reflect.Value) (version, versionString string) { + if fieldValue.Kind() == reflect.String { + if verStr := fieldValue.String(); verStr != "" { + return verStr, verStr + } + } + + return unknownValue, unknownValue +} + +// extractManufacturerFromField extracts manufacturer from a struct field +func extractManufacturerFromField(fieldValue reflect.Value) string { + if fieldValue.Kind() == reflect.String { + if mfgStr := fieldValue.String(); mfgStr != "" { + return mfgStr + } + } + + return systemManufacturer +} + +// extractReleaseDateFromStruct extracts release date from struct field +func extractReleaseDateFromStruct(fieldValue reflect.Value, _, defaultReleaseDate string) string { + if !fieldValue.IsValid() { + return defaultReleaseDate + } + + // The ReleaseDate is a bios.Time struct that has a DateTime field + releaseDateStruct := fieldValue.Interface() + if releaseDateStruct == nil { + return defaultReleaseDate + } + + releaseDateValue := reflect.ValueOf(releaseDateStruct) + if releaseDateValue.Kind() != reflect.Struct { + return defaultReleaseDate + } + + dateTimeField := releaseDateValue.FieldByName("DateTime") + if !dateTimeField.IsValid() || dateTimeField.Kind() != reflect.String { + return defaultReleaseDate + } + + dateTimeStr := dateTimeField.String() + if dateTimeStr == "" { + return defaultReleaseDate + } + + // Parse the ISO date and extract just the date part (YYYY-MM-DD) + parsedTime, err := time.Parse(time.RFC3339, dateTimeStr) + if err != nil { + return defaultReleaseDate + } + + return parsedTime.Format("2006-01-02") +} // createIntelOemSection creates Intel-specific OEM extensions +func createIntelOemSection(systemID string) map[string]interface{} { + return map[string]interface{}{ + "Intel": map[string]interface{}{ + "@odata.type": "#Intel.v1_0_0.Intel", + "SystemGUID": systemID, + "LastUpdated": time.Now().UTC().Format(time.RFC3339), + "AMTCapabilities": map[string]interface{}{ + "SupportsSOL": true, + "SupportsIDER": true, + "SupportsKVM": true, + "SupportsPowerAction": true, + }, + }, + } +} + +// getFirmwareInventoryCollectionHandler handles GET /Systems/{id}/FirmwareInventory +func getFirmwareInventoryCollectionHandler(d devices.Feature, l logger.Interface) gin.HandlerFunc { + return func(c *gin.Context) { + systemID := c.Param("id") + + // Get AMT version information for AMT firmware components + _, versionInfo, err := d.GetVersion(c.Request.Context(), systemID) + if err != nil { + l.Error(err, "redfish v1 - FirmwareInventory: failed to get version for system %s", systemID) + ResourceNotFoundError(c, "ComputerSystem", systemID) + + return + } + + // Get hardware info and build collection + collection := buildFirmwareCollection(d, l, c, systemID, versionInfo) + + // Set Redfish-compliant headers + SetRedfishHeaders(c) + + // Set ETag header for HTTP caching + c.Header("ETag", collection.ODataEtag) + c.Header("Cache-Control", "max-age=300") // Cache for 5 minutes + + c.JSON(http.StatusOK, collection) + } +} + +// buildFirmwareCollection creates the firmware inventory collection +func buildFirmwareCollection(d devices.Feature, l logger.Interface, c *gin.Context, systemID string, versionInfo interface{}) FirmwareInventoryCollection { + // Get hardware information for BIOS and system firmware + // Add small delay to avoid potential connection conflicts + time.Sleep(sleepDurationMs * time.Millisecond) + l.Info("redfish v1 - FirmwareInventory: attempting to get hardware info for system %s", systemID) + + hwInfo, hwErr := d.GetHardwareInfo(c.Request.Context(), systemID) + if hwErr != nil { + l.Warn("redfish v1 - FirmwareInventory: failed to get hardware info for system %s: %v", systemID, hwErr) + } else { + // Debug: Log the hwInfo structure to understand what we're getting + if hwInfoJSON, err := json.Marshal(hwInfo); err == nil { + l.Info("redfish v1 - FirmwareInventory: hwInfo structure: %s", string(hwInfoJSON)) + } else { + l.Warn("redfish v1 - FirmwareInventory: failed to marshal hwInfo: %v", err) + } + } + + // Build firmware inventory collection from AMT version data + collection := FirmwareInventoryCollection{ + ODataContext: "/redfish/v1/$metadata#SoftwareInventoryCollection.SoftwareInventoryCollection", + ODataID: "/redfish/v1/Systems/" + systemID + "/FirmwareInventory", + ODataType: "#SoftwareInventoryCollection.SoftwareInventoryCollection", + ID: "FirmwareInventory", + Name: "Firmware Inventory Collection", + Description: "Collection of firmware inventory for this system", + Members: []FirmwareInventoryMember{}, + MembersCount: 0, + Oem: createIntelOemSection(systemID), + } + + // Add firmware members based on available version info + addFirmwareMembers(&collection, systemID, versionInfo) + + // Add system firmware from hardware info + if hwErr == nil && hwInfo != nil { + addBIOSMember(&collection, systemID) + } + + collection.MembersCount = len(collection.Members) + + // Generate ETag for caching + collectionContent := fmt.Sprintf("FirmwareInventory-%s-%d", systemID, collection.MembersCount) + collection.ODataEtag = generateETag(collectionContent) + + return collection +} + +// addFirmwareMembers adds firmware inventory members based on version info +func addFirmwareMembers(collection *FirmwareInventoryCollection, systemID string, versionInfo interface{}) { + // Use type assertion to access version info fields + // This assumes versionInfo has the expected structure + v := reflect.ValueOf(versionInfo) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + + if v.Kind() != reflect.Struct { + return + } + + // Add AMT firmware components as inventory items + if amt := getStringField(v, "AMT"); amt != "" { + collection.Members = append(collection.Members, FirmwareInventoryMember{ + ODataID: "/redfish/v1/Systems/" + systemID + "/FirmwareInventory/AMT", + }) + } + + if flash := getStringField(v, "Flash"); flash != "" { + collection.Members = append(collection.Members, FirmwareInventoryMember{ + ODataID: "/redfish/v1/Systems/" + systemID + "/FirmwareInventory/Flash", + }) + } + + if netstack := getStringField(v, "Netstack"); netstack != "" { + collection.Members = append(collection.Members, FirmwareInventoryMember{ + ODataID: "/redfish/v1/Systems/" + systemID + "/FirmwareInventory/Netstack", + }) + } + + if amtApps := getStringField(v, "AMTApps"); amtApps != "" { + collection.Members = append(collection.Members, FirmwareInventoryMember{ + ODataID: "/redfish/v1/Systems/" + systemID + "/FirmwareInventory/AMTApps", + }) + } +} + +// getStringField safely extracts a string field from a struct using reflection +func getStringField(v reflect.Value, fieldName string) string { + field := v.FieldByName(fieldName) + if field.IsValid() && field.Kind() == reflect.String { + return field.String() + } + + return "" +} + +// addBIOSMember adds BIOS firmware member to the collection +func addBIOSMember(collection *FirmwareInventoryCollection, systemID string) { + collection.Members = append(collection.Members, FirmwareInventoryMember{ + ODataID: "/redfish/v1/Systems/" + systemID + "/FirmwareInventory/BIOS", + }) +} + +// getFirmwareInventoryInstanceHandler handles GET /Systems/{id}/FirmwareInventory/{firmwareId} +func getFirmwareInventoryInstanceHandler(d devices.Feature, l logger.Interface) gin.HandlerFunc { + return func(c *gin.Context) { + systemID := c.Param("id") + firmwareID := c.Param("firmwareId") + + // Get AMT version information + _, versionInfo, err := d.GetVersion(c.Request.Context(), systemID) + if err != nil { + l.Error(err, "redfish v1 - FirmwareInventory: failed to get version for system %s", systemID) + ResourceNotFoundError(c, "ComputerSystem", systemID) + + return + } + + // Get hardware info if needed for BIOS + hwInfo, err := getHardwareInfoIfNeeded(d, l, c, systemID, firmwareID) + if err != nil { + return // Error already handled in the function + } + + // Get the specific firmware inventory item + firmware := getFirmwareItem(systemID, firmwareID, versionInfo, hwInfo, l) + if firmware == nil { + ResourceNotFoundError(c, "SoftwareInventory", firmwareID) + + return + } + + // Send response + sendFirmwareResponse(c, firmware) + } +} + +// getHardwareInfoIfNeeded gets hardware info only for BIOS requests +func getHardwareInfoIfNeeded(d devices.Feature, l logger.Interface, c *gin.Context, systemID, firmwareID string) (interface{}, error) { + if firmwareID != biosID { + return nil, nil + } + + l.Info("redfish v1 - FirmwareInventory: getting hardware info for BIOS firmware, system %s", systemID) + + hwInfo, err := d.GetHardwareInfo(c.Request.Context(), systemID) + if err != nil { + l.Error(err, "redfish v1 - FirmwareInventory: failed to get hardware info for system %s", systemID) + ResourceNotFoundError(c, "SoftwareInventory", firmwareID) + + return nil, err + } + + // Debug: Log what we actually got + if hwInfoJSON, marshalErr := json.Marshal(hwInfo); marshalErr == nil { + l.Info("redfish v1 - FirmwareInventory: BIOS hwInfo retrieved: %s", string(hwInfoJSON)) + } else { + l.Warn("redfish v1 - FirmwareInventory: failed to marshal BIOS hwInfo: %v", marshalErr) + } + + return hwInfo, nil +} + +// getFirmwareItem creates the appropriate firmware inventory item based on firmware ID +func getFirmwareItem(systemID, firmwareID string, versionInfo, hwInfo interface{}, l logger.Interface) *FirmwareInventory { + switch firmwareID { + case "AMT": + return createAMTFirmware(systemID, versionInfo) + case "Flash": + return createFlashFirmware(systemID, versionInfo) + case "Netstack": + return createNetstackFirmware(systemID, versionInfo) + case "AMTApps": + return createAMTAppsFirmware(systemID, versionInfo) + case biosID: + return createBIOSFirmware(systemID, hwInfo, l) + } + + return nil +} + +// sendFirmwareResponse sends the firmware inventory response +func sendFirmwareResponse(c *gin.Context, firmware *FirmwareInventory) { + // Set Redfish-compliant headers + SetRedfishHeaders(c) + + // Set ETag header for HTTP caching + if firmware.ODataEtag != "" { + c.Header("ETag", firmware.ODataEtag) + } + + c.Header("Cache-Control", "max-age=300") // Cache for 5 minutes + + c.JSON(http.StatusOK, firmware) +} + +// createAMTFirmware creates firmware inventory for AMT +func createAMTFirmware(systemID string, versionInfo interface{}) *FirmwareInventory { + v := reflect.ValueOf(versionInfo) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + + amt := getStringField(v, "AMT") + if amt == "" { + return nil + } + + return &FirmwareInventory{ + ODataContext: "/redfish/v1/$metadata#SoftwareInventory.SoftwareInventory", + ODataID: "/redfish/v1/Systems/" + systemID + "/FirmwareInventory/AMT", + ODataType: "#SoftwareInventory.v1_3_0.SoftwareInventory", + ODataEtag: generateETag(fmt.Sprintf("AMT-%s-%s", systemID, amt)), + ID: "AMT", + Name: "Intel Active Management Technology", + Description: "Intel AMT Firmware", + Version: amt, + VersionString: amt, + Manufacturer: "Intel Corporation", + ReleaseDate: time.Now().UTC().Format("2006-01-02"), // Current date as placeholder + SoftwareID: "AMT-" + systemID, + Updateable: false, // AMT firmware updates typically require special procedures + Status: Status{ + State: "Enabled", + Health: "OK", + }, + Oem: createAMTOemSection(versionInfo, systemID), + } +} + +// createFlashFirmware creates firmware inventory for Flash +func createFlashFirmware(systemID string, versionInfo interface{}) *FirmwareInventory { + v := reflect.ValueOf(versionInfo) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + + flash := getStringField(v, "Flash") + if flash == "" { + return nil + } + + return &FirmwareInventory{ + ODataContext: "/redfish/v1/$metadata#SoftwareInventory.SoftwareInventory", + ODataID: "/redfish/v1/Systems/" + systemID + "/FirmwareInventory/Flash", + ODataType: "#SoftwareInventory.v1_3_0.SoftwareInventory", + ODataEtag: generateETag(fmt.Sprintf("Flash-%s-%s", systemID, flash)), + ID: "Flash", + Name: "AMT Flash Firmware", + Description: "AMT Flash Memory Firmware", + Version: flash, + VersionString: flash, + Manufacturer: "Intel Corporation", + ReleaseDate: time.Now().UTC().Format("2006-01-02"), + SoftwareID: "Flash-" + systemID, + Updateable: false, + Status: Status{ + State: "Enabled", + Health: "OK", + }, + Oem: map[string]interface{}{ + "Intel": map[string]interface{}{ + "@odata.type": "#Intel.v1_0_0.Intel", + "FirmwareType": "Flash", + "Component": "AMT Flash Memory", + "SystemGUID": systemID, + }, + }, + } +} + +// createNetstackFirmware creates firmware inventory for Netstack +func createNetstackFirmware(systemID string, versionInfo interface{}) *FirmwareInventory { + v := reflect.ValueOf(versionInfo) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + + netstack := getStringField(v, "Netstack") + if netstack == "" { + return nil + } + + return &FirmwareInventory{ + ODataContext: "/redfish/v1/$metadata#SoftwareInventory.SoftwareInventory", + ODataID: "/redfish/v1/Systems/" + systemID + "/FirmwareInventory/Netstack", + ODataType: "#SoftwareInventory.v1_3_0.SoftwareInventory", + ODataEtag: generateETag(fmt.Sprintf("Netstack-%s-%s", systemID, netstack)), + ID: "Netstack", + Name: "AMT Network Stack", + Description: "AMT Network Stack Firmware", + Version: netstack, + VersionString: netstack, + Manufacturer: "Intel Corporation", + ReleaseDate: time.Now().UTC().Format("2006-01-02"), + SoftwareID: "Netstack-" + systemID, + Updateable: false, + Status: Status{ + State: "Enabled", + Health: "OK", + }, + Oem: map[string]interface{}{ + "Intel": map[string]interface{}{ + "@odata.type": "#Intel.v1_0_0.Intel", + "FirmwareType": "Netstack", + "Component": "AMT Network Stack", + "SystemGUID": systemID, + }, + }, + } +} + +// createAMTAppsFirmware creates firmware inventory for AMTApps +func createAMTAppsFirmware(systemID string, versionInfo interface{}) *FirmwareInventory { + v := reflect.ValueOf(versionInfo) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + + amtApps := getStringField(v, "AMTApps") + if amtApps == "" { + return nil + } + + return &FirmwareInventory{ + ODataContext: "/redfish/v1/$metadata#SoftwareInventory.SoftwareInventory", + ODataID: "/redfish/v1/Systems/" + systemID + "/FirmwareInventory/AMTApps", + ODataType: "#SoftwareInventory.v1_3_0.SoftwareInventory", + ODataEtag: generateETag(fmt.Sprintf("AMTApps-%s-%s", systemID, amtApps)), + ID: "AMTApps", + Name: "AMT Applications", + Description: "AMT Applications Firmware", + Version: amtApps, + VersionString: amtApps, + Manufacturer: "Intel Corporation", + ReleaseDate: time.Now().UTC().Format("2006-01-02"), + SoftwareID: "AMTApps-" + systemID, + Updateable: false, + Status: Status{ + State: "Enabled", + Health: "OK", + }, + Oem: map[string]interface{}{ + "Intel": map[string]interface{}{ + "@odata.type": "#Intel.v1_0_0.Intel", + "FirmwareType": "AMTApps", + "Component": "AMT Applications", + "SystemGUID": systemID, + }, + }, + } +} + +// createBIOSFirmware creates firmware inventory for BIOS +func createBIOSFirmware(systemID string, hwInfo interface{}, l logger.Interface) *FirmwareInventory { + if hwInfo == nil { + l.Warn("BIOS firmware request - hwInfo is nil, cannot retrieve BIOS version") + + return nil + } + + // Handle BIOS/UEFI system firmware + l.Info("BIOS case: hwInfo type: %T, hwInfo == nil: %v", hwInfo, hwInfo == nil) + + // Parse hardware info to extract BIOS version information + version, versionString, manufacturer, releaseDate := parseBIOSInfo(hwInfo) + + l.Info("BIOS case: parsed version=%s, manufacturer=%s, releaseDate=%s", version, manufacturer, releaseDate) + + return &FirmwareInventory{ + ODataContext: "/redfish/v1/$metadata#SoftwareInventory.SoftwareInventory", + ODataID: "/redfish/v1/Systems/" + systemID + "/FirmwareInventory/BIOS", + ODataType: "#SoftwareInventory.v1_3_0.SoftwareInventory", + ODataEtag: generateETag(fmt.Sprintf("BIOS-%s-%s", systemID, version)), + ID: "BIOS", + Name: "System BIOS/UEFI", + Description: "System BIOS/UEFI Firmware", + Version: version, + VersionString: versionString, + Manufacturer: manufacturer, + ReleaseDate: releaseDate, // Use actual BIOS release date instead of current date + SoftwareID: "BIOS-" + systemID, + Updateable: false, // BIOS updates typically require special procedures + Status: Status{ + State: "Enabled", + Health: "OK", + }, + Oem: map[string]interface{}{ + "Intel": map[string]interface{}{ + "@odata.type": "#Intel.v1_0_0.Intel", + "FirmwareType": "BIOS", + "Component": "System BIOS/UEFI", + "SystemGUID": systemID, + }, + }, + } +} + +// createAMTOemSection creates the OEM section for AMT firmware +func createAMTOemSection(versionInfo interface{}, _ string) map[string]interface{} { + v := reflect.ValueOf(versionInfo) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + + return map[string]interface{}{ + "Intel": map[string]interface{}{ + "@odata.type": "#Intel.v1_0_0.Intel", + "FirmwareType": "AMT", + "BuildNumber": getStringField(v, "BuildNumber"), + "AMTFWCore": getStringField(v, "AMTFWCoreVersion"), + "LegacyMode": getStringField(v, "LegacyMode"), + "SKU": getStringField(v, "SKU"), + "VendorID": getStringField(v, "VendorID"), + }, + } +} diff --git a/internal/controller/http/redfish/v1/firmware_test.go b/internal/controller/http/redfish/v1/firmware_test.go new file mode 100644 index 000000000..cf65a7ddf --- /dev/null +++ b/internal/controller/http/redfish/v1/firmware_test.go @@ -0,0 +1,1060 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2025 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +// Package v1 implements Redfish API v1 FirmwareInventory resources tests. +package v1 + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + dto "github.com/device-management-toolkit/console/internal/entity/dto/v1" + dtov2 "github.com/device-management-toolkit/console/internal/entity/dto/v2" + "github.com/device-management-toolkit/console/internal/mocks" +) + +const testSystemID = "test-system-123" + +func TestGenerateETag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + expected string + }{ + { + name: "empty content", + content: "", + expected: `W/"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"`, + }, + { + name: "simple content", + content: "test", + expected: `W/"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"`, + }, + { + name: "firmware inventory content", + content: "FirmwareInventory-system-123-5", + expected: `W/"f10d99bfef532179d77a3e1229f4a1a98ae4b9bfbed03d6839442365a8d759fd"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := generateETag(tt.content) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseBIOSInfo(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + hwInfo interface{} + expectedVersion string + expectedVersionStr string + expectedManufacturer string + expectedRelease string + }{ + { + name: "nil hardware info", + hwInfo: nil, + expectedVersion: "Unknown", + expectedVersionStr: "Unknown", + expectedManufacturer: "System Manufacturer", + expectedRelease: time.Now().UTC().Format("2006-01-02"), + }, + { + name: "complete BIOS info from map", + hwInfo: map[string]interface{}{ + "CIM_BIOSElement": map[string]interface{}{ + "response": map[string]interface{}{ + "Version": "DNKBLi7v.86A.0082.2024.0321.1028", + "Manufacturer": "Intel Corp.", + "ReleaseDate": map[string]interface{}{ + "DateTime": "2024-03-21T00:00:00Z", + }, + }, + }, + }, + expectedVersion: "DNKBLi7v.86A.0082.2024.0321.1028", + expectedVersionStr: "DNKBLi7v.86A.0082.2024.0321.1028 (Released: 2024-03-21)", + expectedManufacturer: "Intel Corp.", + expectedRelease: "2024-03-21", + }, + { + name: "BIOS info without release date", + hwInfo: map[string]interface{}{ + "CIM_BIOSElement": map[string]interface{}{ + "response": map[string]interface{}{ + "Version": "BIOS-1.0.0", + "Manufacturer": "ACME Corp.", + }, + }, + }, + expectedVersion: "BIOS-1.0.0", + expectedVersionStr: "BIOS-1.0.0", + expectedManufacturer: "ACME Corp.", + expectedRelease: time.Now().UTC().Format("2006-01-02"), + }, + { + name: "empty CIM_BIOSElement", + hwInfo: map[string]interface{}{ + "CIM_BIOSElement": map[string]interface{}{}, + }, + expectedVersion: "Unknown", + expectedVersionStr: "Unknown", + expectedManufacturer: "System Manufacturer", + expectedRelease: time.Now().UTC().Format("2006-01-02"), + }, + { + name: "invalid hardware info structure", + hwInfo: "invalid", + expectedVersion: "Unknown", + expectedVersionStr: "Unknown", + expectedManufacturer: "System Manufacturer", + expectedRelease: time.Now().UTC().Format("2006-01-02"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + version, versionStr, manufacturer, releaseDate := parseBIOSInfo(tt.hwInfo) + + assert.Equal(t, tt.expectedVersion, version) + assert.Equal(t, tt.expectedVersionStr, versionStr) + assert.Equal(t, tt.expectedManufacturer, manufacturer) + assert.Equal(t, tt.expectedRelease, releaseDate) + }) + } +} + +func TestParseFromStruct(t *testing.T) { + t.Parallel() + + // Mock BIOS struct similar to what would come from go-wsman-messages + type MockBIOSTime struct { + DateTime string + } + + type MockBIOSElement struct { + Version string + Manufacturer string + ReleaseDate MockBIOSTime + } + + tests := []struct { + name string + response interface{} + expectedVersion string + expectedVersionStr string + expectedManufacturer string + expectedRelease string + }{ + { + name: "valid BIOS struct", + response: MockBIOSElement{ + Version: "STRUCT-BIOS-1.2.3", + Manufacturer: "Struct Corp.", + ReleaseDate: MockBIOSTime{ + DateTime: "2023-12-15T00:00:00Z", + }, + }, + expectedVersion: "STRUCT-BIOS-1.2.3", + expectedVersionStr: "STRUCT-BIOS-1.2.3 (Released: 2023-12-15)", + expectedManufacturer: "Struct Corp.", + expectedRelease: "2023-12-15", + }, + { + name: "non-struct response", + response: "not a struct", + expectedVersion: "Unknown", + expectedVersionStr: "Unknown", + expectedManufacturer: "System Manufacturer", + expectedRelease: time.Now().UTC().Format("2006-01-02"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + version, versionStr, manufacturer, releaseDate := parseFromStruct(tt.response) + + assert.Equal(t, tt.expectedVersion, version) + assert.Equal(t, tt.expectedVersionStr, versionStr) + assert.Equal(t, tt.expectedManufacturer, manufacturer) + assert.Equal(t, tt.expectedRelease, releaseDate) + }) + } +} + +func TestExtractReleaseDateFromMap(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input interface{} + defaultDate string + expected string + }{ + { + name: "valid date map", + input: map[string]interface{}{ + "DateTime": "2024-01-15T10:30:00Z", + }, + defaultDate: "2023-01-01", + expected: "2024-01-15", + }, + { + name: "non-map input", + input: "not a map", + defaultDate: "2023-01-01", + expected: "2023-01-01", + }, + { + name: "map without DateTime", + input: map[string]interface{}{"other": "value"}, + defaultDate: "2023-01-01", + expected: "2023-01-01", + }, + { + name: "invalid DateTime format", + input: map[string]interface{}{ + "DateTime": "invalid-date", + }, + defaultDate: "2023-01-01", + expected: "2023-01-01", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := extractReleaseDateFromMap(tt.input, "", tt.defaultDate) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetStringField(t *testing.T) { + t.Parallel() + + type TestStruct struct { + StringField string + IntField int + BoolField bool + } + + testStruct := TestStruct{ + StringField: "test-value", + IntField: 42, + BoolField: true, + } + + v := reflect.ValueOf(testStruct) + + tests := []struct { + name string + fieldName string + expected string + }{ + { + name: "existing string field", + fieldName: "StringField", + expected: "test-value", + }, + { + name: "non-string field", + fieldName: "IntField", + expected: "", + }, + { + name: "non-existent field", + fieldName: "NonExistent", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := getStringField(v, tt.fieldName) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestCreateIntelOemSection(t *testing.T) { + t.Parallel() + + systemID := testSystemID + result := createIntelOemSection(systemID) + + assert.Contains(t, result, "Intel") + intel, ok := result["Intel"].(map[string]interface{}) + require.True(t, ok) + + assert.Equal(t, "#Intel.v1_0_0.Intel", intel["@odata.type"]) + assert.Equal(t, systemID, intel["SystemGUID"]) + assert.Contains(t, intel, "LastUpdated") + assert.Contains(t, intel, "AMTCapabilities") + + caps, ok := intel["AMTCapabilities"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, true, caps["SupportsSOL"]) + assert.Equal(t, true, caps["SupportsIDER"]) + assert.Equal(t, true, caps["SupportsKVM"]) + assert.Equal(t, true, caps["SupportsPowerAction"]) +} + +func TestGetFirmwareInventoryCollectionHandler(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + systemID string + setupMocks func(*mocks.MockDeviceManagementFeature, *mocks.MockLogger) + expectedStatus int + expectedMembersCount int + validateResponse func(t *testing.T, body string, headers http.Header) + }{ + { + name: "successful collection retrieval", + systemID: "valid-system-id", + setupMocks: func(mockFeature *mocks.MockDeviceManagementFeature, mockLogger *mocks.MockLogger) { + // Mock successful GetVersion call + mockFeature.EXPECT(). + GetVersion(gomock.Any(), "valid-system-id"). + Return(dto.Version{}, dtov2.Version{AMT: "15.0.25"}, nil) + + // Mock successful GetHardwareInfo call + hwInfo := map[string]interface{}{ + "CIM_BIOSElement": map[string]interface{}{ + "response": map[string]interface{}{ + "Version": "BIOS-1.0.0", + }, + }, + } + mockFeature.EXPECT(). + GetHardwareInfo(gomock.Any(), "valid-system-id"). + Return(hwInfo, nil) + + // Logger expectations + mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() + mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes() + }, + expectedStatus: http.StatusOK, + expectedMembersCount: 2, // AMT + BIOS + validateResponse: func(t *testing.T, body string, headers http.Header) { + t.Helper() + + var collection FirmwareInventoryCollection + + err := json.Unmarshal([]byte(body), &collection) + require.NoError(t, err) + + assert.Equal(t, "/redfish/v1/Systems/valid-system-id/FirmwareInventory", collection.ODataID) + assert.Equal(t, "#SoftwareInventoryCollection.SoftwareInventoryCollection", collection.ODataType) + assert.Equal(t, "FirmwareInventory", collection.ID) + assert.Equal(t, "Firmware Inventory Collection", collection.Name) + assert.Greater(t, len(collection.Members), 0) + assert.Equal(t, len(collection.Members), collection.MembersCount) + + // Check headers + assert.Equal(t, "application/json; charset=utf-8", headers.Get("Content-Type")) + assert.Equal(t, "4.0", headers.Get("OData-Version")) + assert.Contains(t, headers.Get("ETag"), `W/"`) + assert.Equal(t, "max-age=300", headers.Get("Cache-Control")) + }, + }, + { + name: "GetVersion failure - system not found", + systemID: "invalid-system-id", + setupMocks: func(mockFeature *mocks.MockDeviceManagementFeature, mockLogger *mocks.MockLogger) { + mockFeature.EXPECT(). + GetVersion(gomock.Any(), "invalid-system-id"). + Return(dto.Version{}, dtov2.Version{}, fmt.Errorf("system not found")) + + mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).Times(1) + mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() + }, + expectedStatus: http.StatusNotFound, + validateResponse: func(t *testing.T, body string, _ http.Header) { + t.Helper() + assert.Contains(t, body, "Base.1.11.0.ResourceNotFound") + assert.Contains(t, body, "ComputerSystem") + assert.Contains(t, body, "invalid-system-id") + }, + }, + { + name: "GetHardwareInfo failure but GetVersion succeeds", + systemID: "partial-system-id", + setupMocks: func(mockFeature *mocks.MockDeviceManagementFeature, mockLogger *mocks.MockLogger) { + mockFeature.EXPECT(). + GetVersion(gomock.Any(), "partial-system-id"). + Return(dto.Version{}, dtov2.Version{AMT: "15.0.25", Flash: "1.2.3"}, nil) + + mockFeature.EXPECT(). + GetHardwareInfo(gomock.Any(), "partial-system-id"). + Return(nil, fmt.Errorf("hardware info not available")) + + mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() + mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).Times(1) + }, + expectedStatus: http.StatusOK, + expectedMembersCount: 2, // AMT + Flash, no BIOS + validateResponse: func(t *testing.T, body string, _ http.Header) { + t.Helper() + + var collection FirmwareInventoryCollection + + err := json.Unmarshal([]byte(body), &collection) + require.NoError(t, err) + + assert.Equal(t, 2, collection.MembersCount) + // Should not contain BIOS member when hardware info fails + for _, member := range collection.Members { + assert.NotContains(t, member.ODataID, "/BIOS") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockFeature := mocks.NewMockDeviceManagementFeature(ctrl) + mockLogger := mocks.NewMockLogger(ctrl) + + tt.setupMocks(mockFeature, mockLogger) + + gin.SetMode(gin.TestMode) + router := gin.New() + + // Setup routes + systems := router.Group("/redfish/v1/Systems") + NewFirmwareRoutes(systems, mockFeature, mockLogger) + + w := httptest.NewRecorder() + req, _ := http.NewRequestWithContext( + context.Background(), + "GET", + "/redfish/v1/Systems/"+tt.systemID+"/FirmwareInventory", + http.NoBody, + ) + + router.ServeHTTP(w, req) + + assert.Equal(t, tt.expectedStatus, w.Code) + + if tt.validateResponse != nil { + tt.validateResponse(t, w.Body.String(), w.Header()) + } + }) + } +} + +func TestGetFirmwareInventoryInstanceHandler(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + systemID string + firmwareID string + setupMocks func(*mocks.MockDeviceManagementFeature, *mocks.MockLogger) + expectedStatus int + validateResponse func(t *testing.T, body string, headers http.Header) + }{ + { + name: "successful AMT firmware retrieval", + systemID: "test-system", + firmwareID: "AMT", + setupMocks: func(mockFeature *mocks.MockDeviceManagementFeature, mockLogger *mocks.MockLogger) { + mockFeature.EXPECT(). + GetVersion(gomock.Any(), "test-system"). + Return(dto.Version{}, dtov2.Version{AMT: "15.0.25"}, nil) + + mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() + }, + expectedStatus: http.StatusOK, + validateResponse: func(t *testing.T, body string, _ http.Header) { + t.Helper() + + var firmware FirmwareInventory + + err := json.Unmarshal([]byte(body), &firmware) + require.NoError(t, err) + + assert.Equal(t, "AMT", firmware.ID) + assert.Equal(t, "Intel Active Management Technology", firmware.Name) + assert.Equal(t, "15.0.25", firmware.Version) + assert.Equal(t, "Intel Corporation", firmware.Manufacturer) + assert.Equal(t, "#SoftwareInventory.v1_3_0.SoftwareInventory", firmware.ODataType) + }, + }, + { + name: "successful BIOS firmware retrieval", + systemID: "test-system", + firmwareID: "BIOS", + setupMocks: func(mockFeature *mocks.MockDeviceManagementFeature, mockLogger *mocks.MockLogger) { + mockFeature.EXPECT(). + GetVersion(gomock.Any(), "test-system"). + Return(dto.Version{}, dtov2.Version{}, nil) + + hwInfo := map[string]interface{}{ + "CIM_BIOSElement": map[string]interface{}{ + "response": map[string]interface{}{ + "Version": "DNKBLi7v.86A.0082.2024.0321.1028", + "Manufacturer": "Intel Corp.", + "ReleaseDate": map[string]interface{}{ + "DateTime": "2024-03-21T00:00:00Z", + }, + }, + }, + } + mockFeature.EXPECT(). + GetHardwareInfo(gomock.Any(), "test-system"). + Return(hwInfo, nil) + + mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() + }, + expectedStatus: http.StatusOK, + validateResponse: func(t *testing.T, body string, _ http.Header) { + t.Helper() + + var firmware FirmwareInventory + + err := json.Unmarshal([]byte(body), &firmware) + require.NoError(t, err) + + assert.Equal(t, "BIOS", firmware.ID) + assert.Equal(t, "System BIOS/UEFI", firmware.Name) + assert.Equal(t, "DNKBLi7v.86A.0082.2024.0321.1028", firmware.Version) + assert.Equal(t, "Intel Corp.", firmware.Manufacturer) + assert.Equal(t, "2024-03-21", firmware.ReleaseDate) + }, + }, + { + name: "firmware not found", + systemID: "test-system", + firmwareID: "NonExistent", + setupMocks: func(mockFeature *mocks.MockDeviceManagementFeature, mockLogger *mocks.MockLogger) { + mockFeature.EXPECT(). + GetVersion(gomock.Any(), "test-system"). + Return(dto.Version{}, dtov2.Version{}, nil) + + mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() + }, + expectedStatus: http.StatusNotFound, + validateResponse: func(t *testing.T, body string, _ http.Header) { + t.Helper() + assert.Contains(t, body, "Base.1.11.0.ResourceNotFound") + assert.Contains(t, body, "SoftwareInventory") + assert.Contains(t, body, "NonExistent") + }, + }, + { + name: "system not found", + systemID: "invalid-system", + firmwareID: "AMT", + setupMocks: func(mockFeature *mocks.MockDeviceManagementFeature, mockLogger *mocks.MockLogger) { + mockFeature.EXPECT(). + GetVersion(gomock.Any(), "invalid-system"). + Return(dto.Version{}, dtov2.Version{}, fmt.Errorf("system not found")) + + mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).Times(1) + mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() + }, + expectedStatus: http.StatusNotFound, + validateResponse: func(t *testing.T, body string, _ http.Header) { + t.Helper() + assert.Contains(t, body, "Base.1.11.0.ResourceNotFound") + assert.Contains(t, body, "ComputerSystem") + }, + }, + { + name: "BIOS hardware info failure", + systemID: "test-system", + firmwareID: "BIOS", + setupMocks: func(mockFeature *mocks.MockDeviceManagementFeature, mockLogger *mocks.MockLogger) { + mockFeature.EXPECT(). + GetVersion(gomock.Any(), "test-system"). + Return(dto.Version{}, dtov2.Version{}, nil) + + mockFeature.EXPECT(). + GetHardwareInfo(gomock.Any(), "test-system"). + Return(nil, fmt.Errorf("hardware info not available")) + + mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() + mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).Times(1) + }, + expectedStatus: http.StatusNotFound, + validateResponse: func(t *testing.T, body string, _ http.Header) { + t.Helper() + assert.Contains(t, body, "Base.1.11.0.ResourceNotFound") + assert.Contains(t, body, "SoftwareInventory") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockFeature := mocks.NewMockDeviceManagementFeature(ctrl) + mockLogger := mocks.NewMockLogger(ctrl) + + tt.setupMocks(mockFeature, mockLogger) + + gin.SetMode(gin.TestMode) + router := gin.New() + + // Setup routes + systems := router.Group("/redfish/v1/Systems") + NewFirmwareRoutes(systems, mockFeature, mockLogger) + + w := httptest.NewRecorder() + req, _ := http.NewRequestWithContext( + context.Background(), + "GET", + fmt.Sprintf("/redfish/v1/Systems/%s/FirmwareInventory/%s", tt.systemID, tt.firmwareID), + http.NoBody, + ) + + router.ServeHTTP(w, req) + + assert.Equal(t, tt.expectedStatus, w.Code) + + if tt.validateResponse != nil { + tt.validateResponse(t, w.Body.String(), w.Header()) + } + }) + } +} + +func TestCreateFirmwareFunctions(t *testing.T) { + t.Parallel() + + systemID := testSystemID + + t.Run("createAMTFirmware", func(t *testing.T) { + t.Parallel() + + versionInfo := dtov2.Version{AMT: "15.0.25"} + + firmware := createAMTFirmware(systemID, versionInfo) + require.NotNil(t, firmware) + + assert.Equal(t, "AMT", firmware.ID) + assert.Equal(t, "Intel Active Management Technology", firmware.Name) + assert.Equal(t, "15.0.25", firmware.Version) + assert.Equal(t, "Intel Corporation", firmware.Manufacturer) + assert.False(t, firmware.Updateable) + assert.Equal(t, "Enabled", firmware.Status.State) + assert.Equal(t, "OK", firmware.Status.Health) + }) + + t.Run("createAMTFirmware with empty version", func(t *testing.T) { + t.Parallel() + + versionInfo := dtov2.Version{AMT: ""} + + firmware := createAMTFirmware(systemID, versionInfo) + assert.Nil(t, firmware) + }) + + t.Run("createFlashFirmware", func(t *testing.T) { + t.Parallel() + + versionInfo := dtov2.Version{Flash: "1.2.3"} + + firmware := createFlashFirmware(systemID, versionInfo) + require.NotNil(t, firmware) + + assert.Equal(t, "Flash", firmware.ID) + assert.Equal(t, "AMT Flash Firmware", firmware.Name) + assert.Equal(t, "1.2.3", firmware.Version) + assert.Equal(t, "Intel Corporation", firmware.Manufacturer) + }) + + t.Run("createNetstackFirmware", func(t *testing.T) { + t.Parallel() + + versionInfo := dtov2.Version{Netstack: "2.3.4"} + + firmware := createNetstackFirmware(systemID, versionInfo) + require.NotNil(t, firmware) + + assert.Equal(t, "Netstack", firmware.ID) + assert.Equal(t, "AMT Network Stack", firmware.Name) + assert.Equal(t, "2.3.4", firmware.Version) + assert.Equal(t, "Intel Corporation", firmware.Manufacturer) + }) + + t.Run("createAMTAppsFirmware", func(t *testing.T) { + t.Parallel() + + versionInfo := dtov2.Version{AMTApps: "3.4.5"} + + firmware := createAMTAppsFirmware(systemID, versionInfo) + require.NotNil(t, firmware) + + assert.Equal(t, "AMTApps", firmware.ID) + assert.Equal(t, "AMT Applications", firmware.Name) + assert.Equal(t, "3.4.5", firmware.Version) + assert.Equal(t, "Intel Corporation", firmware.Manufacturer) + }) + + t.Run("createBIOSFirmware", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockLogger := mocks.NewMockLogger(ctrl) + mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() + + hwInfo := map[string]interface{}{ + "CIM_BIOSElement": map[string]interface{}{ + "response": map[string]interface{}{ + "Version": "BIOS-1.0.0", + "Manufacturer": "Test Corp.", + "ReleaseDate": map[string]interface{}{ + "DateTime": "2024-01-15T00:00:00Z", + }, + }, + }, + } + + firmware := createBIOSFirmware(systemID, hwInfo, mockLogger) + require.NotNil(t, firmware) + + assert.Equal(t, "BIOS", firmware.ID) + assert.Equal(t, "System BIOS/UEFI", firmware.Name) + assert.Equal(t, "BIOS-1.0.0", firmware.Version) + assert.Equal(t, "Test Corp.", firmware.Manufacturer) + assert.Equal(t, "2024-01-15", firmware.ReleaseDate) + }) + + t.Run("createBIOSFirmware with nil hwInfo", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockLogger := mocks.NewMockLogger(ctrl) + mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).Times(1) + + firmware := createBIOSFirmware(systemID, nil, mockLogger) + assert.Nil(t, firmware) + }) +} + +func TestHTTPMethodNotAllowedHandlers(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + method string + path string + expected int + }{ + { + name: "POST to collection not allowed", + method: "POST", + path: "/redfish/v1/Systems/test-id/FirmwareInventory", + expected: http.StatusMethodNotAllowed, + }, + { + name: "PUT to collection not allowed", + method: "PUT", + path: "/redfish/v1/Systems/test-id/FirmwareInventory", + expected: http.StatusMethodNotAllowed, + }, + { + name: "DELETE to collection not allowed", + method: "DELETE", + path: "/redfish/v1/Systems/test-id/FirmwareInventory", + expected: http.StatusMethodNotAllowed, + }, + { + name: "PATCH to collection not allowed", + method: "PATCH", + path: "/redfish/v1/Systems/test-id/FirmwareInventory", + expected: http.StatusMethodNotAllowed, + }, + { + name: "POST to instance not allowed", + method: "POST", + path: "/redfish/v1/Systems/test-id/FirmwareInventory/AMT", + expected: http.StatusMethodNotAllowed, + }, + { + name: "PUT to instance not allowed", + method: "PUT", + path: "/redfish/v1/Systems/test-id/FirmwareInventory/AMT", + expected: http.StatusMethodNotAllowed, + }, + { + name: "DELETE to instance not allowed", + method: "DELETE", + path: "/redfish/v1/Systems/test-id/FirmwareInventory/AMT", + expected: http.StatusMethodNotAllowed, + }, + { + name: "PATCH to instance not allowed", + method: "PATCH", + path: "/redfish/v1/Systems/test-id/FirmwareInventory/AMT", + expected: http.StatusMethodNotAllowed, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockFeature := mocks.NewMockDeviceManagementFeature(ctrl) + mockLogger := mocks.NewMockLogger(ctrl) + + // Add logger expectation for route registration + mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() + + gin.SetMode(gin.TestMode) + router := gin.New() + + // Setup routes + systems := router.Group("/redfish/v1/Systems") + NewFirmwareRoutes(systems, mockFeature, mockLogger) + + w := httptest.NewRecorder() + req, _ := http.NewRequestWithContext( + context.Background(), + tt.method, + tt.path, + http.NoBody, + ) + + router.ServeHTTP(w, req) + + assert.Equal(t, tt.expected, w.Code) + + // Check that error response is Redfish compliant + body := w.Body.String() + assert.Contains(t, body, "Base.1.11.0.OperationNotAllowed") + assert.Contains(t, body, "@Message.ExtendedInfo") + + // Check Allow header for method not allowed + assert.Equal(t, "GET", w.Header().Get("Allow")) + }) + } +} + +func TestAddFirmwareMembers(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + versionInfo dtov2.Version + expectedCount int + expectedPaths []string + }{ + { + name: "all firmware components present", + versionInfo: dtov2.Version{ + AMT: "15.0.25", + Flash: "1.2.3", + Netstack: "2.3.4", + AMTApps: "3.4.5", + }, + expectedCount: 4, + expectedPaths: []string{ + "/redfish/v1/Systems/test-id/FirmwareInventory/AMT", + "/redfish/v1/Systems/test-id/FirmwareInventory/Flash", + "/redfish/v1/Systems/test-id/FirmwareInventory/Netstack", + "/redfish/v1/Systems/test-id/FirmwareInventory/AMTApps", + }, + }, + { + name: "partial firmware components", + versionInfo: dtov2.Version{ + AMT: "15.0.25", + Flash: "1.2.3", + }, + expectedCount: 2, + expectedPaths: []string{ + "/redfish/v1/Systems/test-id/FirmwareInventory/AMT", + "/redfish/v1/Systems/test-id/FirmwareInventory/Flash", + }, + }, + { + name: "no firmware components", + versionInfo: dtov2.Version{}, + expectedCount: 0, + expectedPaths: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + collection := &FirmwareInventoryCollection{ + Members: []FirmwareInventoryMember{}, + } + + addFirmwareMembers(collection, "test-id", tt.versionInfo) + + assert.Equal(t, tt.expectedCount, len(collection.Members)) + + actualPaths := make([]string, len(collection.Members)) + for i, member := range collection.Members { + actualPaths[i] = member.ODataID + } + + for _, expectedPath := range tt.expectedPaths { + assert.Contains(t, actualPaths, expectedPath) + } + }) + } +} + +func TestGetFirmwareItem(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockLogger := mocks.NewMockLogger(ctrl) + mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() + mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes() + + versionInfo := dtov2.Version{ + AMT: "15.0.25", + Flash: "1.2.3", + Netstack: "2.3.4", + AMTApps: "3.4.5", + } + + hwInfo := map[string]interface{}{ + "CIM_BIOSElement": map[string]interface{}{ + "response": map[string]interface{}{ + "Version": "BIOS-1.0.0", + }, + }, + } + + tests := []struct { + name string + firmwareID string + expectNil bool + expectedID string + }{ + { + name: "get AMT firmware", + firmwareID: "AMT", + expectNil: false, + expectedID: "AMT", + }, + { + name: "get Flash firmware", + firmwareID: "Flash", + expectNil: false, + expectedID: "Flash", + }, + { + name: "get Netstack firmware", + firmwareID: "Netstack", + expectNil: false, + expectedID: "Netstack", + }, + { + name: "get AMTApps firmware", + firmwareID: "AMTApps", + expectNil: false, + expectedID: "AMTApps", + }, + { + name: "get BIOS firmware", + firmwareID: "BIOS", + expectNil: false, + expectedID: "BIOS", + }, + { + name: "get non-existent firmware", + firmwareID: "NonExistent", + expectNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := getFirmwareItem("test-system", tt.firmwareID, versionInfo, hwInfo, mockLogger) + + if tt.expectNil { + assert.Nil(t, result) + } else { + require.NotNil(t, result) + assert.Equal(t, tt.expectedID, result.ID) + } + }) + } +} + +func TestCreateAMTOemSection(t *testing.T) { + t.Parallel() + + versionInfo := dtov2.Version{ + BuildNumber: "123", + AMTFWCoreVersion: "15.0.25.1234", + SKU: "16392", + VendorID: "8086", + } + + result := createAMTOemSection(versionInfo, "test-system") + + require.Contains(t, result, "Intel") + intel, ok := result["Intel"].(map[string]interface{}) + require.True(t, ok) + + assert.Equal(t, "#Intel.v1_0_0.Intel", intel["@odata.type"]) + assert.Equal(t, "AMT", intel["FirmwareType"]) + assert.Equal(t, "123", intel["BuildNumber"]) + assert.Equal(t, "15.0.25.1234", intel["AMTFWCore"]) + assert.Equal(t, "16392", intel["SKU"]) + assert.Equal(t, "8086", intel["VendorID"]) +} diff --git a/internal/controller/http/redfish/v1/system.go b/internal/controller/http/redfish/v1/system.go index 5e5bde742..b5c37b557 100644 --- a/internal/controller/http/redfish/v1/system.go +++ b/internal/controller/http/redfish/v1/system.go @@ -1,3 +1,9 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2025 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +// Package v1 implements Redfish API v1 error handling and utilities. package v1 import ( @@ -36,12 +42,18 @@ const ( // - GET /redfish/v1/Systems // - GET /redfish/v1/Systems/:id // - POST /redfish/v1/Systems/:id/Actions/ComputerSystem.Reset +// - GET /redfish/v1/Systems/:id/FirmwareInventory +// - GET /redfish/v1/Systems/:id/FirmwareInventory/:firmwareId // The :id is expected to be the device GUID and will be mapped directly to SendPowerAction. func NewSystemsRoutes(r *gin.RouterGroup, d devices.Feature, l logger.Interface) { systems := r.Group("/Systems") systems.GET("", getSystemsCollectionHandler(d, l)) systems.GET(":id", getSystemInstanceHandler(d, l)) systems.POST(":id/Actions/ComputerSystem.Reset", postSystemResetHandler(d, l)) + + // Add firmware inventory routes + NewFirmwareRoutes(systems, d, l) + l.Info("Registered Redfish Systems routes under %s", r.BasePath()+"/Systems") } diff --git a/internal/controller/http/redfish/v1/system_test.go b/internal/controller/http/redfish/v1/system_test.go new file mode 100644 index 000000000..131b6202c --- /dev/null +++ b/internal/controller/http/redfish/v1/system_test.go @@ -0,0 +1,1004 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2025 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +// Package v1 implements Redfish API v1 System resources tests. +package v1 + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/power" + + dto "github.com/device-management-toolkit/console/internal/entity/dto/v1" + dtov2 "github.com/device-management-toolkit/console/internal/entity/dto/v2" + "github.com/device-management-toolkit/console/internal/mocks" +) + +const ( + testSystemGUID = "test-system-guid-123" + testInvalidGUID = "invalid-system-guid" + systemsBasePath = "/redfish/v1/Systems" + systemsInstanceURL = systemsBasePath + "/" + testSystemGUID + resetActionURL = systemsInstanceURL + "/Actions/ComputerSystem.Reset" +) + +func TestNewSystemsRoutes(t *testing.T) { + t.Parallel() + + t.Run("routes registration", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockFeature := mocks.NewMockDeviceManagementFeature(ctrl) + mockLogger := mocks.NewMockLogger(ctrl) + + // Expect logging calls for route registration + mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).Times(2) // Systems + Firmware routes + + gin.SetMode(gin.TestMode) + router := gin.New() + + // Test route registration + redfishGroup := router.Group("/redfish/v1") + NewSystemsRoutes(redfishGroup, mockFeature, mockLogger) + + // Verify routes exist by testing them + routes := router.Routes() + + // Check that expected routes are registered + expectedRoutes := []string{ + "GET /redfish/v1/Systems", + "GET /redfish/v1/Systems/:id", + "POST /redfish/v1/Systems/:id/Actions/ComputerSystem.Reset", + "GET /redfish/v1/Systems/:id/FirmwareInventory", + "GET /redfish/v1/Systems/:id/FirmwareInventory/:firmwareId", + } + + routeMap := make(map[string]bool) + + for _, route := range routes { + key := fmt.Sprintf("%s %s", route.Method, route.Path) + routeMap[key] = true + } + + for _, expectedRoute := range expectedRoutes { + assert.True(t, routeMap[expectedRoute], "Route %s should be registered", expectedRoute) + } + }) + + t.Run("with nil dependencies", func(t *testing.T) { + t.Parallel() + + gin.SetMode(gin.TestMode) + router := gin.New() + redfishGroup := router.Group("/redfish/v1") + + // This will panic due to firmware routes accessing nil logger + // Testing that routes can be set up, but will fail on actual usage + require.Panics(t, func() { + NewSystemsRoutes(redfishGroup, nil, nil) + }) + }) +} + +func TestGetSystemsCollectionHandler(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupMocks func(*mocks.MockDeviceManagementFeature, *mocks.MockLogger) + expectedStatus int + validateResponse func(t *testing.T, body string) + }{ + { + name: "successful collection retrieval", + setupMocks: func(mockFeature *mocks.MockDeviceManagementFeature, mockLogger *mocks.MockLogger) { + devices := []dto.Device{ + { + GUID: "system-1", + Hostname: "host1", + Tags: []string{"tag1"}, + DNSSuffix: "example.com", + Username: "admin", + Password: "password", + UseTLS: true, + AllowSelfSigned: false, + }, + { + GUID: "system-2", + Hostname: "host2", + Tags: []string{"tag2"}, + DNSSuffix: "example.com", + Username: "admin", + Password: "password", + UseTLS: true, + AllowSelfSigned: false, + }, + { + GUID: "", // Should be filtered out + Hostname: "host3", + Tags: []string{}, + DNSSuffix: "example.com", + Username: "admin", + Password: "password", + UseTLS: false, + AllowSelfSigned: true, + }, + } + + mockFeature.EXPECT(). + Get(gomock.Any(), maxSystemsList, 0, ""). + Return(devices, nil) + + mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() + }, + expectedStatus: http.StatusOK, + validateResponse: func(t *testing.T, body string) { + t.Helper() + + var collection map[string]interface{} + + err := json.Unmarshal([]byte(body), &collection) + require.NoError(t, err) + + assert.Equal(t, "#ComputerSystemCollection.ComputerSystemCollection", collection["@odata.type"]) + assert.Equal(t, "/redfish/v1/Systems", collection["@odata.id"]) + assert.Equal(t, "Computer System Collection", collection["Name"]) + + members, ok := collection["Members"].([]interface{}) + require.True(t, ok, "Members should be a slice of interfaces") + assert.Equal(t, 2, len(members)) // Only 2 systems with valid GUIDs + + membersCount, ok := collection["Members@odata.count"].(float64) + require.True(t, ok, "Members@odata.count should be a float64") + assert.Equal(t, 2.0, membersCount) + + // Check first member + member1, ok := members[0].(map[string]interface{}) + require.True(t, ok, "First member should be a map") + assert.Equal(t, "/redfish/v1/Systems/system-1", member1["@odata.id"]) + + // Check second member + member2, ok := members[1].(map[string]interface{}) + require.True(t, ok, "Second member should be a map") + assert.Equal(t, "/redfish/v1/Systems/system-2", member2["@odata.id"]) + }, + }, + { + name: "empty collection", + setupMocks: func(mockFeature *mocks.MockDeviceManagementFeature, mockLogger *mocks.MockLogger) { + mockFeature.EXPECT(). + Get(gomock.Any(), maxSystemsList, 0, ""). + Return([]dto.Device{}, nil) + + mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() + }, + expectedStatus: http.StatusOK, + validateResponse: func(t *testing.T, body string) { + t.Helper() + + var collection map[string]interface{} + + err := json.Unmarshal([]byte(body), &collection) + require.NoError(t, err) + + members, ok := collection["Members"].([]interface{}) + require.True(t, ok, "Members should be a slice of interfaces") + assert.Equal(t, 0, len(members)) + + membersCount, ok := collection["Members@odata.count"].(float64) + require.True(t, ok, "Members@odata.count should be a float64") + assert.Equal(t, 0.0, membersCount) + }, + }, + { + name: "backend error", + setupMocks: func(mockFeature *mocks.MockDeviceManagementFeature, mockLogger *mocks.MockLogger) { + mockFeature.EXPECT(). + Get(gomock.Any(), maxSystemsList, 0, ""). + Return(nil, fmt.Errorf("backend connection failed")) + + mockLogger.EXPECT().Error(gomock.Any(), gomock.Any()).Times(1) + }, + expectedStatus: http.StatusInternalServerError, + validateResponse: func(t *testing.T, body string) { + t.Helper() + assert.Contains(t, body, "backend connection failed") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockFeature := mocks.NewMockDeviceManagementFeature(ctrl) + mockLogger := mocks.NewMockLogger(ctrl) + + tt.setupMocks(mockFeature, mockLogger) + + gin.SetMode(gin.TestMode) + router := gin.New() + systems := router.Group("/redfish/v1/Systems") + systems.GET("", getSystemsCollectionHandler(mockFeature, mockLogger)) + + w := httptest.NewRecorder() + req, _ := http.NewRequestWithContext( + context.Background(), + "GET", + "/redfish/v1/Systems", + http.NoBody, + ) + + router.ServeHTTP(w, req) + + assert.Equal(t, tt.expectedStatus, w.Code) + tt.validateResponse(t, w.Body.String()) + }) + } +} + +func TestGetSystemInstanceHandler(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + systemID string + setupMocks func(*mocks.MockDeviceManagementFeature, *mocks.MockLogger) + expectedStatus int + validateResponse func(t *testing.T, body string) + }{ + { + name: "successful system retrieval with power on", + systemID: testSystemGUID, + setupMocks: func(mockFeature *mocks.MockDeviceManagementFeature, mockLogger *mocks.MockLogger) { + powerState := dto.PowerState{ + PowerState: actionPowerUp, // 2 = On + } + mockFeature.EXPECT(). + GetPowerState(gomock.Any(), testSystemGUID). + Return(powerState, nil) + + mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() + }, + expectedStatus: http.StatusOK, + validateResponse: func(t *testing.T, body string) { + t.Helper() + + var system map[string]interface{} + + err := json.Unmarshal([]byte(body), &system) + require.NoError(t, err) + + assert.Equal(t, "#ComputerSystem.v1_0_0.ComputerSystem", system["@odata.type"]) + assert.Equal(t, "/redfish/v1/Systems/"+testSystemGUID, system["@odata.id"]) + assert.Equal(t, testSystemGUID, system["Id"]) + assert.Equal(t, "Computer System "+testSystemGUID, system["Name"]) + assert.Equal(t, powerStateOn, system["PowerState"]) + + // Check Actions + actions, ok := system["Actions"].(map[string]interface{}) + require.True(t, ok, "Actions should be a map") + resetAction, ok := actions["#ComputerSystem.Reset"].(map[string]interface{}) + require.True(t, ok, "Reset action should be a map") + assert.Equal(t, "/redfish/v1/Systems/"+testSystemGUID+"/Actions/ComputerSystem.Reset", resetAction["target"]) + + allowedValues, ok := resetAction["ResetType@Redfish.AllowableValues"].([]interface{}) + require.True(t, ok, "AllowableValues should be a slice of interfaces") + + expectedValues := []string{resetTypeOn, resetTypeForceOff, resetTypeForceRestart, resetTypePowerCycle} + assert.Equal(t, len(expectedValues), len(allowedValues)) + }, + }, + { + name: "system with power off state", + systemID: testSystemGUID, + setupMocks: func(mockFeature *mocks.MockDeviceManagementFeature, mockLogger *mocks.MockLogger) { + powerState := dto.PowerState{ + PowerState: cimPowerSoftOff, // 7 = Soft Off + } + mockFeature.EXPECT(). + GetPowerState(gomock.Any(), testSystemGUID). + Return(powerState, nil) + + mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() + }, + expectedStatus: http.StatusOK, + validateResponse: func(t *testing.T, body string) { + t.Helper() + + var system map[string]interface{} + + err := json.Unmarshal([]byte(body), &system) + require.NoError(t, err) + + assert.Equal(t, powerStateOff, system["PowerState"]) + }, + }, + { + name: "system with sleep state (treated as on)", + systemID: testSystemGUID, + setupMocks: func(mockFeature *mocks.MockDeviceManagementFeature, mockLogger *mocks.MockLogger) { + powerState := dto.PowerState{ + PowerState: cimPowerSleep, // 3 = Sleep + } + mockFeature.EXPECT(). + GetPowerState(gomock.Any(), testSystemGUID). + Return(powerState, nil) + + mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() + }, + expectedStatus: http.StatusOK, + validateResponse: func(t *testing.T, body string) { + t.Helper() + + var system map[string]interface{} + + err := json.Unmarshal([]byte(body), &system) + require.NoError(t, err) + + assert.Equal(t, powerStateOn, system["PowerState"]) // Sleep treated as On + }, + }, + { + name: "power state retrieval failure", + systemID: testSystemGUID, + setupMocks: func(mockFeature *mocks.MockDeviceManagementFeature, mockLogger *mocks.MockLogger) { + mockFeature.EXPECT(). + GetPowerState(gomock.Any(), testSystemGUID). + Return(dto.PowerState{}, fmt.Errorf("power state not available")) + + mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any(), gomock.Any()).Times(1) + }, + expectedStatus: http.StatusOK, + validateResponse: func(t *testing.T, body string) { + t.Helper() + + var system map[string]interface{} + + err := json.Unmarshal([]byte(body), &system) + require.NoError(t, err) + + assert.Equal(t, powerStateUnknown, system["PowerState"]) // Default to Unknown + }, + }, + { + name: "unknown power state value", + systemID: testSystemGUID, + setupMocks: func(mockFeature *mocks.MockDeviceManagementFeature, mockLogger *mocks.MockLogger) { + powerState := dto.PowerState{ + PowerState: 999, // Unknown value + } + mockFeature.EXPECT(). + GetPowerState(gomock.Any(), testSystemGUID). + Return(powerState, nil) + + mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() + }, + expectedStatus: http.StatusOK, + validateResponse: func(t *testing.T, body string) { + t.Helper() + + var system map[string]interface{} + + err := json.Unmarshal([]byte(body), &system) + require.NoError(t, err) + + assert.Equal(t, powerStateUnknown, system["PowerState"]) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockFeature := mocks.NewMockDeviceManagementFeature(ctrl) + mockLogger := mocks.NewMockLogger(ctrl) + + tt.setupMocks(mockFeature, mockLogger) + + gin.SetMode(gin.TestMode) + router := gin.New() + systems := router.Group("/redfish/v1/Systems") + systems.GET(":id", getSystemInstanceHandler(mockFeature, mockLogger)) + + w := httptest.NewRecorder() + req, _ := http.NewRequestWithContext( + context.Background(), + "GET", + "/redfish/v1/Systems/"+tt.systemID, + http.NoBody, + ) + + router.ServeHTTP(w, req) + + assert.Equal(t, tt.expectedStatus, w.Code) + tt.validateResponse(t, w.Body.String()) + }) + } +} + +func TestPostSystemResetHandler(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + systemID string + requestBody string + setupMocks func(*mocks.MockDeviceManagementFeature, *mocks.MockLogger) + expectedStatus int + validateResponse func(t *testing.T, body string) + }{ + { + name: "successful power on", + systemID: testSystemGUID, + requestBody: `{"ResetType": "On"}`, + setupMocks: func(mockFeature *mocks.MockDeviceManagementFeature, mockLogger *mocks.MockLogger) { + expectedResult := power.PowerActionResponse{ + ReturnValue: power.ReturnValue(0), + } + mockFeature.EXPECT(). + SendPowerAction(gomock.Any(), testSystemGUID, actionPowerUp). + Return(expectedResult, nil) + + mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() + }, + expectedStatus: http.StatusOK, + validateResponse: func(t *testing.T, body string) { + t.Helper() + assert.Contains(t, body, "ReturnValue") + }, + }, + { + name: "successful force off", + systemID: testSystemGUID, + requestBody: `{"ResetType": "ForceOff"}`, + setupMocks: func(mockFeature *mocks.MockDeviceManagementFeature, _ *mocks.MockLogger) { + expectedResult := power.PowerActionResponse{ + ReturnValue: power.ReturnValue(0), + } + mockFeature.EXPECT(). + SendPowerAction(gomock.Any(), testSystemGUID, actionPowerDown). + Return(expectedResult, nil) + }, + expectedStatus: http.StatusOK, + validateResponse: func(t *testing.T, body string) { + t.Helper() + assert.Contains(t, body, "ReturnValue") + }, + }, + { + name: "successful force restart", + systemID: testSystemGUID, + requestBody: `{"ResetType": "ForceRestart"}`, + setupMocks: func(mockFeature *mocks.MockDeviceManagementFeature, _ *mocks.MockLogger) { + expectedResult := power.PowerActionResponse{ + ReturnValue: power.ReturnValue(0), + } + mockFeature.EXPECT(). + SendPowerAction(gomock.Any(), testSystemGUID, actionReset). + Return(expectedResult, nil) + }, + expectedStatus: http.StatusOK, + validateResponse: func(t *testing.T, body string) { + t.Helper() + assert.Contains(t, body, "ReturnValue") + }, + }, + { + name: "successful power cycle", + systemID: testSystemGUID, + requestBody: `{"ResetType": "PowerCycle"}`, + setupMocks: func(mockFeature *mocks.MockDeviceManagementFeature, _ *mocks.MockLogger) { + expectedResult := power.PowerActionResponse{ + ReturnValue: power.ReturnValue(0), + } + mockFeature.EXPECT(). + SendPowerAction(gomock.Any(), testSystemGUID, actionPowerCycle). + Return(expectedResult, nil) + }, + expectedStatus: http.StatusOK, + validateResponse: func(t *testing.T, body string) { + t.Helper() + assert.Contains(t, body, "ReturnValue") + }, + }, + { + name: "invalid reset type", + systemID: testSystemGUID, + requestBody: `{"ResetType": "InvalidType"}`, + setupMocks: func(_ *mocks.MockDeviceManagementFeature, _ *mocks.MockLogger) { + // No mock calls expected for invalid reset type + }, + expectedStatus: http.StatusBadRequest, + validateResponse: func(t *testing.T, body string) { + t.Helper() + assert.Contains(t, body, "unsupported ResetType") + }, + }, + { + name: "malformed JSON", + systemID: testSystemGUID, + requestBody: `{"ResetType": }`, // Invalid JSON + setupMocks: func(_ *mocks.MockDeviceManagementFeature, _ *mocks.MockLogger) { + // No mock calls expected for malformed JSON + }, + expectedStatus: http.StatusBadRequest, + validateResponse: func(t *testing.T, body string) { + t.Helper() + assert.Contains(t, body, "error") + }, + }, + { + name: "missing reset type", + systemID: testSystemGUID, + requestBody: `{}`, // Missing ResetType + setupMocks: func(_ *mocks.MockDeviceManagementFeature, _ *mocks.MockLogger) { + // No mock calls expected - empty ResetType will be treated as unsupported + }, + expectedStatus: http.StatusBadRequest, + validateResponse: func(t *testing.T, body string) { + t.Helper() + assert.Contains(t, body, "unsupported ResetType") + }, + }, + { + name: "backend error", + systemID: testSystemGUID, + requestBody: `{"ResetType": "On"}`, + setupMocks: func(mockFeature *mocks.MockDeviceManagementFeature, mockLogger *mocks.MockLogger) { + mockFeature.EXPECT(). + SendPowerAction(gomock.Any(), testSystemGUID, actionPowerUp). + Return(power.PowerActionResponse{}, fmt.Errorf("system not found")) + + mockLogger.EXPECT().Error(gomock.Any(), gomock.Any()).Times(1) + }, + expectedStatus: http.StatusInternalServerError, + validateResponse: func(t *testing.T, body string) { + t.Helper() + assert.Contains(t, body, "system not found") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockFeature := mocks.NewMockDeviceManagementFeature(ctrl) + mockLogger := mocks.NewMockLogger(ctrl) + + tt.setupMocks(mockFeature, mockLogger) + + gin.SetMode(gin.TestMode) + router := gin.New() + systems := router.Group("/redfish/v1/Systems") + systems.POST(":id/Actions/ComputerSystem.Reset", postSystemResetHandler(mockFeature, mockLogger)) + + w := httptest.NewRecorder() + req, _ := http.NewRequestWithContext( + context.Background(), + "POST", + "/redfish/v1/Systems/"+tt.systemID+"/Actions/ComputerSystem.Reset", + strings.NewReader(tt.requestBody), + ) + req.Header.Set("Content-Type", "application/json") + + router.ServeHTTP(w, req) + + assert.Equal(t, tt.expectedStatus, w.Code) + tt.validateResponse(t, w.Body.String()) + }) + } +} + +func TestPowerStateMapping(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cimPowerState int + expectedRedfish string + }{ + { + name: "CIM Power On maps to Redfish On", + cimPowerState: actionPowerUp, // 2 + expectedRedfish: powerStateOn, + }, + { + name: "CIM Sleep maps to Redfish On", + cimPowerState: cimPowerSleep, // 3 + expectedRedfish: powerStateOn, + }, + { + name: "CIM Standby maps to Redfish On", + cimPowerState: cimPowerStandby, // 4 + expectedRedfish: powerStateOn, + }, + { + name: "CIM Soft Off maps to Redfish Off", + cimPowerState: cimPowerSoftOff, // 7 + expectedRedfish: powerStateOff, + }, + { + name: "CIM Hard Off maps to Redfish Off", + cimPowerState: cimPowerHardOff, // 8 + expectedRedfish: powerStateOff, + }, + { + name: "Unknown CIM state maps to Redfish Unknown", + cimPowerState: 999, + expectedRedfish: powerStateUnknown, + }, + { + name: "Zero value maps to Redfish Unknown", + cimPowerState: 0, + expectedRedfish: powerStateUnknown, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockFeature := mocks.NewMockDeviceManagementFeature(ctrl) + mockLogger := mocks.NewMockLogger(ctrl) + + powerState := dto.PowerState{ + PowerState: tt.cimPowerState, + } + mockFeature.EXPECT(). + GetPowerState(gomock.Any(), testSystemGUID). + Return(powerState, nil) + + mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() + + gin.SetMode(gin.TestMode) + router := gin.New() + systems := router.Group("/redfish/v1/Systems") + systems.GET(":id", getSystemInstanceHandler(mockFeature, mockLogger)) + + w := httptest.NewRecorder() + req, _ := http.NewRequestWithContext( + context.Background(), + "GET", + "/redfish/v1/Systems/"+testSystemGUID, + http.NoBody, + ) + + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var system map[string]interface{} + + err := json.Unmarshal(w.Body.Bytes(), &system) + require.NoError(t, err) + + assert.Equal(t, tt.expectedRedfish, system["PowerState"]) + }) + } +} + +func TestResetTypeMapping(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + redfishResetType string + expectedCIMAction int + }{ + { + name: "On maps to PowerUp action", + redfishResetType: resetTypeOn, + expectedCIMAction: actionPowerUp, // 2 + }, + { + name: "ForceOff maps to PowerDown action", + redfishResetType: resetTypeForceOff, + expectedCIMAction: actionPowerDown, // 8 + }, + { + name: "ForceRestart maps to Reset action", + redfishResetType: resetTypeForceRestart, + expectedCIMAction: actionReset, // 10 + }, + { + name: "PowerCycle maps to PowerCycle action", + redfishResetType: resetTypePowerCycle, + expectedCIMAction: actionPowerCycle, // 5 + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockFeature := mocks.NewMockDeviceManagementFeature(ctrl) + mockLogger := mocks.NewMockLogger(ctrl) + + expectedResult := power.PowerActionResponse{ + ReturnValue: power.ReturnValue(0), + } + mockFeature.EXPECT(). + SendPowerAction(gomock.Any(), testSystemGUID, tt.expectedCIMAction). + Return(expectedResult, nil) + + gin.SetMode(gin.TestMode) + router := gin.New() + systems := router.Group("/redfish/v1/Systems") + systems.POST(":id/Actions/ComputerSystem.Reset", postSystemResetHandler(mockFeature, mockLogger)) + + requestBody := fmt.Sprintf(`{"ResetType": %q}`, tt.redfishResetType) + + w := httptest.NewRecorder() + req, _ := http.NewRequestWithContext( + context.Background(), + "POST", + "/redfish/v1/Systems/"+testSystemGUID+"/Actions/ComputerSystem.Reset", + strings.NewReader(requestBody), + ) + req.Header.Set("Content-Type", "application/json") + + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + }) + } +} + +func TestSystemsIntegrationWithFirmware(t *testing.T) { + t.Parallel() + + t.Run("firmware routes integrated with systems", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockFeature := mocks.NewMockDeviceManagementFeature(ctrl) + mockLogger := mocks.NewMockLogger(ctrl) + + // Mock for firmware inventory access + mockFeature.EXPECT(). + GetVersion(gomock.Any(), testSystemGUID). + Return(dto.Version{}, dtov2.Version{AMT: "15.0.25"}, nil) + + // Mock for BIOS hardware info (required by firmware routes) + mockFeature.EXPECT(). + GetHardwareInfo(gomock.Any(), testSystemGUID). + Return(map[string]interface{}{ + "CIM_BIOSElement": map[string]interface{}{ + "Version": "BIOS.15.25.10", + "Manufacturer": "Intel Corp.", + }, + }, nil) + + mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() + mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes() + + gin.SetMode(gin.TestMode) + router := gin.New() + + // Setup complete systems routes including firmware + redfishGroup := router.Group("/redfish/v1") + NewSystemsRoutes(redfishGroup, mockFeature, mockLogger) + + // Test that firmware inventory endpoint is accessible via systems routes + w := httptest.NewRecorder() + req, _ := http.NewRequestWithContext( + context.Background(), + "GET", + "/redfish/v1/Systems/"+testSystemGUID+"/FirmwareInventory", + http.NoBody, + ) + + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var collection map[string]interface{} + + err := json.Unmarshal(w.Body.Bytes(), &collection) + require.NoError(t, err) + + assert.Equal(t, "#SoftwareInventoryCollection.SoftwareInventoryCollection", collection["@odata.type"]) + assert.Equal(t, "/redfish/v1/Systems/"+testSystemGUID+"/FirmwareInventory", collection["@odata.id"]) + }) +} + +func TestConstants(t *testing.T) { + t.Parallel() + + t.Run("power state constants", func(t *testing.T) { + t.Parallel() + + assert.Equal(t, "Unknown", powerStateUnknown) + assert.Equal(t, "On", powerStateOn) + assert.Equal(t, "Off", powerStateOff) + }) + + t.Run("reset type constants", func(t *testing.T) { + t.Parallel() + + assert.Equal(t, "On", resetTypeOn) + assert.Equal(t, "ForceOff", resetTypeForceOff) + assert.Equal(t, "ForceRestart", resetTypeForceRestart) + assert.Equal(t, "PowerCycle", resetTypePowerCycle) + }) + + t.Run("action constants", func(t *testing.T) { + t.Parallel() + + assert.Equal(t, 2, actionPowerUp) + assert.Equal(t, 5, actionPowerCycle) + assert.Equal(t, 8, actionPowerDown) + assert.Equal(t, 10, actionReset) + }) + + t.Run("CIM power state constants", func(t *testing.T) { + t.Parallel() + + assert.Equal(t, 2, cimPowerOn) + assert.Equal(t, 3, cimPowerSleep) + assert.Equal(t, 4, cimPowerStandby) + assert.Equal(t, 7, cimPowerSoftOff) + assert.Equal(t, 8, cimPowerHardOff) + }) + + t.Run("limits constants", func(t *testing.T) { + t.Parallel() + + assert.Equal(t, 100, maxSystemsList) + }) +} + +func TestSystemResponseStructure(t *testing.T) { + t.Parallel() + + t.Run("system response contains all required fields", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockFeature := mocks.NewMockDeviceManagementFeature(ctrl) + mockLogger := mocks.NewMockLogger(ctrl) + + powerState := dto.PowerState{PowerState: actionPowerUp} + mockFeature.EXPECT(). + GetPowerState(gomock.Any(), testSystemGUID). + Return(powerState, nil) + + mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() + + gin.SetMode(gin.TestMode) + router := gin.New() + systems := router.Group("/redfish/v1/Systems") + systems.GET(":id", getSystemInstanceHandler(mockFeature, mockLogger)) + + w := httptest.NewRecorder() + req, _ := http.NewRequestWithContext( + context.Background(), + "GET", + "/redfish/v1/Systems/"+testSystemGUID, + http.NoBody, + ) + + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var system map[string]interface{} + + err := json.Unmarshal(w.Body.Bytes(), &system) + require.NoError(t, err) + + // Check required Redfish properties + requiredFields := []string{ + "@odata.type", + "@odata.id", + "Id", + "Name", + "PowerState", + "Actions", + } + + for _, field := range requiredFields { + assert.Contains(t, system, field, "System response missing required field: %s", field) + } + + // Check Actions structure + actions, ok := system["Actions"].(map[string]interface{}) + require.True(t, ok, "Actions should be a map") + assert.Contains(t, actions, "#ComputerSystem.Reset") + + resetAction, ok := actions["#ComputerSystem.Reset"].(map[string]interface{}) + require.True(t, ok, "Reset action should be a map") + assert.Contains(t, resetAction, "target") + assert.Contains(t, resetAction, "ResetType@Redfish.AllowableValues") + + allowedValues, ok := resetAction["ResetType@Redfish.AllowableValues"].([]interface{}) + require.True(t, ok, "AllowableValues should be a slice of interfaces") + assert.Equal(t, 4, len(allowedValues)) // On, ForceOff, ForceRestart, PowerCycle + }) +} + +func TestErrorHandling(t *testing.T) { + t.Parallel() + + t.Run("handles context cancellation", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockFeature := mocks.NewMockDeviceManagementFeature(ctrl) + mockLogger := mocks.NewMockLogger(ctrl) + + mockFeature.EXPECT(). + Get(gomock.Any(), maxSystemsList, 0, ""). + Return(nil, context.Canceled) + + mockLogger.EXPECT().Error(gomock.Any(), gomock.Any()).Times(1) + + gin.SetMode(gin.TestMode) + router := gin.New() + systems := router.Group("/redfish/v1/Systems") + systems.GET("", getSystemsCollectionHandler(mockFeature, mockLogger)) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + w := httptest.NewRecorder() + req, _ := http.NewRequestWithContext(ctx, "GET", "/redfish/v1/Systems", http.NoBody) + + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + }) + + t.Run("handles nil device feature gracefully", func(t *testing.T) { + t.Parallel() + + gin.SetMode(gin.TestMode) + router := gin.New() + systems := router.Group("/redfish/v1/Systems") + + // This should not panic, but will result in a runtime error when called + require.NotPanics(t, func() { + systems.GET("", getSystemsCollectionHandler(nil, nil)) + }) + }) +}