Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1716,6 +1716,8 @@
" pm.expect(jsonData.tags.length).to.be.equal(0);\r",
" pm.expect(jsonData.mpsInstance).to.be.equal(\"\");\r",
" pm.expect(jsonData.connectionStatus).to.be.equal(false);\r",
" pm.expect(jsonData.useTLS).to.be.equal(true);\r",
" pm.expect(jsonData.allowSelfSigned).to.be.equal(true);\r",
"})"
],
"type": "text/javascript",
Expand Down Expand Up @@ -2688,6 +2690,21 @@
"exec": [
"pm.test(\"Response includes X-Content-Type-Options: nosniff\", function () {",
" pm.expect(pm.response.headers.get(\"X-Content-Type-Options\")).to.eql(\"nosniff\");",
"});",
"",
"pm.test(\"Explicit false TLS settings remain false when present\", function () {",
" var jsonData = {};",
" try {",
" jsonData = pm.response.json();",
" } catch (e) {",
" return;",
" }",
" if (jsonData && Object.prototype.hasOwnProperty.call(jsonData, 'useTLS') && jsonData.useTLS === false) {",
" pm.expect(jsonData.useTLS).to.be.equal(false);",
" }",
" if (jsonData && Object.prototype.hasOwnProperty.call(jsonData, 'allowSelfSigned') && jsonData.allowSelfSigned === false) {",
" pm.expect(jsonData.allowSelfSigned).to.be.equal(false);",
" }",
"});"
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8421,6 +8421,21 @@
"exec": [
"pm.test(\"Response includes X-Content-Type-Options: nosniff\", function () {",
" pm.expect(pm.response.headers.get(\"X-Content-Type-Options\")).to.eql(\"nosniff\");",
"});",
"",
"pm.test(\"Explicit false TLS settings remain false when present\", function () {",
" var jsonData = {};",
" try {",
" jsonData = pm.response.json();",
" } catch (e) {",
" return;",
" }",
" if (jsonData && Object.prototype.hasOwnProperty.call(jsonData, 'useTLS') && jsonData.useTLS === false) {",
" pm.expect(jsonData.useTLS).to.be.equal(false);",
" }",
" if (jsonData && Object.prototype.hasOwnProperty.call(jsonData, 'allowSelfSigned') && jsonData.allowSelfSigned === false) {",
" pm.expect(jsonData.allowSelfSigned).to.be.equal(false);",
" }",
"});"
]
}
Expand Down
71 changes: 65 additions & 6 deletions internal/controller/httpapi/v1/devices.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package v1

