Files
felhom-agent/internal/pbs/verify_test.go
T
admin 766500dfc3 v0.6.0: slice 6 Phase B — PBS offsite tier (verify + PBS-API client + reporting)
Spike-proven that backup/restore-to-PBS reuse Phase A unchanged; the only new code is
the verify capability, a small PBS-API client, and PBSSnapshot reporting.

- internal/pbs: fingerprint-pinned, token-authed PBS-API client (Verify/Snapshots/
  TaskStatus, node-from-UPID; secret read from /etc/pve/priv/storage/<id>.pw at runtime,
  never logged) + the verify maintenance loop (own cadence, default 6h, NOT gated/journaled,
  like the watchdog) + SnapshotStore.
- hub: PBSSnapshot filled (namespace/type/id/time/size/owner/protected/encrypted/
  verify_state/verify_upid); PBSReporter collector seam; cross-repo golden + bidirectional
  key-set tests; hub handler parses pbs_snapshots + logs a failed-verify WARN.
- backup: report the ACTUAL vzdump mode (parsed from the task log; PVE may downgrade
  snapshot->stop). proxmox.Storage.Username. config PBSVerifyCadence/secret-dir.
  --selftest=pbs-verify. Backup/restore-to-PBS unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:53:04 +02:00

101 lines
3.7 KiB
Go

package pbs
import (
"bytes"
"context"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// verifyServer is a fake PBS that answers verify POSTs, task status, and a snapshot list whose
// verification.state is controlled by `state` ("" → no verification field → none).
func verifyServer(t *testing.T, state string) (*httptest.Server, string) {
t.Helper()
verification := ""
if state != "" {
verification = `"verification":{"state":"` + state + `","upid":"UPID:dooplex:0:0:0:0:verify:ds:u:"},`
}
body := `{"data":[{"backup-type":"ct","backup-id":"9001","backup-time":1781014713,"size":10,"owner":"u",` +
verification + `"files":[{"filename":"root.pxar.didx","crypt-mode":"encrypt","size":10}]}]}`
return newPBSTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "/verify"):
w.Write([]byte(`{"data":"UPID:dooplex:0:0:0:0:verify:ds:u:"}`))
case strings.Contains(r.URL.Path, "/status"):
w.Write([]byte(`{"data":{"status":"stopped","exitstatus":"OK","node":"dooplex"}}`))
default: // snapshots
w.Write([]byte(body))
}
})
}
func TestVerifyLoop_RecordsState(t *testing.T) {
ts, fp := verifyServer(t, "ok")
host, port := hostPort(t, ts.URL)
c, _ := NewClient(Config{Server: host, Port: port, Fingerprint: fp, TokenID: "u@pbs!t", Secret: "s"})
store := NewSnapshotStore()
loop := NewVerifyLoop(VerifyLoopOptions{
Targets: func(context.Context) ([]Target, error) { return []Target{{Datastore: "ds", Client: c}}, nil },
Store: store,
})
loop.RunOnce(context.Background())
snaps := store.PBSSnapshots(context.Background())
if len(snaps) != 1 || snaps[0].VerifyState != VerifyOK {
t.Fatalf("loop should record 1 ok snapshot, got %+v", snaps)
}
}
func TestVerifyLoop_FailedVerifyRecorded(t *testing.T) {
ts, fp := verifyServer(t, "failed")
host, port := hostPort(t, ts.URL)
c, _ := NewClient(Config{Server: host, Port: port, Fingerprint: fp, TokenID: "u@pbs!t", Secret: "s"})
store := NewSnapshotStore()
NewVerifyLoop(VerifyLoopOptions{
Targets: func(context.Context) ([]Target, error) { return []Target{{Datastore: "ds", Client: c}}, nil },
Store: store,
}).RunOnce(context.Background())
if s := store.PBSSnapshots(context.Background()); len(s) != 1 || s[0].VerifyState != VerifyFailed {
t.Fatalf("failed verify must be recorded, got %+v", s)
}
}
// TestVerifyLoop_NeverLogsSecret runs a full cycle with the loop's logger capturing output and
// asserts the token secret never appears in any log line.
func TestVerifyLoop_NeverLogsSecret(t *testing.T) {
const secret = "tok-secret-DO-NOT-LOG-7f3a"
ts, fp := verifyServer(t, "ok")
host, port := hostPort(t, ts.URL)
c, _ := NewClient(Config{Server: host, Port: port, Fingerprint: fp, TokenID: "felhom@pbs!n100", Secret: secret})
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
loop := NewVerifyLoop(VerifyLoopOptions{
Targets: func(context.Context) ([]Target, error) { return []Target{{Datastore: "ds", Client: c}}, nil },
Store: NewSnapshotStore(),
Logger: logger,
})
loop.RunOnce(context.Background())
if strings.Contains(buf.String(), secret) {
t.Fatalf("the verify loop logged the token secret")
}
}
func TestVerifyLoop_DisabledByNegativeCadence(t *testing.T) {
loop := NewVerifyLoop(VerifyLoopOptions{
Targets: func(context.Context) ([]Target, error) { return nil, nil },
Store: NewSnapshotStore(),
Cadence: -1,
})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- loop.Run(ctx) }()
cancel()
if err := <-done; err != nil {
t.Fatalf("disabled loop Run should return nil on cancel, got %v", err)
}
}