test(proxmox): verify pending update credential routing

An admin-browser update read does not establish access for the monitoring token. Cover the proxied target path, configured token, denial propagation and healthy endpoint retention. Remove the unsupported Sys.Audit diagnostic hint: endpoint access is determined by Proxmox, not general inventory access.

Change-source: pulse-maintainer
Contract-Neutral: Diagnostic wording and method-comment correction plus synthetic regression coverage only; no collection logic, public API, permission policy, freshness or counter semantics change.
This commit is contained in:
pulse-triage[bot]
2026-09-08 22:32:23 +01:00
parent 340160ab9d
commit 092856b31c
3 changed files with 68 additions and 2 deletions
@@ -378,7 +378,7 @@ func (m *Monitor) applyNodePendingUpdates(ctx context.Context, instanceName stri
Err(err).
Str("node", node.Node).
Str("instance", instanceName).
Msg("Could not check pending apt updates (may require Sys.Audit permission)")
Msg("Could not check pending apt updates with configured Proxmox credentials")
modelNode.PendingUpdatesReason = pendingUpdatesFailureReason(err)
if hasCached {
modelNode.PendingUpdates = cached.count
+3 -1
View File
@@ -2944,7 +2944,9 @@ type AptPackage struct {
}
// GetNodePendingUpdates returns the list of pending apt updates for a node
// Requires Sys.Audit permission on /nodes/{node}
// Access is evaluated by Proxmox for the configured credential on /nodes/{node}.
// Do not infer access from general node-audit permission; endpoint requirements
// can differ from other monitoring reads.
func (c *Client) GetNodePendingUpdates(ctx context.Context, node string) ([]AptPackage, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/apt/update", node))
if err != nil {
@@ -0,0 +1,64 @@
package proxmox
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
)
// A cluster entry endpoint may proxy a different target node. Neither the
// selected endpoint nor its successful inventory read grants apt access.
func TestClusterClient_PendingUpdatesConfiguredCredentialAndTarget(t *testing.T) {
for _, status := range []int{http.StatusOK, http.StatusForbidden} {
t.Run(fmt.Sprint(status), func(t *testing.T) {
var reads atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("unexpected method %s", r.Method)
}
if r.Header.Get("Authorization") != "PVEAPIToken=monitor@pve!readonly=synthetic" {
t.Error("configured token was not preserved")
}
if r.Header.Get("Cookie") != "" {
t.Error("unexpected browser/session credential")
}
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api2/json/nodes":
fmt.Fprint(w, `{"data":[{"node":"entry","status":"online"},{"node":"target","status":"online"}]}`)
case "/api2/json/nodes/target/apt/update":
reads.Add(1)
w.WriteHeader(status)
if status == http.StatusOK {
fmt.Fprint(w, `{"data":[{"Package":"example","Version":"2"}]}`)
} else {
fmt.Fprint(w, `{"message":"Permission check failed"}`)
}
default:
t.Errorf("unexpected request path %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
cfg := ClientConfig{Host: server.URL, TokenName: "monitor@pve!readonly", TokenValue: "synthetic"}
cc := NewClusterClient("routing-test", cfg, []string{server.URL}, nil)
updates, err := cc.GetNodePendingUpdates(context.Background(), "target")
if status == http.StatusOK {
if err != nil || len(updates) != 1 || updates[0].Package != "example" {
t.Fatalf("unexpected successful result: %v, %v", updates, err)
}
} else if err == nil || extractStatusCode(err.Error()) != http.StatusForbidden || len(updates) != 0 {
t.Fatalf("denial not preserved: %v, %v", updates, err)
}
if reads.Load() != 1 {
t.Fatalf("apt reads = %d; expected one without auth retry", reads.Load())
}
if !cc.GetHealthStatus()[server.URL] {
t.Error("apt permission failure must not poison cluster endpoint health")
}
})
}
}