import (
"bytes"
"encoding/json"
"io"
"net/http"
"strings"
"time"
Expand Down Expand Up @@ -189,14 +191,42 @@ func (dr *deviceRoutes) getByID(c *gin.Context) {
}

func (dr *deviceRoutes) insert(c *gin.Context) {
body, err := readJSONBody(c)
if err != nil {
validationErr := ErrValidationDevices.Wrap("insert", "readJSONBody", err)
ErrorResponse(c, validationErr)

return
}

var device dto.Device
if err := c.ShouldBindJSON(&device); err != nil {
validationErr := ErrValidationDevices.Wrap("insert", "ShouldBindJSON", err)
if err := json.Unmarshal(body, &device); err != nil {
validationErr := ErrValidationDevices.Wrap("insert", "json.Unmarshal", err)
ErrorResponse(c, validationErr)

return
}

var raw map[string]json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil {
validationErr := ErrValidationDevices.Wrap("insert", "json.Unmarshal", err)
ErrorResponse(c, validationErr)

return
}

hasUseTLS := hasJSONKey(raw, "usetls")
hasAllowSelfSigned := hasJSONKey(raw, "allowselfsigned")

// Security defaults: if these flags are omitted on create, default to secure values.
if !hasUseTLS {
device.UseTLS = true
}

if !hasAllowSelfSigned {
device.AllowSelfSigned = true
}

newDevice, err := dr.t.Insert(c.Request.Context(), &device)
if err != nil {
dr.l.Error(err, "http - devices - v1 - insert")
Expand All @@ -208,13 +238,35 @@ func (dr *deviceRoutes) insert(c *gin.Context) {
c.JSON(http.StatusCreated, newDevice)
}

func readJSONBody(c *gin.Context) ([]byte, error) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
return nil, err
}

c.Request.Body = io.NopCloser(bytes.NewReader(body))

return body, nil
}

func hasJSONKey(raw map[string]json.RawMessage, field string) bool {
needle := strings.ToLower(field)
for k := range raw {
if strings.EqualFold(k, needle) {
return true
}
}

return false
}

// Keys are lowercased so callers can match against setter maps regardless of
// client casing (encoding/json unmarshals case-insensitively).
// Nested objects are flattened with dot notation (for example,
// "deviceinfo.fwversion") so PATCH handlers can deep-merge object fields.
func providedJSONFields(c *gin.Context) (map[string]bool, error) {
func providedJSONFieldsFromBody(body []byte) (map[string]bool, error) {
var raw map[string]json.RawMessage
if err := c.ShouldBindBodyWithJSON(&raw); err != nil {
if err := json.Unmarshal(body, &raw); err != nil {
return nil, err
}

Expand Down Expand Up @@ -248,14 +300,21 @@ func collectNestedJSONFields(prefix string, raw json.RawMessage, fields map[stri
}

func (dr *deviceRoutes) update(c *gin.Context) {
body, err := readJSONBody(c)
if err != nil {
ErrorResponse(c, err)

return
}

var device dto.Device
if err := c.ShouldBindBodyWithJSON(&device); err != nil {
if err := json.Unmarshal(body, &device); err != nil {
ErrorResponse(c, err)

return
}

fields, err := providedJSONFields(c)
fields, err := providedJSONFieldsFromBody(body)
if err != nil {
ErrorResponse(c, err)

Expand Down
144 changes: 142 additions & 2 deletions internal/controller/httpapi/v1/devices_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,112 @@ func TestDevicesUpdatePartialPatch(t *testing.T) {
require.Equal(t, string(expected), w.Body.String())
}

func TestDevicesInsertDefaultsUseTLSToTrueWhenOmitted(t *testing.T) {
t.Parallel()

devicesFeature, engine := devicesTest(t)

expected := &dto.Device{
ConnectionStatus: false,
Hostname: "host-no-tls-field",
GUID: "123e4567-e89b-12d3-a456-426614174000",
Username: "admin1",
Password: "password1",
UseTLS: true,
AllowSelfSigned: true,
}

devicesFeature.EXPECT().Insert(context.Background(), expected).Return(expected, nil)

body := []byte(`{"connectionStatus":false,"hostname":"host-no-tls-field","guid":"123e4567-e89b-12d3-a456-426614174000","username":"admin1","password":"password1"}`)
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "/api/v1/devices", bytes.NewBuffer(body))
require.NoError(t, err)

w := httptest.NewRecorder()
engine.ServeHTTP(w, req)

require.Equal(t, http.StatusCreated, w.Code)

jsonBytes, _ := json.Marshal(expected)
require.Equal(t, string(jsonBytes), w.Body.String())
}

func TestDevicesInsertDefaultsAllowSelfSignedToTrueWhenOmitted(t *testing.T) {
t.Parallel()

devicesFeature, engine := devicesTest(t)

expected := &dto.Device{
ConnectionStatus: false,
Hostname: "host-no-self-signed-field",
GUID: "123e4567-e89b-12d3-a456-426614174001",
Username: "admin1",
Password: "password1",
UseTLS: true,
AllowSelfSigned: true,
}

devicesFeature.EXPECT().Insert(context.Background(), expected).Return(expected, nil)

body := []byte(`{"connectionStatus":false,"hostname":"host-no-self-signed-field","guid":"123e4567-e89b-12d3-a456-426614174001","username":"admin1","password":"password1"}`)
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "/api/v1/devices", bytes.NewBuffer(body))
require.NoError(t, err)

w := httptest.NewRecorder()
engine.ServeHTTP(w, req)

require.Equal(t, http.StatusCreated, w.Code)

jsonBytes, _ := json.Marshal(expected)
require.Equal(t, string(jsonBytes), w.Body.String())
}

func TestDevicesInsertHonorsExplicitUseTLSFalse(t *testing.T) {
t.Parallel()

devicesFeature, engine := devicesTest(t)

expected := &dto.Device{
ConnectionStatus: false,
Hostname: "host-explicit-false",
GUID: "123e4567-e89b-12d3-a456-426614174001",
Username: "admin1",
Password: "password1",
UseTLS: false,
AllowSelfSigned: false,
}

devicesFeature.EXPECT().Insert(context.Background(), expected).Return(expected, nil)

body := []byte(`{"connectionStatus":false,"hostname":"host-explicit-false","guid":"123e4567-e89b-12d3-a456-426614174001","username":"admin1","password":"password1","useTLS":false,"allowSelfSigned":false}`)
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "/api/v1/devices", bytes.NewBuffer(body))
require.NoError(t, err)

w := httptest.NewRecorder()
engine.ServeHTTP(w, req)

require.Equal(t, http.StatusCreated, w.Code)

jsonBytes, _ := json.Marshal(expected)
require.Equal(t, string(jsonBytes), w.Body.String())
}

func TestDevicesInsertRejectsInvalidJSON(t *testing.T) {
t.Parallel()

_, engine := devicesTest(t)

// Invalid JSON should fail during request binding.
body := []byte(`{invalid json}`)
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "/api/v1/devices", bytes.NewBuffer(body))
require.NoError(t, err)

w := httptest.NewRecorder()
engine.ServeHTTP(w, req)

require.Equal(t, http.StatusBadRequest, w.Code)
}

// encoding/json unmarshals case-insensitively; the merge must see the field as
// provided regardless of the casing the client used.
func TestDevicesUpdatePartialPatchMixedCaseKeys(t *testing.T) {
Expand Down Expand Up @@ -513,6 +619,38 @@ func TestCollectNestedJSONFields(t *testing.T) {
})
}

func TestDevicesInsertDefaultsTLSAndSelfSignedWhenOmitted(t *testing.T) {
t.Parallel()

incoming := &dto.Device{
AllowSelfSigned: true,
GUID: testDeviceGUID,
Hostname: "test-device",
UseTLS: true,
}

devicesFeature, engine := devicesTest(t)
devicesFeature.EXPECT().
Insert(context.Background(), incoming).
Return(incoming, nil)

body := []byte(`{
"guid":"` + testDeviceGUID + `",
"hostname":"test-device"
}`)

req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "/api/v1/devices", bytes.NewBuffer(body))
require.NoError(t, err)

w := httptest.NewRecorder()
engine.ServeHTTP(w, req)

require.Equal(t, http.StatusCreated, w.Code)

expected, _ := json.Marshal(incoming)
require.Equal(t, string(expected), w.Body.String())
}

func TestDevicesInsertAcceptsFullDeviceInfo(t *testing.T) {
t.Parallel()

Expand All @@ -525,8 +663,10 @@ func TestDevicesInsertAcceptsFullDeviceInfo(t *testing.T) {
ieee8021xEnabled := false

incoming := &dto.Device{
GUID: testDeviceGUID,
Hostname: "test-device",
GUID: testDeviceGUID,
Hostname: "test-device",
UseTLS: true,
AllowSelfSigned: true,
DeviceInfo: &dto.DeviceInfo{
FWVersion: "16.1.30",
FWBuild: "3400",
Expand Down
3 changes: 3 additions & 0 deletions internal/controller/httpapi/v1/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ func (lr LoginRoute) handleBasicAuth(creds dto.Credentials, c *gin.Context) {
// still clear its own. Not revocation: the JWT stays valid until it expires.
func (lr LoginRoute) Logout(c *gin.Context) {
clearSessionCookies(c)
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.Header("Pragma", "no-cache")
c.Header("Expires", "0")

c.JSON(http.StatusOK, gin.H{messageKey: "logged out"})
}
Expand Down
3 changes: 3 additions & 0 deletions internal/controller/httpapi/v1/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,9 @@ func TestLogoutExpiresSessionCookie(t *testing.T) {
engine.ServeHTTP(w, req)

require.Equal(t, http.StatusOK, w.Code, "logout must work without a valid session")
require.Equal(t, "no-cache, no-store, must-revalidate", w.Header().Get("Cache-Control"))
require.Equal(t, "no-cache", w.Header().Get("Pragma"))
require.Equal(t, "0", w.Header().Get("Expires"))

cleared := make(map[string]*http.Cookie)
for _, cookie := range w.Result().Cookies() {
Expand Down
17 changes: 13 additions & 4 deletions internal/controller/httpapi/v1/profiles.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package v1

import (
"encoding/json"
"net/http"

"github.com/gin-gonic/gin"
Expand Down Expand Up @@ -136,17 +137,25 @@ func (r *profileRoutes) insert(c *gin.Context) {
}

func (r *profileRoutes) update(c *gin.Context) {
body, err := readJSONBody(c)
if err != nil {
validationErr := ErrValidationProfile.Wrap("update", "readJSONBody", err)
ErrorResponse(c, validationErr)

return
}

var profile dto.Profile
if err := c.ShouldBindBodyWithJSON(&profile); err != nil {
validationErr := ErrValidationProfile.Wrap("update", "ShouldBindBodyWithJSON", err)
if err := json.Unmarshal(body, &profile); err != nil {
validationErr := ErrValidationProfile.Wrap("update", "json.Unmarshal", err)
ErrorResponse(c, validationErr)

return
}

fields, err := providedJSONFields(c)
fields, err := providedJSONFieldsFromBody(body)
if err != nil {
validationErr := ErrValidationProfile.Wrap("update", "providedJSONFields", err)
validationErr := ErrValidationProfile.Wrap("update", "providedJSONFieldsFromBody", err)
ErrorResponse(c, validationErr)

return
Expand Down
Loading
Loading