Skip to content
Merged
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
18 changes: 16 additions & 2 deletions internal/api/lldp_capability.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,20 @@ type LLDPCapabilityResult struct {
Detail string `json:"detail,omitempty"`
}

func normalizedLLDPCapabilityResult(result *LLDPCapabilityResult) *LLDPCapabilityResult {
if result == nil {
return nil
}
normalized := *result
if normalized.RequestedPackages == nil {
normalized.RequestedPackages = []string{}
}
if normalized.AddedPackages == nil {
normalized.AddedPackages = []string{}
}
return &normalized
}

func (s *Server) handleLLDPStatus(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r, "id")
if !ok {
Expand Down Expand Up @@ -72,7 +86,7 @@ func (s *Server) handleLLDPStatus(w http.ResponseWriter, r *http.Request) {
}
}
}
writeJSON(w, http.StatusOK, out)
writeJSON(w, http.StatusOK, normalizedLLDPCapabilityResult(&out))
}

func (s *Server) handleLLDPCapability(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -147,5 +161,5 @@ func (s *Server) handleLLDPCapability(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, res)
writeJSON(w, http.StatusOK, normalizedLLDPCapabilityResult(res))
}
87 changes: 87 additions & 0 deletions internal/api/lldp_capability_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package api

import (
"context"
"encoding/json"
"net/http"
"strconv"
"testing"
)

func TestLLDPStatusNormalizesPackageArrays(t *testing.T) {
for _, tc := range []struct {
name string
requested []string
added []string
wantReq string
wantAdded string
}{
{name: "nil", wantReq: "[]", wantAdded: "[]"},
{
name: "nonempty", requested: []string{"lldpd"}, added: []string{"lldpd", "libcap"},
wantReq: `["lldpd"]`, wantAdded: `["lldpd","libcap"]`,
},
} {
t.Run(tc.name, func(t *testing.T) {
h := newHarness(t)
h.setup()
dev := h.seedDevice("lldp-"+tc.name, true, nil)
ctx := context.Background()
if err := h.db.BeginCapabilityInstall(ctx, dev.ID, "lldp", "apk", tc.requested, []string{}, nil); err != nil {
t.Fatal(err)
}
if err := h.db.CompleteCapabilityInstall(ctx, dev.ID, "lldp", tc.added, nil); err != nil {
t.Fatal(err)
}

w := h.do(http.MethodGet, "/api/v1/devices/"+strconv.FormatInt(dev.ID, 10)+"/capabilities/lldp", nil)
assertLLDPPackageJSON(t, w.Code, w.Body.Bytes(), tc.wantReq, tc.wantAdded)
})
}
}

func TestLLDPActionNormalizesPackageArrays(t *testing.T) {
for _, tc := range []struct {
name string
result *LLDPCapabilityResult
wantReq string
wantAdded string
}{
{
name: "nil", result: &LLDPCapabilityResult{DeviceID: 7, State: "installed"},
wantReq: "[]", wantAdded: "[]",
},
{
name: "nonempty", result: &LLDPCapabilityResult{
DeviceID: 7, State: "installed", RequestedPackages: []string{"lldpd"}, AddedPackages: []string{"lldpd"},
},
wantReq: `["lldpd"]`, wantAdded: `["lldpd"]`,
},
} {
t.Run(tc.name, func(t *testing.T) {
h, enroller := harnessWithEnroller(t)
enroller.lldpRes = tc.result
w := h.do(http.MethodPost, "/api/v1/devices/7/capabilities/lldp", map[string]any{
"action": "diagnose", "username": "root", "acknowledge_read_only_diagnostics": true,
})
assertLLDPPackageJSON(t, w.Code, w.Body.Bytes(), tc.wantReq, tc.wantAdded)
})
}
}

