package proxmox import ( "bytes" "context" "encoding/json" "encoding/pem" "errors" "fmt" "io" stdlog "log" "net" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "reflect" "strings" "sync" "sync/atomic" "testing" "time" sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/sdk/trace/tracetest" "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider" "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/tracing" ) const testToken = "proxy-operator@pve!operator=0b4e1c2a-5d6f-4a7b-8c9d-0e1f2a3b4c5d" // clientFixture is a TLS test server, a CA file trusting it, a token file // and the config pointing at them. type clientFixture struct { srv *httptest.Server cfg provider.ProxmoxConfig tokenFile string caFile string } // newClientFixture starts h on a TLS server whose error log is discarded: // the tests that fail a handshake on purpose would otherwise print it. func newClientFixture(t *testing.T, h http.HandlerFunc) *clientFixture { t.Helper() srv := httptest.NewUnstartedServer(h) srv.Config.ErrorLog = stdlog.New(io.Discard, "", 0) srv.StartTLS() t.Cleanup(srv.Close) return fixtureFor(t, srv) } func fixtureFor(t *testing.T, srv *httptest.Server) *clientFixture { t.Helper() dir := t.TempDir() fx := &clientFixture{srv: srv, tokenFile: filepath.Join(dir, "token"), caFile: filepath.Join(dir, "ca.pem")} writeFile(t, fx.tokenFile, testToken+"\n") writeFile(t, fx.caFile, string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: srv.Certificate().Raw}))) fx.cfg = provider.ProxmoxConfig{URL: srv.URL, TokenFile: fx.tokenFile, CAFile: fx.caFile, RequestTimeoutSeconds: 30} return fx } func (fx *clientFixture) api(t *testing.T, opts ...tracing.Option) *httpAPI { t.Helper() a, err := newHTTPAPI(fx.cfg, opts...) if err != nil { t.Fatalf("newHTTPAPI error = %v", err) } return a } func writeFile(t *testing.T, path, content string) { t.Helper() if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatalf("writing %s: %v", path, err) } } // serveCapture writes a Phase 0 capture back byte for byte, reason phrase // included, which net/http's ResponseWriter cannot do. func serveCapture(t *testing.T, w http.ResponseWriter, c capture) { t.Helper() conn, buf, err := w.(http.Hijacker).Hijack() if err != nil { t.Errorf("hijack: %v", err) return } defer func() { _ = conn.Close() }() _, _ = fmt.Fprintf(buf, "HTTP/1.1 %d %s\r\nContent-Type: application/json;charset=UTF-8\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", c.Status, c.Reason, len(c.Body), c.Body) _ = buf.Flush() } func serveJSON(w http.ResponseWriter, body string) { w.Header().Set("Content-Type", "application/json;charset=UTF-8") _, _ = io.WriteString(w, body) } func assertClass(t *testing.T, err, class error) { t.Helper() if got := classify("get", err); got != class { t.Errorf("classify(%v) = %v, want %v", err, got, class) } } func TestClient_sendsTokenHeaderFromFileOnEveryRequest(t *testing.T) { t.Parallel() var ( mu sync.Mutex headers []string ) fx := newClientFixture(t, func(w http.ResponseWriter, r *http.Request) { mu.Lock() headers = append(headers, r.Header.Get("Authorization")) mu.Unlock() serveJSON(w, readCapture(t, "version").Body) }) a := fx.api(t) const rotated = "proxy-operator@pve!operator=11111111-2222-3333-4444-555555555555" for i, token := range []string{testToken, rotated} { writeFile(t, fx.tokenFile, token+"\n") v, err := a.Version(context.Background()) if err != nil || v.Version != "9.2.20" { t.Fatalf("call %d: Version() = %+v, %v", i, v, err) } } want := []string{"PVEAPIToken=" + testToken, "PVEAPIToken=" + rotated} mu.Lock() defer mu.Unlock() if !reflect.DeepEqual(headers, want) { t.Errorf("Authorization headers = %q, want %q", headers, want) } } func TestClient_missingTokenFileIsTransient(t *testing.T) { t.Parallel() var requests atomic.Int32 fx := newClientFixture(t, func(http.ResponseWriter, *http.Request) { requests.Add(1) }) fx.cfg.TokenFile = filepath.Join(t.TempDir(), "absent") _, err := fx.api(t).Version(context.Background()) if !errors.Is(err, provider.ErrTransient) || !errors.Is(err, errCredentials) { t.Errorf("error = %v, want ErrTransient and errCredentials", err) } if n := requests.Load(); n != 0 { t.Errorf("server saw %d requests, want none without a token", n) } } func TestClient_unparsableTokenFileIsPermanent(t *testing.T) { t.Parallel() const sentinel = "SENTINEL-7f3e" tests := []struct{ name, content string }{ {name: "empty", content: ""}, {name: "secret only", content: sentinel}, {name: "no secret", content: "proxy-operator@pve!operator"}, {name: "no token id", content: "proxy-operator@pve=" + sentinel}, {name: "no realm", content: "proxy-operator!operator=" + sentinel}, {name: "two lines", content: "proxy-operator@pve!operator=" + sentinel + "\nsecond=line"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() var requests atomic.Int32 fx := newClientFixture(t, func(http.ResponseWriter, *http.Request) { requests.Add(1) }) writeFile(t, fx.tokenFile, tc.content) _, err := fx.api(t).Version(context.Background()) if !errors.Is(err, provider.ErrPermanent) || !errors.Is(err, errCredentials) { t.Errorf("error = %v, want ErrPermanent and errCredentials", err) } if err != nil && strings.Contains(err.Error(), sentinel) { t.Errorf("error carries the file's content: %v", err) } if n := requests.Load(); n != 0 { t.Errorf("server saw %d requests, want none", n) } }) } } // TestClient_apiErrorFromStatusReasonAndBody serves every captured error // verbatim and expects the apiError the captures describe (F1): the body's // message, or the reason phrase when the body is empty. func TestClient_apiErrorFromStatusReasonAndBody(t *testing.T) { t.Parallel() paths, err := filepath.Glob(filepath.Join("testdata", "err_*.json")) if err != nil || len(paths) == 0 { t.Fatalf("no error captures: %v", err) } for _, path := range paths { name := strings.TrimSuffix(filepath.Base(path), ".json") if readCapture(t, name).Status < 400 { continue // err_tag_hyphen: hyphens turned out to be accepted (F9) } t.Run(name, func(t *testing.T) { t.Parallel() c := readCapture(t, name) fx := newClientFixture(t, func(w http.ResponseWriter, _ *http.Request) { serveCapture(t, w, c) }) _, err := fx.api(t).Version(context.Background()) var ae *apiError if !errors.As(err, &ae) { t.Fatalf("error = %v (%T), want *apiError", err, err) } want := loadAPIError(t, name) if ae.Status != want.Status || ae.Message != want.Message || !reflect.DeepEqual(ae.Errors, want.Errors) { t.Errorf("apiError = %+v, want %+v", ae, want) } if ae.Message == "" || strings.HasSuffix(ae.Message, "\n") { t.Errorf("message %q is empty or untrimmed", ae.Message) } }) } } func TestClient_decodesDataEnvelope(t *testing.T) { t.Parallel() ctx := context.Background() tests := []struct { capture string call func(a *httpAPI) (any, error) check func(t *testing.T, got any) }{ {capture: "version", call: func(a *httpAPI) (any, error) { return a.Version(ctx) }, check: func(t *testing.T, got any) { if v := got.(versionInfo); v.Version != "9.2.20" { t.Errorf("version = %+v", v) } }}, {capture: "node_status", call: func(a *httpAPI) (any, error) { return a.NodeStatus(ctx, "pv1-1") }, check: func(t *testing.T, got any) { if s := got.(nodeStatus); s.CurrentKernel.Release != "7.0.14-17-pve" { t.Errorf("node status = %+v", s) } }}, {capture: "storage_status", call: func(a *httpAPI) (any, error) { return a.StorageStatus(ctx, "pv1-1", "local") }, check: func(t *testing.T, got any) { if s := got.(storageStatus); s.Avail <= 0 || !strings.Contains(s.Content, "iso") { t.Errorf("storage status = %+v", s) } }}, {capture: "cluster_resources_during_clone", call: func(a *httpAPI) (any, error) { return a.ClusterResources(ctx) }, check: func(t *testing.T, got any) { rs := got.([]vmResource) for _, r := range rs { if r.VMID == 199999 && r.Lock == "clone" && r.Name == "" { return } } t.Errorf("resources = %+v, want the nameless locked clone", rs) }}, {capture: "vm_config_clone", call: func(a *httpAPI) (any, error) { return a.VMConfig(ctx, "pv1-1", 199999) }, check: func(t *testing.T, got any) { cfg := got.(vmConfig) if cfg["cores"] != "2" || cfg["memory"] != "2048" || cfg["agent"] != "1" || cfg["boot"] != "order=scsi0" { t.Errorf("config scalars = cores %q memory %q agent %q boot %q", cfg["cores"], cfg["memory"], cfg["agent"], cfg["boot"]) } if m := parseDescription(cfg["description"]); !m.Managed || m.UID == "" { t.Errorf("description %q did not survive decoding", cfg["description"]) } }}, {capture: "status_current_paused", call: func(a *httpAPI) (any, error) { return a.VMStatus(ctx, "pv1-1", 199999) }, check: func(t *testing.T, got any) { if s := got.(vmStatus); s.Status != "running" || s.QMPStatus != "paused" { t.Errorf("status = %+v", s) } }}, {capture: "task_status_stopped_error", call: func(a *httpAPI) (any, error) { return a.TaskStatus(ctx, "pv1-1", "UPID:pv1-1:x") }, check: func(t *testing.T, got any) { if s := got.(taskStatus); s.Status != "stopped" || s.ExitStatus != "shrinking disks is not supported" { t.Errorf("task = %+v", s) } }}, {capture: "agent_network_get_interfaces", call: func(a *httpAPI) (any, error) { return a.AgentNetworkInterfaces(ctx, "pv1-1", 199999) }, check: func(t *testing.T, got any) { if ip := firstIPv4(got.([]agentInterface)); ip != "192.168.0.150" { t.Errorf("first IPv4 = %q", ip) } }}, {capture: "set_config_response", call: func(a *httpAPI) (any, error) { return nil, a.SetVMConfig(ctx, "pv1-1", 199999, map[string]string{"cores": "1"}) }, check: func(*testing.T, any) {}}, {capture: "clone_response", call: func(a *httpAPI) (any, error) { return a.Clone(ctx, "pv1-1", 9001, cloneParams{NewID: 199999}) }, check: wantUPID("qmclone")}, {capture: "resize_response", call: func(a *httpAPI) (any, error) { return a.Resize(ctx, "pv1-1", 199999, "scsi0", "4G") }, check: wantUPID("resize")}, {capture: "start_response", call: func(a *httpAPI) (any, error) { return a.Start(ctx, "pv1-1", 199999) }, check: wantUPID("qmstart")}, {capture: "stop_response", call: func(a *httpAPI) (any, error) { return a.Stop(ctx, "pv1-1", 199999) }, check: wantUPID("qmstop")}, {capture: "destroy_response", call: func(a *httpAPI) (any, error) { return a.Destroy(ctx, "pv1-1", 199999) }, check: wantUPID("qmdestroy")}, {capture: "upload_response", call: func(a *httpAPI) (any, error) { return a.UploadISO(ctx, "pv1-1", "local", "x.iso", strings.NewReader("iso"), 3) }, check: wantUPID("imgcopy")}, {capture: "content_delete_response", call: func(a *httpAPI) (any, error) { return a.DeleteVolume(ctx, "pv1-1", "proxy-seeds", "proxy-seeds:iso/x.iso") }, check: wantUPID("imgdel")}, } for _, tc := range tests { t.Run(tc.capture, func(t *testing.T) { t.Parallel() c := readCapture(t, tc.capture) fx := newClientFixture(t, func(w http.ResponseWriter, r *http.Request) { _, _ = io.Copy(io.Discard, r.Body) serveCapture(t, w, c) }) got, err := tc.call(fx.api(t)) if err != nil { t.Fatalf("call error = %v", err) } tc.check(t, got) }) } } func wantUPID(kind string) func(t *testing.T, got any) { return func(t *testing.T, got any) { t.Helper() if upid := got.(string); !strings.HasPrefix(upid, "UPID:pv1-1:") || !strings.Contains(upid, ":"+kind+":") { t.Errorf("upid = %q, want a %s UPID of pv1-1", upid, kind) } } } // seenRequest is what a test server records of one request. type seenRequest struct { method, path, escapedPath string query, form url.Values contentType string } func recordingServer(t *testing.T, seen chan<- seenRequest) *clientFixture { t.Helper() return newClientFixture(t, func(w http.ResponseWriter, r *http.Request) { req := seenRequest{method: r.Method, path: r.URL.Path, escapedPath: r.URL.EscapedPath(), query: r.URL.Query(), contentType: r.Header.Get("Content-Type")} if r.Method == http.MethodPost || r.Method == http.MethodPut { if err := r.ParseForm(); err != nil { t.Errorf("parsing form: %v", err) } req.form = r.PostForm } seen <- req serveJSON(w, `{"data":null}`) }) } // TestClient_methodPathAndParams pins the HTTP method, path and parameters // of every seam method to the endpoint map of spec entry 17. func TestClient_methodPathAndParams(t *testing.T) { t.Parallel() ctx := context.Background() const upid = "UPID:pv1-1:0010FBF1:02804D0D:6AB378EA:qmstart:199999:proxy-operator@pve!operator:" tests := map[string]struct { call func(a *httpAPI) error method string path string query url.Values form url.Values }{ "Version": {call: func(a *httpAPI) error { _, err := a.Version(ctx); return err }, method: "GET", path: "/api2/json/version"}, "NodeStatus": {call: func(a *httpAPI) error { _, err := a.NodeStatus(ctx, "pv1-1"); return err }, method: "GET", path: "/api2/json/nodes/pv1-1/status"}, "StorageStatus": {call: func(a *httpAPI) error { _, err := a.StorageStatus(ctx, "pv1-1", "proxy-seeds"); return err }, method: "GET", path: "/api2/json/nodes/pv1-1/storage/proxy-seeds/status"}, "ClusterResources": {call: func(a *httpAPI) error { _, err := a.ClusterResources(ctx); return err }, method: "GET", path: "/api2/json/cluster/resources", query: url.Values{"type": {"vm"}}}, "VMConfig": {call: func(a *httpAPI) error { _, err := a.VMConfig(ctx, "pv1-1", 150042); return err }, method: "GET", path: "/api2/json/nodes/pv1-1/qemu/150042/config"}, "SetVMConfig": {call: func(a *httpAPI) error { return a.SetVMConfig(ctx, "pv1-1", 150042, map[string]string{"cores": "1", "ide2": "proxy-seeds:iso/proxy-x-seed.iso,media=cdrom"}) }, method: "PUT", path: "/api2/json/nodes/pv1-1/qemu/150042/config", form: url.Values{"cores": {"1"}, "ide2": {"proxy-seeds:iso/proxy-x-seed.iso,media=cdrom"}}}, "Clone": {call: func(a *httpAPI) error { _, err := a.Clone(ctx, "pv1-1", 9001, cloneParams{NewID: 150042, Name: "proxy-x", Description: "a=1\nb=2", Storage: "local-lvm", Target: "pv1-2"}) return err }, method: "POST", path: "/api2/json/nodes/pv1-1/qemu/9001/clone", form: url.Values{"newid": {"150042"}, "name": {"proxy-x"}, "description": {"a=1\nb=2"}, "full": {"1"}, "storage": {"local-lvm"}, "target": {"pv1-2"}}}, "Resize": {call: func(a *httpAPI) error { _, err := a.Resize(ctx, "pv1-1", 150042, "scsi0", "8G"); return err }, method: "PUT", path: "/api2/json/nodes/pv1-1/qemu/150042/resize", form: url.Values{"disk": {"scsi0"}, "size": {"8G"}}}, "Start": {call: func(a *httpAPI) error { _, err := a.Start(ctx, "pv1-1", 150042); return err }, method: "POST", path: "/api2/json/nodes/pv1-1/qemu/150042/status/start", form: url.Values{}}, "Stop": {call: func(a *httpAPI) error { _, err := a.Stop(ctx, "pv1-1", 150042); return err }, method: "POST", path: "/api2/json/nodes/pv1-1/qemu/150042/status/stop", form: url.Values{}}, "VMStatus": {call: func(a *httpAPI) error { _, err := a.VMStatus(ctx, "pv1-1", 150042); return err }, method: "GET", path: "/api2/json/nodes/pv1-1/qemu/150042/status/current"}, "Destroy": {call: func(a *httpAPI) error { _, err := a.Destroy(ctx, "pv1-1", 150042); return err }, method: "DELETE", path: "/api2/json/nodes/pv1-1/qemu/150042", query: url.Values{"purge": {"1"}, "destroy-unreferenced-disks": {"1"}}}, "TaskStatus": {call: func(a *httpAPI) error { _, err := a.TaskStatus(ctx, "pv1-1", upid); return err }, method: "GET", path: "/api2/json/nodes/pv1-1/tasks/" + upid + "/status"}, "AgentNetworkInterfaces": {call: func(a *httpAPI) error { _, err := a.AgentNetworkInterfaces(ctx, "pv1-1", 150042); return err }, method: "GET", path: "/api2/json/nodes/pv1-1/qemu/150042/agent/network-get-interfaces"}, "UploadISO": {call: func(a *httpAPI) error { _, err := a.UploadISO(ctx, "pv1-1", "proxy-seeds", "proxy-x-seed.iso", strings.NewReader("iso"), 3) return err }, method: "POST", path: "/api2/json/nodes/pv1-1/storage/proxy-seeds/upload"}, // multipart, see TestClient_uploadStreamsMultipart "DeleteVolume": {call: func(a *httpAPI) error { _, err := a.DeleteVolume(ctx, "pv1-1", "proxy-seeds", "proxy-seeds:iso/proxy-x-seed.iso") return err }, method: "DELETE", path: "/api2/json/nodes/pv1-1/storage/proxy-seeds/content/proxy-seeds:iso/proxy-x-seed.iso"}, } for m := range reflect.TypeFor[api]().Methods() { if _, ok := tests[m.Name]; !ok { t.Errorf("no row for seam method %s", m.Name) } } for name, tc := range tests { t.Run(name, func(t *testing.T) { t.Parallel() seen := make(chan seenRequest, 1) fx := recordingServer(t, seen) if err := tc.call(fx.api(t)); err != nil { t.Fatalf("call error = %v", err) } got := <-seen if got.method != tc.method || got.path != tc.path { t.Errorf("request = %s %s, want %s %s", got.method, got.path, tc.method, tc.path) } if len(got.query) != 0 || len(tc.query) != 0 { if !reflect.DeepEqual(got.query, tc.query) { t.Errorf("query = %v, want %v", got.query, tc.query) } } if tc.form == nil { if got.form != nil && len(got.form) != 0 { t.Errorf("form = %v, want none", got.form) } return } if got.contentType != "application/x-www-form-urlencoded" { t.Errorf("content type = %q, want a urlencoded form", got.contentType) } for k, want := range tc.form { if !reflect.DeepEqual(got.form[k], want) { t.Errorf("form %s = %q, want %q", k, got.form[k], want) } } if len(got.form) != len(tc.form) { t.Errorf("form = %v, want exactly %v", got.form, tc.form) } }) } } func TestClient_cloneOmitsEmptyStorageAndTarget(t *testing.T) { t.Parallel() seen := make(chan seenRequest, 1) fx := recordingServer(t, seen) if _, err := fx.api(t).Clone(context.Background(), "pv1-1", 9001, cloneParams{NewID: 150042, Name: "proxy-x"}); err != nil { t.Fatalf("Clone error = %v", err) } got := <-seen if _, ok := got.form["storage"]; ok { t.Errorf("form = %v, want no storage", got.form) } if _, ok := got.form["target"]; ok { t.Errorf("form = %v, want no target", got.form) } } // TestClient_destroyQueryFlags checks that destroy leaves nothing behind: // the purge and unreferenced-disk flags travel in the query of the DELETE // (spec entry 11). func TestClient_destroyQueryFlags(t *testing.T) { t.Parallel() seen := make(chan seenRequest, 1) fx := recordingServer(t, seen) if _, err := fx.api(t).Destroy(context.Background(), "pv1-1", 150042); err != nil { t.Fatalf("Destroy error = %v", err) } got := <-seen if got.method != http.MethodDelete || got.query.Get("purge") != "1" || got.query.Get("destroy-unreferenced-disks") != "1" { t.Errorf("request = %s %s?%s, want DELETE with purge=1 and destroy-unreferenced-disks=1", got.method, got.path, got.query.Encode()) } } // TestClient_volIDSlashIsEscaped sends the volid's "/" as %2F, the way // Phase 0 deleted volumes, so the router sees one path segment. func TestClient_volIDSlashIsEscaped(t *testing.T) { t.Parallel() seen := make(chan seenRequest, 1) fx := recordingServer(t, seen) if _, err := fx.api(t).DeleteVolume(context.Background(), "pv1-1", "proxy-seeds", "proxy-seeds:iso/proxy-x-seed.iso"); err != nil { t.Fatalf("DeleteVolume error = %v", err) } if got := <-seen; !strings.HasSuffix(got.escapedPath, "/content/proxy-seeds:iso%2Fproxy-x-seed.iso") { t.Errorf("escaped path = %q, want the volid as one segment with %%2F", got.escapedPath) } } func TestClient_http1Only(t *testing.T) { t.Parallel() protos := make(chan int, 2) srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { protos <- r.ProtoMajor serveJSON(w, readCapture(t, "version").Body) })) srv.EnableHTTP2 = true srv.StartTLS() t.Cleanup(srv.Close) resp, err := srv.Client().Get(srv.URL) if err != nil { t.Fatalf("sanity request error = %v", err) } _ = resp.Body.Close() if p := <-protos; p != 2 { t.Fatalf("sanity: server spoke HTTP/%d to an h2 client, want 2", p) } if _, err := fixtureFor(t, srv).api(t).Version(context.Background()); err != nil { t.Fatalf("Version error = %v", err) } if p := <-protos; p != 1 { t.Errorf("client spoke HTTP/%d, want HTTP/1.1 (F18)", p) } } func TestClient_requestTimeoutIsTransient(t *testing.T) { t.Parallel() fx := newClientFixture(t, func(_ http.ResponseWriter, r *http.Request) { select { case <-r.Context().Done(): case <-time.After(5 * time.Second): } }) a := fx.api(t) a.timeout = 100 * time.Millisecond start := time.Now() _, err := a.Version(context.Background()) if elapsed := time.Since(start); elapsed > 2*time.Second { t.Errorf("Version took %v, want the 100ms request timeout to cut it", elapsed) } var ne net.Error if !errors.As(err, &ne) || !ne.Timeout() { t.Errorf("error = %v, want a timeout", err) } assertClass(t, err, provider.ErrTransient) } // TestClient_cancelledContextReturnsPromptly also checks that the // cancellation touches only that request: the next call on the same client // succeeds. func TestClient_cancelledContextReturnsPromptly(t *testing.T) { t.Parallel() fx := newClientFixture(t, func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/status") { select { case <-r.Context().Done(): case <-time.After(5 * time.Second): } return } serveJSON(w, readCapture(t, "version").Body) }) a := fx.api(t) ctx, cancel := context.WithCancel(context.Background()) time.AfterFunc(50*time.Millisecond, cancel) start := time.Now() _, err := a.NodeStatus(ctx, "pv1-1") if elapsed := time.Since(start); elapsed > time.Second { t.Errorf("NodeStatus took %v after cancel, want prompt", elapsed) } if !errors.Is(err, context.Canceled) { t.Errorf("error = %v, want context.Canceled", err) } assertClass(t, err, provider.ErrTransient) if _, err := a.Version(context.Background()); err != nil { t.Errorf("next call error = %v, want the client unaffected", err) } } func tracedFixture(t *testing.T) (*clientFixture, *tracetest.SpanRecorder, *sdktrace.TracerProvider, func() []string) { t.Helper() var ( mu sync.Mutex traceparent []string ) fx := newClientFixture(t, func(w http.ResponseWriter, r *http.Request) { mu.Lock() traceparent = append(traceparent, r.Header.Get("Traceparent")) mu.Unlock() serveJSON(w, readCapture(t, "version").Body) }) sr := tracetest.NewSpanRecorder() tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr)) t.Cleanup(func() { _ = tp.Shutdown(context.Background()) }) sent := func() []string { mu.Lock() defer mu.Unlock() return append([]string(nil), traceparent...) } return fx, sr, tp, sent } func TestClient_childSpanUnderParent(t *testing.T) { t.Parallel() fx, sr, tp, traceparent := tracedFixture(t) a := fx.api(t, tracing.WithTracerProvider(tp)) ctx, parent := tp.Tracer("test").Start(context.Background(), "provider.get") if _, err := a.Version(ctx); err != nil { t.Fatalf("Version error = %v", err) } parent.End() var child sdktrace.ReadOnlySpan for _, s := range sr.Ended() { if s.Name() != "provider.get" { child = s } } if child == nil { t.Fatalf("no HTTP span recorded; spans: %d", len(sr.Ended())) } if child.Parent().SpanID() != parent.SpanContext().SpanID() || child.SpanContext().TraceID() != parent.SpanContext().TraceID() { t.Errorf("HTTP span %q is not a child of provider.get", child.Name()) } secret := testToken[strings.Index(testToken, "=")+1:] for _, kv := range child.Attributes() { if strings.Contains(kv.Value.Emit(), secret) { t.Errorf("span attribute %s carries the token", kv.Key) } } if sent := traceparent(); len(sent) != 1 || !strings.Contains(sent[0], child.SpanContext().TraceID().String()) { t.Errorf("traceparent sent = %q, want the trace id", sent) } } func TestClient_noSpanWithoutParent(t *testing.T) { t.Parallel() fx, sr, tp, traceparent := tracedFixture(t) if _, err := fx.api(t, tracing.WithTracerProvider(tp)).Version(context.Background()); err != nil { t.Fatalf("Version error = %v", err) } if n := len(sr.Ended()); n != 0 { t.Errorf("got %d spans without a parent, want none", n) } if sent := traceparent(); len(sent) != 1 || sent[0] != "" { t.Errorf("traceparent sent = %q, want none", sent) } } // TestClient_tokenNeverInErrorsOrLogs has the server echo the secret back // in every place an error's text comes from; neither the error nor the // decorator's V(2) lines may carry it. func TestClient_tokenNeverInErrorsOrLogs(t *testing.T) { t.Parallel() const secret = "SENTINEL-SECRET-9d41" token := "proxy-operator@pve!operator=" + secret tests := []struct { name string resp func(auth string) capture }{ {name: "401 body echoes the header", resp: func(auth string) capture { return capture{Status: 401, Reason: "Authentication failed!", Body: `{"data":null,"message":"bad credentials: ` + auth + `\n"}`} }}, {name: "401 reason echoes the header", resp: func(auth string) capture { return capture{Status: 401, Reason: "bad credentials " + auth} }}, {name: "400 errors echo the header", resp: func(auth string) capture { return capture{Status: 400, Reason: "Parameter verification failed.", Body: `{"data":null,"message":"Parameter verification failed.\n","errors":{"token":"` + auth + `"}}`} }}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() fx := newClientFixture(t, func(w http.ResponseWriter, r *http.Request) { serveCapture(t, w, tc.resp(r.Header.Get("Authorization"))) }) writeFile(t, fx.tokenFile, token) logs := &logCapture{} ctx := logs.context(2) _, err := observe(fx.api(t), "proxmox-home").Version(ctx) var ae *apiError if !errors.As(err, &ae) { t.Fatalf("error = %v, want an apiError", err) } if !strings.Contains(err.Error(), "") { t.Errorf("error = %v, want the echoed secret redacted", err) } for _, s := range append([]string{err.Error(), fmt.Sprintf("%+v", err), fmt.Sprintf("%#v", ae)}, logs.all()...) { if strings.Contains(s, secret) { t.Errorf("secret leaked: %s", s) } } if len(logs.all()) != 1 { t.Errorf("got %d log lines, want the one call line", len(logs.all())) } }) } } func TestClient_caFileTrustsPrivateCA(t *testing.T) { t.Parallel() fx := newClientFixture(t, func(w http.ResponseWriter, _ *http.Request) { serveJSON(w, readCapture(t, "version").Body) }) if _, err := fx.api(t).Version(context.Background()); err != nil { t.Errorf("with the CA file: error = %v, want nil", err) } fx.cfg.CAFile = "" _, err := fx.api(t).Version(context.Background()) if !isTLSVerification(err) { t.Errorf("with system roots: error = %v, want a verification failure", err) } } // TestClient_caFileLoadedLazily covers spec entry 14: a missing CA file is // Transient and retried until it lands; one without a certificate is // Permanent. func TestClient_caFileLoadedLazily(t *testing.T) { t.Parallel() fx := newClientFixture(t, func(w http.ResponseWriter, _ *http.Request) { serveJSON(w, readCapture(t, "version").Body) }) pemBytes, err := os.ReadFile(fx.caFile) if err != nil { t.Fatal(err) } fx.cfg.CAFile = filepath.Join(t.TempDir(), "ca.pem") a := fx.api(t) if _, err := a.Version(context.Background()); !errors.Is(err, provider.ErrTransient) { t.Errorf("missing CA file: error = %v, want ErrTransient", err) } writeFile(t, fx.cfg.CAFile, "not a certificate") if _, err := a.Version(context.Background()); !errors.Is(err, provider.ErrPermanent) { t.Errorf("CA file without a certificate: error = %v, want ErrPermanent", err) } writeFile(t, fx.cfg.CAFile, string(pemBytes)) if _, err := a.Version(context.Background()); err != nil { t.Errorf("CA file landed: error = %v, want nil", err) } } func TestClient_x509FailureIsPermanent(t *testing.T) { t.Parallel() tests := []struct { name string mutate func(fx *clientFixture) }{ {name: "unknown authority", mutate: func(fx *clientFixture) { fx.cfg.CAFile = "" }}, {name: "host not in the certificate", mutate: func(fx *clientFixture) { fx.cfg.URL = strings.Replace(fx.srv.URL, "127.0.0.1", "localhost", 1) }}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() var requests atomic.Int32 fx := newClientFixture(t, func(http.ResponseWriter, *http.Request) { requests.Add(1) }) tc.mutate(fx) _, err := fx.api(t).Version(context.Background()) if !isTLSVerification(err) { t.Errorf("error = %v, want a verification failure", err) } assertClass(t, err, provider.ErrPermanent) if n := requests.Load(); n != 0 { t.Errorf("server saw %d requests, want none past the handshake", n) } }) } } func TestClient_insecureSkipsVerification(t *testing.T) { t.Parallel() fx := newClientFixture(t, func(w http.ResponseWriter, _ *http.Request) { serveJSON(w, readCapture(t, "version").Body) }) fx.cfg.CAFile = "" fx.cfg.InsecureSkipTLSVerify = true if _, err := fx.api(t).Version(context.Background()); err != nil { t.Errorf("error = %v, want nil with verification off", err) } } // gatedReader hands out first, then waits for release before handing out // rest. A client that read the whole body before sending would never get // past first. type gatedReader struct { first, rest []byte release <-chan struct{} waited bool } func (g *gatedReader) Read(p []byte) (int, error) { if len(g.first) > 0 { n := copy(p, g.first) g.first = g.first[n:] return n, nil } if !g.waited { select { case <-g.release: case <-time.After(5 * time.Second): return 0, errors.New("the server saw nothing of the file before the reader was drained: the body is not streamed") } g.waited = true } if len(g.rest) == 0 { return 0, io.EOF } n := copy(p, g.rest) g.rest = g.rest[n:] return n, nil } func TestClient_uploadStreamsMultipart(t *testing.T) { t.Parallel() first := bytes.Repeat([]byte("a"), 256<<10) rest := bytes.Repeat([]byte("b"), 256<<10) sawFirst := make(chan struct{}) type upload struct { contentLength int64 chunked bool content, name string fileName, bytes string } got := make(chan upload, 1) fx := newClientFixture(t, func(w http.ResponseWriter, r *http.Request) { u := upload{contentLength: r.ContentLength, chunked: len(r.TransferEncoding) > 0} mr, err := r.MultipartReader() if err != nil { t.Errorf("multipart reader: %v", err) return } for { part, err := mr.NextPart() if err == io.EOF { break } if err != nil { t.Errorf("next part: %v", err) return } switch part.FormName() { case "content": b, _ := io.ReadAll(part) u.content = string(b) case "filename": u.name, u.fileName = part.FormName(), part.FileName() var buf bytes.Buffer if _, err := io.CopyN(&buf, part, 1); err != nil { t.Errorf("reading the file part: %v", err) return } close(sawFirst) _, _ = io.Copy(&buf, part) u.bytes = buf.String() } } got <- u serveJSON(w, readCapture(t, "upload_response").Body) }) body := &gatedReader{first: first, rest: rest, release: sawFirst} size := int64(len(first) + len(rest)) upid, err := fx.api(t).UploadISO(context.Background(), "pv1-1", "proxy-seeds", "proxy-x-seed.iso", body, size) if err != nil { t.Fatalf("UploadISO error = %v", err) } if !strings.Contains(upid, ":imgcopy:") { t.Errorf("upid = %q, want the imgcopy task", upid) } u := <-got if u.chunked || u.contentLength <= size { t.Errorf("Content-Length %d (chunked %v), want an exact length above the file's %d bytes", u.contentLength, u.chunked, size) } if u.content != "iso" || u.name != "filename" || u.fileName != "proxy-x-seed.iso" { t.Errorf("fields content=%q part=%q file=%q, want iso, filename, proxy-x-seed.iso (F12)", u.content, u.name, u.fileName) } if u.bytes != string(first)+string(rest) { t.Errorf("server got %d file bytes, want the %d sent", len(u.bytes), size) } } func TestDecodeConfig_scalarsAsStrings(t *testing.T) { t.Parallel() in := map[string]json.RawMessage{"cores": json.RawMessage(`2`), "memory": json.RawMessage(`"2048"`), "template": json.RawMessage(`1`), "balloon": json.RawMessage(`null`), "description": json.RawMessage(`"a\nb"`)} got, err := decodeConfig(in) if err != nil { t.Fatalf("decodeConfig error = %v", err) } want := vmConfig{"cores": "2", "memory": "2048", "template": "1", "description": "a\nb"} if !reflect.DeepEqual(got, want) { t.Errorf("decodeConfig = %v, want %v", got, want) } } func TestReadToken_trimsTrailingNewline(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "token") writeFile(t, path, " "+testToken+"\r\n") if got, err := readToken(path); err != nil || got != testToken { t.Errorf("readToken = %q, %v; want the token without whitespace", got, err) } }