From 95579634f2fd6c42826045cbac6d8066b0897ec7 Mon Sep 17 00:00:00 2001 From: ShradhaGupta31 Date: Wed, 19 Aug 2026 19:23:19 +0530 Subject: [PATCH 1/3] refactor: add GET /api/v1/devices/export endpoint - flat->nested mapping done in one place so SQL and Mongo responses match - me/os/platform/bmc render as null when a subsystem has no data - X-Total-Count header and server-side audit log per export attempt - fields with no writer yet are marked export-map and render null Add the Fuego/OpenAPI declaration and a Postman request asserting the envelope shape, X-Total-Count, and absence of credential fields. Signed-off-by: ShradhaGupta31 --- .../console_mps_apis.postman_collection.json | 65 +++++ internal/controller/httpapi/v1/devices.go | 1 + internal/controller/httpapi/v1/export.go | 230 ++++++++++++++++++ internal/controller/httpapi/v1/export_test.go | 159 ++++++++++++ internal/controller/openapi/devices.go | 85 +++++++ internal/entity/dto/v1/export.go | 129 ++++++++++ 6 files changed, 669 insertions(+) create mode 100644 internal/controller/httpapi/v1/export.go create mode 100644 internal/controller/httpapi/v1/export_test.go create mode 100644 internal/entity/dto/v1/export.go diff --git a/integration-test/collections/console_mps_apis.postman_collection.json b/integration-test/collections/console_mps_apis.postman_collection.json index 5943679a0..219f1afe9 100644 --- a/integration-test/collections/console_mps_apis.postman_collection.json +++ b/integration-test/collections/console_mps_apis.postman_collection.json @@ -1602,6 +1602,71 @@ }, "response": [] }, + { + "name": "Export Devices", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {\r", + " pm.response.to.have.status(200);\r", + "});\r", + "pm.test(\"Has metadata, summary and data\",function(){\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData).to.have.property('metadata');\r", + " pm.expect(jsonData.metadata).to.have.property('exportedAt');\r", + " pm.expect(jsonData.metadata).to.have.property('swVersion');\r", + " pm.expect(jsonData).to.have.property('summary');\r", + " pm.expect(jsonData.summary).to.have.property('totalCount');\r", + " pm.expect(jsonData).to.have.property('data');\r", + " pm.expect(jsonData.data).to.be.an('array');\r", + "});\r", + "pm.test(\"Returns X-Total-Count header\",function(){\r", + " pm.response.to.have.header('X-Total-Count');\r", + "});\r", + "pm.test(\"Export never leaks credential fields\",function(){\r", + " var raw = pm.response.text();\r", + " pm.expect(raw).to.not.include('mpspassword');\r", + " pm.expect(raw).to.not.include('mebxpassword');\r", + " pm.expect(raw).to.not.include('\\\"password\\\"');\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{protocol}}://{{host}}/api/v1/devices/export", + "protocol": "{{protocol}}", + "host": [ + "{{host}}" + ], + "path": [ + "api", + "v1", + "devices", + "export" + ] + } + }, + "response": [] + }, { "name": "DISTINCT tags when no devices", "event": [ diff --git a/internal/controller/httpapi/v1/devices.go b/internal/controller/httpapi/v1/devices.go index 57485ec84..698d3098e 100644 --- a/internal/controller/httpapi/v1/devices.go +++ b/internal/controller/httpapi/v1/devices.go @@ -33,6 +33,7 @@ func NewDeviceRoutes(handler *gin.RouterGroup, t devices.Feature, l logger.Inter h := handler.Group("/devices") { h.GET("", r.get) + h.GET("export", r.export) h.GET("stats", r.getStats) h.GET("redirectstatus/:guid", r.redirectStatus) h.GET("cert/:guid", r.getDeviceCertificate) diff --git a/internal/controller/httpapi/v1/export.go b/internal/controller/httpapi/v1/export.go new file mode 100644 index 000000000..753f11575 --- /dev/null +++ b/internal/controller/httpapi/v1/export.go @@ -0,0 +1,230 @@ +package v1 + +import ( + "context" + "fmt" + "net/http" + "os" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + + "github.com/device-management-toolkit/console/config" + "github.com/device-management-toolkit/console/internal/entity/dto/v1" +) + +const ( + // maxExportRecords is the hard cap on records returned by a single export. + maxExportRecords = 10000 + + // exportTimeout bounds how long an export may take before it is abandoned. + exportTimeout = 60 * time.Second + + // exportCountHeader tells the client how many records are in the response. + exportCountHeader = "X-Total-Count" + + outcomeSuccess = "success" + outcomeError = "error" + tenantClaimKey = "tenantId" + subjectClaimKey = "sub" +) + +// export handles GET /api/v1/devices/export. It returns a tenant-scoped, +// snapshot of every device details stored in deatabase. +func (dr *deviceRoutes) export(c *gin.Context) { + start := time.Now() + tenantID, userID := exportIdentity(c) + + ctx, cancel := context.WithTimeout(c.Request.Context(), exportTimeout) + defer cancel() + + devicesList, err := dr.t.Get(ctx, maxExportRecords, 0, tenantID) + if err != nil { + dr.logExportAudit(c, userID, tenantID, 0, time.Since(start), outcomeError+": "+err.Error()) + dr.l.Error(err, "http - devices - v1 - export") + // Never emit a partial file: discard everything and signal unavailability. + c.JSON(http.StatusServiceUnavailable, gin.H{errorKey: "export unavailable"}) + + return + } + + records := make([]dto.DeviceExportRecord, 0, len(devicesList)) + for i := range devicesList { + records = append(records, flatToNested(&devicesList[i])) + } + + resp := dto.DeviceExport{ + Metadata: dto.ExportMetadata{ + ExportedAt: time.Now().UTC(), + SwVersion: swVersion(), + }, + Summary: dto.ExportSummary{TotalCount: len(records)}, + Data: records, + } + + c.Header(exportCountHeader, strconv.Itoa(len(records))) + dr.logExportAudit(c, userID, tenantID, len(records), time.Since(start), outcomeSuccess) + c.JSON(http.StatusOK, resp) +} + +// swVersion formats the running software version for the export metadata. +func swVersion() string { + version := "unknown" + if config.ConsoleConfig != nil { + version = config.ConsoleConfig.Version + } + + return "console " + version +} + +// exportIdentity reads the tenant and subject from the already-verified JWT. The +// export is tenant-scoped: only devices for the caller's tenant are returned +func exportIdentity(c *gin.Context) (tenantID, userID string) { + token := resolveToken(c) + if token == "" { + return "", "" + } + + claims := jwt.MapClaims{} + if _, _, err := jwt.NewParser().ParseUnverified(token, claims); err != nil { + return "", "" + } + + if v, ok := claims[tenantClaimKey].(string); ok { + tenantID = v + } + + if v, ok := claims[subjectClaimKey].(string); ok { + userID = v + } + + return tenantID, userID +} + +// logExportAudit writes a server-side audit record for every export attempt, +func (dr *deviceRoutes) logExportAudit(c *gin.Context, userID, tenantID string, count int, dur time.Duration, outcome string) { + serverHostname, _ := os.Hostname() + + msg := fmt.Sprintf("device export | outcome=%q userId=%q tenantId=%q sourceIp=%q serverHostname=%q deviceCount=%d durationMs=%d", + outcome, userID, tenantID, c.ClientIP(), serverHostname, count, dur.Milliseconds()) + + dr.l.Info(msg) +} + +// flatToNested maps a flat stored device into the nested export shape. +// This is the single place where flat->nested shaping happens, so SQL and +// MongoDB backends produce an identical response. +func flatToNested(d *dto.Device) dto.DeviceExportRecord { + tags := d.Tags + if tags == nil { + tags = []string{} + } + + record := dto.DeviceExportRecord{ + GUID: d.GUID, + Hostname: d.Hostname, + FriendlyName: d.FriendlyName, + Tags: tags, + TenantID: d.TenantID, + DeviceInfo: dto.DeviceExportInfo{ + BMC: nil, // export-gap: high-level BMC data is not collected yet. + }, + } + + info := d.DeviceInfo + if info == nil { + return record + } + + record.FirstDiscovered = info.FirstDiscovered + record.LastSynced = info.LastSynced + // export-gap: lastUpdated has no writer yet; add once the device DTO gains a lastUpdated field. + + record.DeviceInfo.ME = buildExportME(d, info) + record.DeviceInfo.OS = buildExportOS(info) + record.DeviceInfo.Platform = buildExportPlatform(info) + + return record +} + +// buildExportME returns the ME subsystem, or nil for devices without a +// Management Engine +func buildExportME(d *dto.Device, info *dto.DeviceInfo) *dto.ExportME { + hasME := info.FWVersion != "" || info.FWSku != "" || info.CurrentMode != "" || + (info.AMTEnabledInBIOS != nil && *info.AMTEnabledInBIOS) + if !hasME { + return nil + } + + me := &dto.ExportME{ + DNSSuffix: d.DNSSuffix, + CurrentMode: info.CurrentMode, + MEBXEnabledInBIOS: info.AMTEnabledInBIOS, + FWVersion: info.FWVersion, + FWBuild: info.FWBuild, + FWSku: info.FWSku, + Features: info.Features, + TLSMode: info.TLSMode, + DHCPEnabled: info.DHCPEnabled, + CertHashes: info.CertHashes, + UPID: info.UPID, + } + + if info.IPAddress != "" { + // export-gap: only the wired ME IP is stored today; wireless has no source field yet, so it stays null. + me.Network = &dto.ExportMENetwork{ + Wired: &dto.ExportMEInterface{ + IPAddress: info.IPAddress, + DHCPEnabled: info.DHCPEnabled, + }, + } + } + + return me +} + +// buildExportOS returns the OS subsystem, or nil when no OS data was reported. +func buildExportOS(info *dto.DeviceInfo) *dto.ExportOS { + hasOS := info.OSName != "" || info.OSVersion != "" || info.OSDistro != "" || + info.OSIPAddress != "" || info.LMSInstalled != nil || info.LMSVersion != "" || + info.MEInterfaceVersion != "" || info.MonitorConnected != nil || info.IEEE8021XEnabled != nil + if !hasOS { + return nil + } + + osInfo := &dto.ExportOS{ + Name: info.OSName, + Version: info.OSVersion, + Distro: info.OSDistro, + LMSInstalled: info.LMSInstalled, + LMSVersion: info.LMSVersion, + MEInterfaceVersion: info.MEInterfaceVersion, + MonitorConnected: info.MonitorConnected, + IEEE8021XEnabled: info.IEEE8021XEnabled, + } + + if info.OSIPAddress != "" { + // export-gap: only the wired OS IP is stored today; wireless has no source field yet, so it stays null. + osInfo.Network = &dto.ExportOSNetwork{ + Wired: []dto.ExportOSInterface{{IPAddress: info.OSIPAddress}}, + } + } + + return osInfo +} + +// buildExportPlatform returns the platform subsystem, or nil when no platform +// data was reported. +func buildExportPlatform(info *dto.DeviceInfo) *dto.ExportPlatform { + if info.CPUModel == "" && info.EthernetAdapterCount == nil { + return nil + } + + // export-gap: adapters has no source fields yet, so it stays null. + return &dto.ExportPlatform{ + CPU: info.CPUModel, + EthernetAdapterCount: info.EthernetAdapterCount, + } +} diff --git a/internal/controller/httpapi/v1/export_test.go b/internal/controller/httpapi/v1/export_test.go new file mode 100644 index 000000000..f75e21d55 --- /dev/null +++ b/internal/controller/httpapi/v1/export_test.go @@ -0,0 +1,159 @@ +package v1 + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/device-management-toolkit/console/internal/entity/dto/v1" + "github.com/device-management-toolkit/console/internal/mocks" + "github.com/device-management-toolkit/console/pkg/logger" +) + +func exportTestHarness(t *testing.T) (*mocks.MockDeviceManagementFeature, *gin.Engine) { + t.Helper() + setupTestConfig() + + mockCtl := gomock.NewController(t) + t.Cleanup(mockCtl.Finish) + + log := logger.New("error") + device := mocks.NewMockDeviceManagementFeature(mockCtl) + + engine := gin.New() + handler := engine.Group("/api/v1") + + NewDeviceRoutes(handler, device, log) + + return device, engine +} + +func TestExportDevices_Success(t *testing.T) { + t.Parallel() + + device, engine := exportTestHarness(t) + + firstDiscovered := time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC) + lastSynced := time.Date(2026, 7, 20, 14, 22, 15, 0, time.UTC) + amtEnabled := true + dhcp := true + adapters := 2 + + meDevice := dto.Device{ + GUID: "143e4567-e89b-12d3-a456-426614174000", + Hostname: "lab-pc-01", + FriendlyName: "Lab PC Alpha", + Tags: []string{"campus-lab"}, + TenantID: "tenant1", + DNSSuffix: "corp.example.com", + MPSPassword: "should-not-appear", + MEBXPassword: "should-not-appear", + Password: "should-not-appear", + DeviceInfo: &dto.DeviceInfo{ + FWVersion: "16.1.32", + FWBuild: "3400", + FWSku: "16392", + CurrentMode: "Admin", + Features: "AMT Pro Corporate", + AMTEnabledInBIOS: &amtEnabled, + DHCPEnabled: &dhcp, + IPAddress: "10.0.0.12", + FirstDiscovered: &firstDiscovered, + LastSynced: &lastSynced, + OSName: "linux", + OSIPAddress: "10.49.76.163", + CPUModel: "Intel(R) Core(TM) Ultra 7 165H", + EthernetAdapterCount: &adapters, + }, + } + + nonMEDevice := dto.Device{ + GUID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + Hostname: "standard-pc-05", + FriendlyName: "Standard Workstation", + Tags: nil, + TenantID: "tenant1", + DeviceInfo: &dto.DeviceInfo{ + OSName: "linux", + OSIPAddress: "10.49.76.170", + }, + } + + device.EXPECT(). + Get(gomock.Any(), maxExportRecords, 0, ""). + Return([]dto.Device{meDevice, nonMEDevice}, nil) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/devices/export", http.NoBody) + engine.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "2", w.Header().Get(exportCountHeader)) + + var resp dto.DeviceExport + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + + require.Equal(t, 2, resp.Summary.TotalCount) + require.Len(t, resp.Data, 2) + require.NotEmpty(t, resp.Metadata.SwVersion) + require.False(t, resp.Metadata.ExportedAt.IsZero()) + + // ME device is fully shaped into the nested model. + me := resp.Data[0] + require.Equal(t, "143e4567-e89b-12d3-a456-426614174000", me.GUID) + require.Equal(t, []string{"campus-lab"}, me.Tags) + require.NotNil(t, me.FirstDiscovered) + require.NotNil(t, me.LastSynced) + require.Nil(t, me.LastUpdated) + require.NotNil(t, me.DeviceInfo.ME) + require.Equal(t, "corp.example.com", me.DeviceInfo.ME.DNSSuffix) + require.Equal(t, "16.1.32", me.DeviceInfo.ME.FWVersion) + require.NotNil(t, me.DeviceInfo.ME.MEBXEnabledInBIOS) + require.NotNil(t, me.DeviceInfo.ME.Network) + require.Equal(t, "10.0.0.12", me.DeviceInfo.ME.Network.Wired.IPAddress) + require.NotNil(t, me.DeviceInfo.OS) + require.Equal(t, "linux", me.DeviceInfo.OS.Name) + require.Equal(t, "10.49.76.163", me.DeviceInfo.OS.Network.Wired[0].IPAddress) + require.NotNil(t, me.DeviceInfo.Platform) + require.Equal(t, "Intel(R) Core(TM) Ultra 7 165H", me.DeviceInfo.Platform.CPU) + require.Nil(t, me.DeviceInfo.BMC) + + // Non-ME device has a null me subsystem. + nonME := resp.Data[1] + require.Nil(t, nonME.DeviceInfo.ME) + require.NotNil(t, nonME.DeviceInfo.OS) + require.Equal(t, []string{}, nonME.Tags) + + // No credential material may ever leak into the export. + body := w.Body.String() + require.NotContains(t, body, "should-not-appear") + require.NotContains(t, body, "mpspassword") + require.NotContains(t, body, "mebxpassword") +} + +func TestExportDevices_DatabaseError(t *testing.T) { + t.Parallel() + + device, engine := exportTestHarness(t) + + device.EXPECT(). + Get(gomock.Any(), maxExportRecords, 0, ""). + Return(nil, assertErr{}) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/devices/export", http.NoBody) + engine.ServeHTTP(w, req) + + require.Equal(t, http.StatusServiceUnavailable, w.Code) + require.Empty(t, w.Header().Get(exportCountHeader)) +} + +type assertErr struct{} + +func (assertErr) Error() string { return "db failure" } diff --git a/internal/controller/openapi/devices.go b/internal/controller/openapi/devices.go index f10d5f2b9..83b84a2a2 100644 --- a/internal/controller/openapi/devices.go +++ b/internal/controller/openapi/devices.go @@ -70,6 +70,17 @@ func (f *FuegoAdapter) registerDeviceQueryRoutes() { protectedRouteOptions(), ) + fuego.Get(f.server, "/api/v1/devices/export", f.exportDevices, + fuego.OptionTags("Devices"), + fuego.OptionSummary("Export Devices"), + fuego.OptionDescription("Export a tenant-scoped snapshot of all devices as JSON. "+ + "The response follows the nested schema (metadata, summary, data) and "+ + "excludes credential fields. Results are capped and the total record count is "+ + "returned in the X-Total-Count header."), + fuego.OptionAddResponse(http.StatusServiceUnavailable, "Export generation failed or timed out", fuego.Response{Type: ErrorResponse{}}), + protectedRouteOptions(), + ) + fuego.Get(f.server, "/api/v1/devices/redirectstatus/{guid}", f.getRedirectStatus, fuego.OptionTags("Devices"), fuego.OptionSummary("Get Redirect Status"), @@ -208,6 +219,80 @@ func (f *FuegoAdapter) getDeviceStats(_ fuego.ContextNoBody) (dto.DeviceStatResp }, nil } +func exampleDeviceExportRecord() dto.DeviceExportRecord { + firstDiscovered := time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC) + lastSynced := time.Date(2026, 7, 20, 14, 22, 15, 0, time.UTC) + mebxEnabled := true + dhcpEnabled := true + lmsInstalled := true + monitorConnected := true + ieee8021xEnabled := false + ethernetAdapterCount := 2 + + return dto.DeviceExportRecord{ + GUID: exampleDeviceGUID, + Hostname: exampleDeviceHost, + FriendlyName: "Lab PC Alpha", + Tags: []string{"campus-lab", "shared-device"}, + TenantID: defaultTenantID, + FirstDiscovered: &firstDiscovered, + LastSynced: &lastSynced, + DeviceInfo: dto.DeviceExportInfo{ + ME: &dto.ExportME{ + DNSSuffix: "corp.example.com", + CurrentMode: "Admin", + MEBXEnabledInBIOS: &mebxEnabled, + FWVersion: "16.1.32", + FWBuild: "3400", + FWSku: "16392", + Features: "AMT Pro Corporate", + TLSMode: "TLS 1.2", + DHCPEnabled: &dhcpEnabled, + CertHashes: []string{"a1b2c3xxx", "d4e5f6xxx"}, + UPID: map[string]json.RawMessage{ + "csmeId": json.RawMessage(`"4A45A39C5ED9462082510000"`), + "oemId": json.RawMessage(`""`), + "oemPlatformIdType": json.RawMessage(`"Not Set (0)"`), + }, + Network: &dto.ExportMENetwork{ + Wired: &dto.ExportMEInterface{IPAddress: "10.0.0.12", DHCPEnabled: &dhcpEnabled}, + }, + }, + OS: &dto.ExportOS{ + Name: "linux", + Version: "6.8.0-51-generic", + Distro: "Ubuntu 24.04 LTS", + LMSInstalled: &lmsInstalled, + LMSVersion: "2410.5.0.0", + MEInterfaceVersion: "16.1.25.2124", + MonitorConnected: &monitorConnected, + IEEE8021XEnabled: &ieee8021xEnabled, + Network: &dto.ExportOSNetwork{ + Wired: []dto.ExportOSInterface{{IPAddress: "10.49.76.163"}}, + }, + }, + Platform: &dto.ExportPlatform{ + CPU: "Intel(R) Core(TM) Ultra 7 165H", + EthernetAdapterCount: ðernetAdapterCount, + }, + BMC: nil, + }, + } +} + +func (f *FuegoAdapter) exportDevices(_ fuego.ContextNoBody) (dto.DeviceExport, error) { + records := []dto.DeviceExportRecord{exampleDeviceExportRecord()} + + return dto.DeviceExport{ + Metadata: dto.ExportMetadata{ + ExportedAt: time.Now().UTC(), + SwVersion: "console v1.38.1", + }, + Summary: dto.ExportSummary{TotalCount: len(records)}, + Data: records, + }, nil +} + type DeviceRedirectStatusResponse struct { IsSOLConnected bool `json:"isSOLConnected"` IsIDERConnected bool `json:"isIDERConnected"` diff --git a/internal/entity/dto/v1/export.go b/internal/entity/dto/v1/export.go new file mode 100644 index 000000000..95bec59df --- /dev/null +++ b/internal/entity/dto/v1/export.go @@ -0,0 +1,129 @@ +package dto + +import ( + "encoding/json" + "time" +) + +// DeviceExport is the top-level response of GET /api/v1/devices/export. +type DeviceExport struct { + Metadata ExportMetadata `json:"metadata"` + Summary ExportSummary `json:"summary"` + Data []DeviceExportRecord `json:"data"` +} + +// ExportMetadata carries auditing/version-traceability info for the export. +type ExportMetadata struct { + ExportedAt time.Time `json:"exportedAt"` + SwVersion string `json:"swVersion"` +} + +// ExportSummary holds the total count of exported devices. +type ExportSummary struct { + TotalCount int `json:"totalCount"` +} + +// DeviceExportRecord is the per-device allowlisted export shape. Only the +// fields listed here can be exported to users; credential fields (password, +// mpsPassword, mebxPassword, certHash) are intentionally excluded. +type DeviceExportRecord struct { + GUID string `json:"guid"` + Hostname string `json:"hostname"` + FriendlyName string `json:"friendlyName"` + Tags []string `json:"tags"` + TenantID string `json:"tenantId"` + FirstDiscovered *time.Time `json:"firstDiscovered"` + LastSynced *time.Time `json:"lastSynced"` + LastUpdated *time.Time `json:"lastUpdated"` // export-gap: no writer yet; always null until a last-update timestamp is stamped. + DeviceInfo DeviceExportInfo `json:"deviceInfo"` +} + +// DeviceExportInfo groups device details by OS/pheripherals categorie. A nil subsystem is +// rendered as JSON null (e.g. me/bmc for non-ME devices). +type DeviceExportInfo struct { + ME *ExportME `json:"me"` + OS *ExportOS `json:"os"` + Platform *ExportPlatform `json:"platform"` + BMC *ExportBMC `json:"bmc"` // export-gap: BMC data is not collected yet; always null. +} + +// ExportME holds Management Engine (AMT/ISM) details. A device without a +// when `me` is present its fields are always shown, null/empty where unknown. +type ExportME struct { + DNSSuffix string `json:"dnsSuffix"` + CurrentMode string `json:"currentMode"` + MEBXEnabledInBIOS *bool `json:"mebxEnabledInBIOS"` + FWVersion string `json:"fwVersion"` + FWBuild string `json:"fwBuild"` + FWSku string `json:"fwSku"` + Features string `json:"features"` + TLSMode string `json:"tlsMode"` + DHCPEnabled *bool `json:"dhcpEnabled"` + CertHashes []string `json:"certHashes"` + UPID map[string]json.RawMessage `json:"upid"` + Network *ExportMENetwork `json:"network"` +} + +// ExportMENetwork groups ME wired/wireless network settings. +type ExportMENetwork struct { + Wired *ExportMEInterface `json:"wired"` + Wireless *ExportMEInterface `json:"wireless"` // export-gap: no wireless-ME source field yet; always null. +} + +// ExportMEInterface is a single ME network interface. +type ExportMEInterface struct { + IPAddress string `json:"ipAddress"` + DHCPEnabled *bool `json:"dhcpEnabled"` + DHCPMode string `json:"dhcpMode"` // export-gap: no source field yet. + LinkStatus string `json:"linkStatus"` // export-gap: no source field yet. + MACAddress string `json:"macAddress"` // export-gap: no source field yet. +} + +// ExportOS holds operating-system-reported details. +type ExportOS struct { + DNSSuffix string `json:"dnsSuffix"` // export-gap: no OS-reported DNS suffix source field yet. + Name string `json:"name"` + Version string `json:"version"` + Distro string `json:"distro"` + LMSInstalled *bool `json:"lmsInstalled"` + LMSVersion string `json:"lmsVersion"` + MEInterfaceVersion string `json:"meInterfaceVersion"` + MonitorConnected *bool `json:"monitorConnected"` + IEEE8021XEnabled *bool `json:"ieee8021xEnabled"` + Network *ExportOSNetwork `json:"network"` +} + +// ExportOSNetwork groups OS wired/wireless network interfaces. +type ExportOSNetwork struct { + Wired []ExportOSInterface `json:"wired"` + Wireless *ExportOSInterface `json:"wireless"` // export-gap: no wireless-OS source field yet; always null. +} + +// ExportOSInterface is a single OS network interface. +type ExportOSInterface struct { + Name string `json:"name"` // export-gap: no source field yet. + IPAddress string `json:"ipAddress"` + DHCPEnabled *bool `json:"dhcpEnabled"` // export-gap: no source field yet. + LinkStatus string `json:"linkStatus"` // export-gap: no source field yet. + MACAddress string `json:"macAddress"` // export-gap: no source field yet. +} + +// ExportPlatform holds platform-level details (CPU, adapters). +type ExportPlatform struct { + CPU string `json:"cpu"` + EthernetAdapterCount *int `json:"ethernetAdapterCount"` + Adapters *ExportPlatformAdapters `json:"adapters"` // export-gap: no adapter-name source fields yet; always null. +} + +// ExportPlatformAdapters summarizes adapter names. +type ExportPlatformAdapters struct { + Wired string `json:"wired"` + Wireless string `json:"wireless"` +} + +// ExportBMC holds a high-level baseboard management controller summary. +type ExportBMC struct { + Vendor string `json:"vendor"` + Model string `json:"model"` + FirmwareVersion string `json:"firmwareVersion"` +} From 7ce282d9637657106dffe41487c72f8b02f91ff9 Mon Sep 17 00:00:00 2001 From: ShradhaGupta31 Date: Mon, 7 Sep 2026 21:48:23 +0530 Subject: [PATCH 2/3] refactor: address review comments - addressed review comments Signed-off-by: ShradhaGupta31 --- .../console_mps_apis.postman_collection.json | 8 +--- internal/controller/httpapi/v1/devices.go | 2 +- internal/controller/httpapi/v1/export.go | 44 +++---------------- internal/controller/httpapi/v1/export_test.go | 6 +-- internal/controller/openapi/devices.go | 25 ++++++----- internal/entity/dto/v1/export.go | 2 +- 6 files changed, 27 insertions(+), 60 deletions(-) diff --git a/integration-test/collections/console_mps_apis.postman_collection.json b/integration-test/collections/console_mps_apis.postman_collection.json index 219f1afe9..6aff26bc7 100644 --- a/integration-test/collections/console_mps_apis.postman_collection.json +++ b/integration-test/collections/console_mps_apis.postman_collection.json @@ -1622,9 +1622,6 @@ " pm.expect(jsonData).to.have.property('data');\r", " pm.expect(jsonData.data).to.be.an('array');\r", "});\r", - "pm.test(\"Returns X-Total-Count header\",function(){\r", - " pm.response.to.have.header('X-Total-Count');\r", - "});\r", "pm.test(\"Export never leaks credential fields\",function(){\r", " var raw = pm.response.text();\r", " pm.expect(raw).to.not.include('mpspassword');\r", @@ -1652,7 +1649,7 @@ } }, "url": { - "raw": "{{protocol}}://{{host}}/api/v1/devices/export", + "raw": "{{protocol}}://{{host}}/api/v1/device-exports", "protocol": "{{protocol}}", "host": [ "{{host}}" @@ -1660,8 +1657,7 @@ "path": [ "api", "v1", - "devices", - "export" + "device-exports" ] } }, diff --git a/internal/controller/httpapi/v1/devices.go b/internal/controller/httpapi/v1/devices.go index 698d3098e..dd6de41a6 100644 --- a/internal/controller/httpapi/v1/devices.go +++ b/internal/controller/httpapi/v1/devices.go @@ -29,11 +29,11 @@ func NewDeviceRoutes(handler *gin.RouterGroup, t devices.Feature, l logger.Inter r := &deviceRoutes{t, l} handler.GET("authorize/redirection/:id", r.LoginRedirection) + handler.GET("device-exports", r.export) h := handler.Group("/devices") { h.GET("", r.get) - h.GET("export", r.export) h.GET("stats", r.getStats) h.GET("redirectstatus/:guid", r.redirectStatus) h.GET("cert/:guid", r.getDeviceCertificate) diff --git a/internal/controller/httpapi/v1/export.go b/internal/controller/httpapi/v1/export.go index 753f11575..ab59327db 100644 --- a/internal/controller/httpapi/v1/export.go +++ b/internal/controller/httpapi/v1/export.go @@ -5,11 +5,9 @@ import ( "fmt" "net/http" "os" - "strconv" "time" "github.com/gin-gonic/gin" - "github.com/golang-jwt/jwt/v5" "github.com/device-management-toolkit/console/config" "github.com/device-management-toolkit/console/internal/entity/dto/v1" @@ -17,25 +15,22 @@ import ( const ( // maxExportRecords is the hard cap on records returned by a single export. - maxExportRecords = 10000 + maxExportRecords = 500 // exportTimeout bounds how long an export may take before it is abandoned. exportTimeout = 60 * time.Second - // exportCountHeader tells the client how many records are in the response. - exportCountHeader = "X-Total-Count" - - outcomeSuccess = "success" - outcomeError = "error" - tenantClaimKey = "tenantId" - subjectClaimKey = "sub" + outcomeSuccess = "success" + outcomeError = "error" ) -// export handles GET /api/v1/devices/export. It returns a tenant-scoped, +// export handles GET /api/v1/device-exports. It returns a tenant-scoped, // snapshot of every device details stored in deatabase. func (dr *deviceRoutes) export(c *gin.Context) { start := time.Now() - tenantID, userID := exportIdentity(c) + + userID := "" + tenantID := "" ctx, cancel := context.WithTimeout(c.Request.Context(), exportTimeout) defer cancel() @@ -64,7 +59,6 @@ func (dr *deviceRoutes) export(c *gin.Context) { Data: records, } - c.Header(exportCountHeader, strconv.Itoa(len(records))) dr.logExportAudit(c, userID, tenantID, len(records), time.Since(start), outcomeSuccess) c.JSON(http.StatusOK, resp) } @@ -79,30 +73,6 @@ func swVersion() string { return "console " + version } -// exportIdentity reads the tenant and subject from the already-verified JWT. The -// export is tenant-scoped: only devices for the caller's tenant are returned -func exportIdentity(c *gin.Context) (tenantID, userID string) { - token := resolveToken(c) - if token == "" { - return "", "" - } - - claims := jwt.MapClaims{} - if _, _, err := jwt.NewParser().ParseUnverified(token, claims); err != nil { - return "", "" - } - - if v, ok := claims[tenantClaimKey].(string); ok { - tenantID = v - } - - if v, ok := claims[subjectClaimKey].(string); ok { - userID = v - } - - return tenantID, userID -} - // logExportAudit writes a server-side audit record for every export attempt, func (dr *deviceRoutes) logExportAudit(c *gin.Context, userID, tenantID string, count int, dur time.Duration, outcome string) { serverHostname, _ := os.Hostname() diff --git a/internal/controller/httpapi/v1/export_test.go b/internal/controller/httpapi/v1/export_test.go index f75e21d55..c2fb12911 100644 --- a/internal/controller/httpapi/v1/export_test.go +++ b/internal/controller/httpapi/v1/export_test.go @@ -90,11 +90,10 @@ func TestExportDevices_Success(t *testing.T) { Return([]dto.Device{meDevice, nonMEDevice}, nil) w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/api/v1/devices/export", http.NoBody) + req, _ := http.NewRequest(http.MethodGet, "/api/v1/device-exports", http.NoBody) engine.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) - require.Equal(t, "2", w.Header().Get(exportCountHeader)) var resp dto.DeviceExport require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) @@ -147,11 +146,10 @@ func TestExportDevices_DatabaseError(t *testing.T) { Return(nil, assertErr{}) w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/api/v1/devices/export", http.NoBody) + req, _ := http.NewRequest(http.MethodGet, "/api/v1/device-exports", http.NoBody) engine.ServeHTTP(w, req) require.Equal(t, http.StatusServiceUnavailable, w.Code) - require.Empty(t, w.Header().Get(exportCountHeader)) } type assertErr struct{} diff --git a/internal/controller/openapi/devices.go b/internal/controller/openapi/devices.go index 83b84a2a2..56d3b259c 100644 --- a/internal/controller/openapi/devices.go +++ b/internal/controller/openapi/devices.go @@ -16,6 +16,20 @@ func (f *FuegoAdapter) RegisterDeviceRoutes() { f.registerDeviceQueryRoutes() f.registerDeviceCertificateRoutes() f.registerDeviceMutationRoutes() + f.registerDeviceExportRoutes() +} + +func (f *FuegoAdapter) registerDeviceExportRoutes() { + fuego.Get(f.server, "/api/v1/device-exports", f.exportDevices, + fuego.OptionTags("Devices"), + fuego.OptionSummary("Export Devices"), + fuego.OptionDescription("Export a tenant-scoped snapshot of all devices as JSON. "+ + "The response follows the nested schema (metadata, summary, data) and "+ + "excludes credential fields. Results are capped and the total record count is "+ + "returned in the response body's summary.totalCount."), + fuego.OptionAddResponse(http.StatusServiceUnavailable, "Export generation failed or timed out", fuego.Response{Type: ErrorResponse{}}), + protectedRouteOptions(), + ) } func (f *FuegoAdapter) registerDeviceAuthRoutes() { @@ -70,17 +84,6 @@ func (f *FuegoAdapter) registerDeviceQueryRoutes() { protectedRouteOptions(), ) - fuego.Get(f.server, "/api/v1/devices/export", f.exportDevices, - fuego.OptionTags("Devices"), - fuego.OptionSummary("Export Devices"), - fuego.OptionDescription("Export a tenant-scoped snapshot of all devices as JSON. "+ - "The response follows the nested schema (metadata, summary, data) and "+ - "excludes credential fields. Results are capped and the total record count is "+ - "returned in the X-Total-Count header."), - fuego.OptionAddResponse(http.StatusServiceUnavailable, "Export generation failed or timed out", fuego.Response{Type: ErrorResponse{}}), - protectedRouteOptions(), - ) - fuego.Get(f.server, "/api/v1/devices/redirectstatus/{guid}", f.getRedirectStatus, fuego.OptionTags("Devices"), fuego.OptionSummary("Get Redirect Status"), diff --git a/internal/entity/dto/v1/export.go b/internal/entity/dto/v1/export.go index 95bec59df..9bfa5be36 100644 --- a/internal/entity/dto/v1/export.go +++ b/internal/entity/dto/v1/export.go @@ -5,7 +5,7 @@ import ( "time" ) -// DeviceExport is the top-level response of GET /api/v1/devices/export. +// DeviceExport is the top-level response of GET /api/v1/device-exports. type DeviceExport struct { Metadata ExportMetadata `json:"metadata"` Summary ExportSummary `json:"summary"` From 8de1355f5d791044a14a2c3c93dac735bb7fb54c Mon Sep 17 00:00:00 2001 From: ShradhaGupta31 Date: Mon, 7 Sep 2026 22:06:08 +0530 Subject: [PATCH 3/3] refactor: fix codecov failures - updated internal/controller/httpapi/v1/devices_test.go Signed-off-by: ShradhaGupta31 --- internal/controller/openapi/devices_test.go | 42 +++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 internal/controller/openapi/devices_test.go diff --git a/internal/controller/openapi/devices_test.go b/internal/controller/openapi/devices_test.go new file mode 100644 index 000000000..777af49ec --- /dev/null +++ b/internal/controller/openapi/devices_test.go @@ -0,0 +1,42 @@ +package openapi + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRegisterDeviceRoutes_IncludesExportEndpoint(t *testing.T) { + t.Parallel() + + f := newTestAdapter() + f.RegisterDeviceRoutes() + + specBytes, err := f.GetOpenAPISpec() + require.NoError(t, err) + + var spec map[string]interface{} + require.NoError(t, json.Unmarshal(specBytes, &spec)) + + paths, ok := spec["paths"].(map[string]interface{}) + require.True(t, ok) + + require.Contains(t, paths, "/api/v1/device-exports", "device export route should be registered") + + exportPath, ok := paths["/api/v1/device-exports"].(map[string]interface{}) + require.True(t, ok) + require.Contains(t, exportPath, "get", "device export GET operation should be registered") +} + +func TestExportDevices_ReturnsExampleRecord(t *testing.T) { + t.Parallel() + + f := newTestAdapter() + + resp, err := f.exportDevices(nil) + require.NoError(t, err) + require.Equal(t, 1, resp.Summary.TotalCount) + require.Len(t, resp.Data, 1) + require.Equal(t, exampleDeviceGUID, resp.Data[0].GUID) +}