package pbs import ( "context" "crypto/sha256" "encoding/hex" "net/http" "net/http/httptest" "strings" "testing" ) // fingerprintOf returns the SHA-256 hex of a TLS test server's leaf cert (what the client pins). func fingerprintOf(ts *httptest.Server) string { sum := sha256.Sum256(ts.Certificate().Raw) return hex.EncodeToString(sum[:]) } // newPBSTestServer spins up a TLS server routing requests to fn, plus its fingerprint. func newPBSTestServer(t *testing.T, fn http.HandlerFunc) (*httptest.Server, string) { t.Helper() ts := httptest.NewTLSServer(fn) t.Cleanup(ts.Close) return ts, fingerprintOf(ts) } // hostPort splits an httptest URL "https://127.0.0.1:PORT" into host + port for NewClient. func hostPort(t *testing.T, url string) (string, int) { t.Helper() hp := strings.TrimPrefix(url, "https://") host, port, ok := strings.Cut(hp, ":") if !ok { t.Fatalf("bad url %q", url) } p := 0 for _, c := range port { p = p*10 + int(c-'0') } return host, p } // TestClient_FingerprintPinEnforced is the headline: a WRONG fingerprint is rejected; the // RIGHT one succeeds (mirrors the slice-1 PVE pin test). func TestClient_FingerprintPinEnforced(t *testing.T) { ts, fp := newPBSTestServer(t, func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte(`{"data":[]}`)) }) host, port := hostPort(t, ts.URL) // Wrong fingerprint → connection rejected at the TLS pin. wrong := strings.Repeat("ab", 32) bad, _ := NewClient(Config{Server: host, Port: port, Fingerprint: wrong, TokenID: "u@pbs!t", Secret: "s"}) if _, err := bad.Snapshots(context.Background(), "ds"); err == nil || !strings.Contains(err.Error(), "pin mismatch") { t.Fatalf("wrong fingerprint must be rejected with a pin mismatch, got %v", err) } // Correct fingerprint → succeeds. good, err := NewClient(Config{Server: host, Port: port, Fingerprint: fp, TokenID: "u@pbs!t", Secret: "s"}) if err != nil { t.Fatal(err) } if _, err := good.Snapshots(context.Background(), "ds"); err != nil { t.Fatalf("correct fingerprint should connect: %v", err) } } // TestClient_TokenHeaderAndNeverLogged asserts the auth header is the PBS token form and that // the secret is not exposed by the client's printed form. func TestClient_TokenHeaderAndNeverLogged(t *testing.T) { const secret = "super-secret-token-value-xyz" var gotAuth string ts, fp := newPBSTestServer(t, func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") w.Write([]byte(`{"data":[]}`)) }) host, port := hostPort(t, ts.URL) c, _ := NewClient(Config{Server: host, Port: port, Fingerprint: fp, TokenID: "felhom@pbs!n100", Secret: secret}) if _, err := c.Snapshots(context.Background(), "ds"); err != nil { t.Fatal(err) } if gotAuth != "PBSAPIToken=felhom@pbs!n100:"+secret { t.Errorf("auth header = %q, want PBSAPIToken=:", gotAuth) } // The secret lives only in the unexported authHeader and is never handed to a logger (the // client takes no logger). The "never logged" property of the verify LOOP — which does log // — is asserted in TestVerifyLoop_NeverLogsSecret (verify_test.go). } func TestClient_VerifyParsesUPID(t *testing.T) { ts, fp := newPBSTestServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/admin/datastore/ds/verify") { t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) } w.Write([]byte(`{"data":"UPID:dooplex:00034582:5269BDD7:00000005:6A282176:verify:ds:felhom@pbs!n100:"}`)) }) host, port := hostPort(t, ts.URL) c, _ := NewClient(Config{Server: host, Port: port, Fingerprint: fp, TokenID: "u@pbs!t", Secret: "s"}) upid, err := c.Verify(context.Background(), "ds") if err != nil || !strings.HasPrefix(upid, "UPID:dooplex:") { t.Fatalf("Verify upid=%q err=%v", upid, err) } } func TestClient_SnapshotsMapToHub(t *testing.T) { body := `{"data":[ {"backup-type":"ct","backup-id":"9001","backup-time":1781014713,"size":2518889256, "owner":"felhom@pbs!n100","protected":false, "verification":{"state":"ok","upid":"UPID:dooplex:..:verify:ds:u:"}, "files":[{"filename":"pct.conf.blob","crypt-mode":"encrypt","size":240}, {"filename":"index.json.blob","crypt-mode":"sign-only","size":646}]}, {"backup-type":"ct","backup-id":"9002","backup-time":1781000000,"size":10,"owner":"x", "files":[{"filename":"root.pxar.didx","crypt-mode":"encrypt","size":10}]} ]}` ts, fp := newPBSTestServer(t, func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte(body)) }) host, port := hostPort(t, ts.URL) c, _ := NewClient(Config{Server: host, Port: port, Fingerprint: fp, TokenID: "u@pbs!t", Secret: "s"}) snaps, err := c.Snapshots(context.Background(), "ds") if err != nil || len(snaps) != 2 { t.Fatalf("snaps=%d err=%v", len(snaps), err) } // First: verified ok, encrypted, RFC3339 time, default namespace. h0 := snaps[0].ToHub() if h0.VerifyState != VerifyOK || !h0.Encrypted || h0.Namespace != "root" { t.Errorf("h0 = %+v, want verify=ok encrypted=true ns=root", h0) } if h0.BackupTime != "2026-06-09T14:18:33Z" { t.Errorf("backup_time = %q, want RFC3339 UTC", h0.BackupTime) } // Second: NO verification field → verify_state "none". if h1 := snaps[1].ToHub(); h1.VerifyState != VerifyNone { t.Errorf("absent verification must map to %q, got %q", VerifyNone, h1.VerifyState) } } func TestNodeFromUPID(t *testing.T) { cases := map[string]string{ "UPID:dooplex:00034582:5269BDD7:00000005:6A282176:verify:ds:u:": "dooplex", "UPID:demo-felhom:00:00:00:00:vzdump:9001:root@pam:": "demo-felhom", "not-a-upid": "", "": "", } for in, want := range cases { if got := NodeFromUPID(in); got != want { t.Errorf("NodeFromUPID(%q) = %q, want %q", in, got, want) } } } func TestNormalizeFingerprint(t *testing.T) { if _, err := normalizeFingerprint("3b:95:5a"); err == nil { t.Error("short fingerprint must error") } if _, err := normalizeFingerprint(strings.Repeat("g", 64)); err == nil { t.Error("non-hex must error") } got, err := normalizeFingerprint("3B:95:" + strings.Repeat("a", 60)) if err != nil || strings.Contains(got, ":") || got != strings.ToLower(got) { t.Errorf("normalize = %q err=%v (want lowercased, colons stripped)", got, err) } }