func assertLLDPPackageJSON(t *testing.T, status int, body []byte, wantRequested, wantAdded string) {
t.Helper()
if status != http.StatusOK {
t.Fatalf("status=%d body=%s", status, body)
}
var got map[string]json.RawMessage
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("decode response: %v: %s", err, body)
}
if string(got["requested_packages"]) != wantRequested || string(got["added_packages"]) != wantAdded {
t.Fatalf("requested_packages=%s added_packages=%s body=%s", got["requested_packages"], got["added_packages"], body)
}
if _, ok := got["configured_interfaces"]; ok {
t.Fatalf("configured_interfaces should remain omitted: %s", body)
}
}
3 changes: 2 additions & 1 deletion ui/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -714,7 +714,8 @@ export interface LLDPCapabilityResult {
state: 'not_installed' | 'install_planned' | 'installing' | 'installed' | 'configure_planned' | 'remove_planned' | 'removing' | 'error'
package_manager?: 'apk' | 'opkg'
requested_packages: string[]
added_packages: string[]
// Older controllers may return null or omit this field; current responses always use an array.
added_packages?: string[] | null
plan?: string
plan_hash?: string
diagnostics?: string
Expand Down
2 changes: 1 addition & 1 deletion ui/src/screens/Devices.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1513,7 +1513,7 @@ function LLDPCapability({ deviceID, managementMode, managementModeError, onUpdat
{installed && (
<p style={{ margin: '8px 0 0' }}>
Controller record: {status?.state}. Controller-added packages:{' '}
{status?.added_packages.join(', ') || 'none; lldpd existed before adoption'}.
{(status?.added_packages ?? []).join(', ') || 'none recorded'}.
</p>
)}
{!installed && !readOnly && (
Expand Down
42 changes: 41 additions & 1 deletion ui/src/screens/screens.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2661,6 +2661,45 @@ describe('Devices — re-probe panel', () => {
expect(api.changeLLDPCapability).not.toHaveBeenCalled()
})

it.each([
['null', null, 'none recorded'],
['absent', undefined, 'none recorded'],
['empty', [], 'none recorded'],
['nonempty', ['lldpd', 'libcap'], 'lldpd, libcap'],
])('renders legacy %s LLDP added packages while details are collapsed', async (_shape, addedPackages, want) => {
const response: Record<string, unknown> = {
device_id: 1,
name: 'ap-1',
state: 'installed',
requested_packages: ['lldpd'],
}
if (addedPackages !== undefined) response.added_packages = addedPackages
api.lldpCapability.mockResolvedValue(response)

await openPanel()

const details = screen.getByText('What this installs and rolls back').closest('details') as HTMLDetailsElement
expect(details.open).toBe(false)
expect((await screen.findByText(/Controller record:/)).textContent).toContain(`Controller-added packages: ${want}.`)
})

it('keeps a legacy null-package LLDP error state visible', async () => {
api.lldpCapability.mockResolvedValue({
device_id: 1,
name: 'ap-1',
state: 'error',
requested_packages: ['lldpd'],
added_packages: null,
detail: 'LLDP verification failed',
})

await openPanel()

const notice = await screen.findByRole('group', { name: 'Warning: Optional LLDP topology capability' })
expect(notice.textContent).toMatch(/needs attention/)
expect((await screen.findByText('LLDP verification failed')).closest('[role="alert"]')).toBeTruthy()
})

it('disables ACL and LLDP surfaces when the management mode is invalid', async () => {
api.device.mockResolvedValue({
...detail,
Expand Down Expand Up @@ -2707,7 +2746,7 @@ describe('Devices — re-probe panel', () => {
state: 'installed',
package_manager: 'apk',
requested_packages: ['lldpd'],
added_packages: ['lldpd'],
added_packages: null,
service_enabled: true,
service_running: true,
})
Expand Down Expand Up @@ -2771,6 +2810,7 @@ describe('Devices — re-probe panel', () => {
}),
),
)
expect(await screen.findByText(/Controller-added packages: none recorded\./)).toBeTruthy()
})

it('clears rejected LLDP package-plan credentials before a retry', async () => {
Expand Down