Files
egress-proxies-operator/internal/provider/proxmox/check_test.go
T
kacerrandClaude Opus 5.5 3d72507894 proxmox: review fixes, real-host validation record, changelog
Phase 10. Two review passes (go-operator-reviewer, /code-review high)
over the branch, recorded in docs/reviews/2026-09-23-proxmox-provider-review/:
9 findings fixed, 3 accepted as documented limits, 7 rejected with a
reason.

- Delete removes the seed between the stop and the destroy, so a failed
  seed delete leaves the VM for the next Delete instead of leaking the
  ISO; a Permanent refusal on the every-node path fails the call.
- Create skips a probe VMID whose listed name is someone else's without
  waiting on its lock.
- The three answers Phase 0 never captured are fixtures now: a missing
  storage is a 400 (the Checker reports StorageNotFound), a hibernated
  VM is destroyable (Delete destroys ours), a paused VM stops.
- TestProxmox_lifecycle and TestProxmoxCheck_realHost passed on pv1-1
  (PVE 9.2.20); the docs record the run and drop "not yet run".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 10:28:28 +00:00

594 lines
22 KiB
Go

package proxmox
import (
"context"
"crypto/x509"
"errors"
"fmt"
"io"
"maps"
"net"
"net/http"
"net/url"
"path/filepath"
"slices"
"strings"
"sync"
"syscall"
"testing"
"time"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
// checkSecret is the secret half of the Checker's token file; it must never
// reach a verdict or a log line.
const checkSecret = "SENTINEL-token-secret-4c1d"
// newCheckFixture is an observed create fixture at V(2) with a token file
// whose secret is checkSecret.
func newCheckFixture(t *testing.T) *fixture {
t.Helper()
fx := newCreateFixture(t, 2)
fx.p.api = observe(fx.fake, testProvider)
fx.p.cfg.TokenFile = filepath.Join(t.TempDir(), "token")
writeFile(t, fx.p.cfg.TokenFile, "proxy-operator@pve!operator="+checkSecret+"\n")
return fx
}
// assertNoSecret checks every verdict and every log line for checkSecret.
func assertNoSecret(t *testing.T, fx *fixture, got []provider.TargetStatus) {
t.Helper()
for _, ts := range got {
if strings.Contains(ts.Message, checkSecret) || slices.ContainsFunc(slices.Collect(maps.Values(ts.Details)), func(v string) bool { return strings.Contains(v, checkSecret) }) {
t.Errorf("verdict carries the token secret: %+v", ts)
}
}
for _, l := range fx.logs.all() {
if strings.Contains(l, checkSecret) {
t.Errorf("log line carries the token secret: %s", l)
}
}
}
func TestCheck_oneEntryPerNodeInConfigOrder(t *testing.T) {
t.Parallel()
fx := newCheckFixture(t)
fx.p.cfg.Nodes = []string{"pv1-3", fakeNode, "pv1-2"}
fx.fake.nodes["pv1-3"] = fx.fake.nodes[fakeNode]
got := fx.p.Check(fx.ctx)
if len(got) != len(fx.p.cfg.Nodes) {
t.Fatalf("Check() returned %d targets, want %d", len(got), len(fx.p.cfg.Nodes))
}
for i, node := range fx.p.cfg.Nodes {
ts := got[i]
if ts.Name != node || ts.Address != fx.p.cfg.URL {
t.Errorf("target %d = %s (%s), want %s (%s)", i, ts.Name, ts.Address, node, fx.p.cfg.URL)
}
if wantReady := node != "pv1-2"; ts.Ready != wantReady {
t.Errorf("target %s ready = %v, want %v (%s: %s)", node, ts.Ready, wantReady, ts.Reason, ts.Message)
}
}
if got[2].Reason != provider.ReasonAPIError || !strings.Contains(got[2].Message, "pv1-2") {
t.Errorf("pv1-2 verdict = %s: %s, want APIError naming the node", got[2].Reason, got[2].Message)
}
}
// TestCheck_nodesCheckedConcurrently holds every node's first request
// until all of them have arrived; checked one at a time, the first would
// give up and fail its verdict.
func TestCheck_nodesCheckedConcurrently(t *testing.T) {
t.Parallel()
fx := newCheckFixture(t)
fx.p.cfg.Nodes = []string{fakeNode, "pv1-2", "pv1-3"}
for _, n := range fx.p.cfg.Nodes[1:] {
fx.fake.nodes[n] = fx.fake.nodes[fakeNode]
}
var (
mu sync.Mutex
arrived int
)
all := make(chan struct{})
fx.fake.hookOn("Version", func() error {
mu.Lock()
if arrived++; arrived == len(fx.p.cfg.Nodes) {
close(all)
}
mu.Unlock()
select {
case <-all:
return nil
case <-time.After(2 * time.Second):
return errors.New("barrier: the nodes were not checked concurrently")
}
})
for _, ts := range fx.p.Check(fx.ctx) {
if !ts.Ready {
t.Errorf("target %s = %s: %s, want Ready", ts.Name, ts.Reason, ts.Message)
}
}
}
func TestCheck_expiredContextReturnsPromptly(t *testing.T) {
t.Parallel()
fx := newCheckFixture(t)
fx.p.cfg.Nodes = []string{fakeNode, "pv1-2"}
fx.fake.blockOn("Version")
ctx, cancel := context.WithTimeout(fx.ctx, 0)
defer cancel()
start := time.Now()
var got []provider.TargetStatus
within(t, 5*time.Second, func() { got = fx.p.Check(ctx) })
if elapsed := time.Since(start); elapsed > time.Second {
t.Errorf("Check() took %v with an expired ctx, want under 1s", elapsed)
}
if len(got) != 2 {
t.Fatalf("Check() returned %d targets, want 2", len(got))
}
for _, ts := range got {
if ts.Ready || ts.Reason != provider.ReasonTimeout || !strings.Contains(ts.Message, context.DeadlineExceeded.Error()) {
t.Errorf("target %s = ready %v reason %s (%s), want Timeout naming the deadline", ts.Name, ts.Ready, ts.Reason, ts.Message)
}
}
}
func TestCheck_reasonsTable(t *testing.T) {
t.Parallel()
urlErr := func(err error) error {
return &url.Error{Op: "Get", URL: "https://192.0.2.8:8006/api2/json/version", Err: err}
}
tests := []struct {
name string
prep func(fx *fixture)
timeout time.Duration
cancel bool
wantReady bool
wantReason string
wantMessage string
}{
{name: "ok", wantReady: true, wantReason: provider.ReasonReachable},
{
name: "token file missing",
prep: func(fx *fixture) { fx.p.cfg.TokenFile = filepath.Join(fx.t.TempDir(), "absent") },
wantReason: reasonCredentialsUnavailable,
wantMessage: "does not exist yet",
},
{
name: "token file malformed",
prep: func(fx *fixture) { writeFile(fx.t, fx.p.cfg.TokenFile, checkSecret+"\n") },
wantReason: reasonCredentialsUnavailable,
wantMessage: "is not <user>@<realm>!<tokenid>=<secret>",
},
{
name: "token file gone by the time the client reads it",
prep: func(fx *fixture) {
fx.fake.failWith("NodeStatus", fmt.Errorf("%w: %w: /etc/proxy-operator/proxmox/token does not exist yet", provider.ErrTransient, errCredentials))
},
wantReason: reasonCredentialsUnavailable,
},
{
name: "unknown certificate authority",
prep: func(fx *fixture) { fx.fake.failWith("Version", urlErr(x509.UnknownAuthorityError{})) },
wantReason: reasonCertificateInvalid,
},
{
name: "host not in the certificate",
prep: func(fx *fixture) { fx.fake.failWith("Version", urlErr(x509.HostnameError{Host: "192.168.2.8"})) },
wantReason: reasonCertificateInvalid,
},
{
name: "CA file unusable",
prep: func(fx *fixture) {
fx.fake.failWith("Version", fmt.Errorf("%w: %w: reading CA file: %w", provider.ErrTransient, errCAFile, syscall.ENOENT))
},
wantReason: reasonCertificateInvalid,
},
{
name: "wrong secret",
prep: func(fx *fixture) { fx.fake.failWith("Version", loadAPIError(fx.t, "err_unauthorized")) },
wantReason: provider.ReasonUnauthorized,
},
{
name: "no credentials sent",
prep: func(fx *fixture) { fx.fake.failWith("Version", loadAPIError(fx.t, "err_no_credentials")) },
wantReason: provider.ReasonUnauthorized,
},
{
name: "privilege missing",
prep: func(fx *fixture) { fx.fake.failWith("NodeStatus", loadAPIError(fx.t, "err_forbidden")) },
wantReason: provider.ReasonForbidden,
wantMessage: "Sys.Syslog",
},
{
name: "pveproxy cannot reach the node",
prep: func(fx *fixture) { fx.fake.failWith("NodeStatus", fakeErr(595, "Connection refused")) },
wantReason: provider.ReasonUnreachable,
},
{
name: "pveproxy broken pipe",
prep: func(fx *fixture) { fx.fake.failWith("NodeStatus", fakeErr(596, "Broken pipe")) },
wantReason: provider.ReasonUnreachable,
},
{
name: "connection refused",
prep: func(fx *fixture) {
fx.fake.failWith("Version", urlErr(&net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED}))
},
wantReason: provider.ReasonUnreachable,
},
{
name: "node status 500",
prep: func(fx *fixture) { fx.fake.failWith("NodeStatus", fakeErr(500, "fake: pve-cluster not running")) },
wantReason: provider.ReasonAPIError,
},
{
name: "iso storage missing",
prep: func(fx *fixture) {
fx.fake.mu.Lock()
delete(fx.fake.storages, fakeISOStorage)
fx.fake.mu.Unlock()
},
wantReason: reasonStorageNotFound,
wantMessage: "storage proxy-seeds on node pv1-1: No such storage.",
},
{
// Not captured: a storage Proxmox knows but cannot use here.
name: "iso storage unusable on the node",
prep: func(fx *fixture) {
fx.fake.failWith("StorageStatus", fakeErr(500, "fake: storage 'proxy-seeds' is not online"))
},
wantReason: reasonStorageNotFound,
wantMessage: "storage proxy-seeds on node pv1-1: fake: storage 'proxy-seeds' is not online",
},
{
name: "another bad parameter is not a missing storage",
prep: func(fx *fixture) { fx.fake.failWith("StorageStatus", loadAPIError(fx.t, "err_bad_param")) },
wantReason: provider.ReasonAPIError,
},
{
name: "iso storage inactive",
prep: func(fx *fixture) { setStorage(fx, func(s *storageStatus) { s.Active = 0 }) },
wantReason: reasonStorageNotFound,
wantMessage: "not active",
},
{
name: "iso storage without iso content",
prep: func(fx *fixture) { setStorage(fx, func(s *storageStatus) { s.Content = "images,rootdir,isoish" }) },
wantReason: reasonStorageNotFound,
wantMessage: "does not hold iso content (content: images,rootdir,isoish)",
},
{
name: "iso storage forbidden",
prep: func(fx *fixture) { fx.fake.failWith("StorageStatus", loadAPIError(fx.t, "err_forbidden")) },
wantReason: provider.ReasonForbidden,
},
{
name: "template missing",
prep: func(fx *fixture) {
fx.fake.mu.Lock()
delete(fx.fake.vms, testTemplateVMID)
fx.fake.mu.Unlock()
},
wantReason: reasonTemplateNotFound,
wantMessage: `template "debian-13": VM 9001 does not exist`,
},
{
name: "template VMID holds a plain VM",
prep: func(fx *fixture) { fx.fake.vms[testTemplateVMID].template = false },
wantReason: reasonTemplateNotFound,
wantMessage: `template "debian-13": VM 9001 is not a template`,
},
{
name: "template VMID holds a container",
prep: func(fx *fixture) { fx.fake.vms[testTemplateVMID].kind = "lxc" },
wantReason: reasonTemplateNotFound,
wantMessage: "does not exist",
},
{
name: "resources listing fails",
prep: func(fx *fixture) {
fx.fake.failWith("ClusterResources", fakeErr(500, "fake: cluster filesystem not ready"))
},
wantReason: provider.ReasonAPIError,
},
{
name: "request outlives the deadline",
prep: func(fx *fixture) { fx.fake.blockOn("StorageStatus") },
timeout: 50 * time.Millisecond,
wantReason: provider.ReasonTimeout,
},
{
// The real client's dial sees the round's ctx end and fails as
// a dial error, which checkReason alone would call Unreachable.
name: "dial cut short by the round's deadline",
prep: func(fx *fixture) {
fx.fake.hookOn("Version", func() error {
time.Sleep(200 * time.Millisecond)
return urlErr(&net.OpError{Op: "dial", Net: "tcp", Err: context.DeadlineExceeded})
})
},
timeout: 20 * time.Millisecond,
wantReason: provider.ReasonTimeout,
},
{
name: "ctx cancelled mid check",
prep: func(fx *fixture) { fx.fake.blockOn("ClusterResources") },
cancel: true,
wantReason: provider.ReasonTimeout,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
fx := newCheckFixture(t)
if tc.prep != nil {
tc.prep(fx)
}
ctx := fx.ctx
switch {
case tc.timeout > 0:
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, tc.timeout)
defer cancel()
case tc.cancel:
var cancel context.CancelFunc
ctx, cancel = context.WithCancel(ctx)
time.AfterFunc(50*time.Millisecond, cancel)
}
var got []provider.TargetStatus
within(t, 5*time.Second, func() { got = fx.p.Check(ctx) })
if len(got) != 1 {
t.Fatalf("Check() returned %d targets, want 1", len(got))
}
ts := got[0]
if ts.Ready != tc.wantReady || ts.Reason != tc.wantReason {
t.Errorf("verdict = ready %v reason %s (%s), want ready %v reason %s", ts.Ready, ts.Reason, ts.Message, tc.wantReady, tc.wantReason)
}
if !tc.wantReady && ts.Message == "" {
t.Error("failed check has no message")
}
if tc.wantReady && ts.Message != "" {
t.Errorf("successful check has a message: %q", ts.Message)
}
if tc.wantMessage != "" && !strings.Contains(ts.Message, tc.wantMessage) {
t.Errorf("message = %q, want it to contain %q", ts.Message, tc.wantMessage)
}
if ts.Details["isoStorage"] != fakeISOStorage {
t.Errorf("details = %v, want isoStorage whatever failed", ts.Details)
}
assertNoSecret(t, fx, got)
})
}
}
func setStorage(fx *fixture, fn func(s *storageStatus)) {
fx.fake.mu.Lock()
defer fx.fake.mu.Unlock()
s := fx.fake.storages[fakeISOStorage]
fn(&s)
fx.fake.storages[fakeISOStorage] = s
}
// TestCheck_reasonsOverHTTPClient takes the transport-level reasons from
// the real client, so the errors checkReason sees are the ones net/http and
// crypto/tls produce, and the Ready row replays the Phase 0 captures.
func TestCheck_reasonsOverHTTPClient(t *testing.T) {
t.Parallel()
captures := func(t *testing.T) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api2/json/")
name := map[string]string{
"version": "version",
"nodes/pv1-1/status": "node_status",
"nodes/pv1-1/storage/local/status": "storage_status",
"cluster/resources": "cluster_resources",
}[path]
if name == "" {
t.Errorf("unexpected request %s", path)
http.NotFound(w, r)
return
}
serveJSON(w, readCapture(t, name).Body)
}
}
tests := []struct {
name string
handler func(t *testing.T) http.HandlerFunc
mutate func(t *testing.T, cfx *clientFixture)
wantReason string
}{
{name: "captures", handler: captures, wantReason: provider.ReasonReachable},
{name: "unknown authority", handler: captures, mutate: func(_ *testing.T, cfx *clientFixture) { cfx.cfg.CAFile = "" }, wantReason: reasonCertificateInvalid},
{
name: "host not in the certificate",
handler: captures,
mutate: func(_ *testing.T, cfx *clientFixture) {
cfx.cfg.URL = strings.Replace(cfx.srv.URL, "127.0.0.1", "localhost", 1)
},
wantReason: reasonCertificateInvalid,
},
{
name: "CA file missing",
handler: captures,
mutate: func(t *testing.T, cfx *clientFixture) { cfx.cfg.CAFile = filepath.Join(t.TempDir(), "ca.pem") },
wantReason: reasonCertificateInvalid,
},
{
name: "401 from pveproxy",
handler: func(t *testing.T) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) { serveCapture(t, w, readCapture(t, "err_unauthorized")) }
},
wantReason: provider.ReasonUnauthorized,
},
{
name: "596 from pveproxy",
handler: func(t *testing.T) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
serveCapture(t, w, capture{Status: 596, Reason: "Broken pipe"})
}
},
wantReason: provider.ReasonUnreachable,
},
{
name: "server gone",
handler: captures,
mutate: func(_ *testing.T, cfx *clientFixture) { cfx.srv.Close() },
wantReason: provider.ReasonUnreachable,
},
{
name: "connection cut mid response",
handler: func(t *testing.T) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
conn, _, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Errorf("hijack: %v", err)
return
}
_, _ = io.WriteString(conn, "HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n{\"data\":")
_ = conn.Close()
}
},
wantReason: provider.ReasonUnreachable,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
cfx := newClientFixture(t, tc.handler(t))
if tc.mutate != nil {
tc.mutate(t, cfx)
}
cfg := testConfig()
cfg.ISOStorage = "local"
cfg.URL, cfg.TokenFile, cfg.CAFile = cfx.cfg.URL, cfx.cfg.TokenFile, cfx.cfg.CAFile
p := newProvider(testProvider, cfg, cfx.api(t))
got := p.Check(context.Background())
if len(got) != 1 {
t.Fatalf("Check() returned %d targets, want 1", len(got))
}
if ts := got[0]; ts.Reason != tc.wantReason || ts.Ready != (tc.wantReason == provider.ReasonReachable) {
t.Errorf("verdict = ready %v reason %s (%s), want %s", ts.Ready, ts.Reason, ts.Message, tc.wantReason)
}
})
}
}
func TestCheck_detailsKeys(t *testing.T) {
t.Parallel()
full := map[string]string{
"tokenID": "proxy-operator@pve!operator", "isoStorage": fakeISOStorage, "pveVersion": "9.2.20",
"kernel": "7.0.14-17-pve", "isoStorageAvailGiB": "90.0", "templates": "1/1",
}
tests := []struct {
name string
prep func(fx *fixture)
want []string
}{
{name: "success", want: slices.Sorted(maps.Keys(full))},
{name: "token unusable", prep: func(fx *fixture) { writeFile(fx.t, fx.p.cfg.TokenFile, "junk") }, want: []string{"isoStorage"}},
{name: "node status fails", prep: func(fx *fixture) { fx.fake.failWith("NodeStatus", fakeErr(596, "Broken pipe")) }, want: []string{"isoStorage", "pveVersion", "tokenID"}},
{name: "storage inactive", prep: func(fx *fixture) { setStorage(fx, func(s *storageStatus) { s.Active = 0 }) }, want: []string{"isoStorage", "isoStorageAvailGiB", "kernel", "pveVersion", "tokenID"}},
{name: "template missing", prep: func(fx *fixture) { fx.fake.vms[testTemplateVMID].template = false }, want: slices.Sorted(maps.Keys(full))},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
fx := newCheckFixture(t)
if tc.prep != nil {
tc.prep(fx)
}
got := fx.p.Check(fx.ctx)[0]
if keys := slices.Sorted(maps.Keys(got.Details)); !slices.Equal(keys, tc.want) {
t.Errorf("details keys = %v, want %v", keys, tc.want)
}
for k, v := range got.Details {
if want, ok := full[k]; ok && tc.name == "success" && v != want {
t.Errorf("details[%s] = %q, want %q", k, v, want)
}
if v == "" {
t.Errorf("details[%s] is empty", k)
}
}
})
}
t.Run("templates counts found over configured", func(t *testing.T) {
t.Parallel()
fx := newCheckFixture(t)
fx.p.cfg.Templates["ubuntu"] = provider.ProxmoxTemplate{VMID: 9002}
fx.p.cfg.Templates["debian-again"] = provider.ProxmoxTemplate{VMID: testTemplateVMID}
got := fx.p.Check(fx.ctx)[0]
if got.Details["templates"] != "2/3" || got.Reason != reasonTemplateNotFound || !strings.Contains(got.Message, `template "ubuntu": VM 9002`) {
t.Errorf("verdict = %s %q, details %v; want TemplateNotFound naming ubuntu, templates 2/3", got.Reason, got.Message, got.Details)
}
})
}
func TestCheck_logsVerdictPerNode(t *testing.T) {
t.Parallel()
fx := newCheckFixture(t)
fx.p.cfg.Nodes = []string{fakeNode, "pv1-2"}
fx.p.Check(fx.ctx)
checked := fx.logs.matching(`"msg"="proxmox node checked"`)
if len(checked) != 2 {
t.Fatalf("got %d verdict lines, want 2: %v", len(checked), checked)
}
for _, l := range checked {
switch {
case strings.Contains(l, `"node"="pv1-1"`):
if !strings.Contains(l, `"ready"=true`) || !strings.Contains(l, `"reason"="Reachable"`) || !strings.Contains(l, `"latency"=`) {
t.Errorf("pv1-1 verdict line = %s", l)
}
case strings.Contains(l, `"node"="pv1-2"`):
if !strings.Contains(l, `"ready"=false`) || !strings.Contains(l, `"reason"="APIError"`) {
t.Errorf("pv1-2 verdict line = %s", l)
}
default:
t.Errorf("verdict line without a node: %s", l)
}
if !strings.Contains(l, `"provider"="`+testProvider+`"`) || !strings.Contains(l, `"level"=1`) {
t.Errorf("verdict line lacks the provider or is not V(1): %s", l)
}
}
}
func TestCheckReason_table(t *testing.T) {
t.Parallel()
dial := &url.Error{Op: "Get", URL: "https://192.0.2.8:8006/api2/json/version", Err: &net.OpError{Op: "dial", Net: "tcp", Err: context.DeadlineExceeded}}
tests := []struct {
name string
err error
want string
}{
{name: "storage verdict", err: &checkFailure{reason: reasonStorageNotFound, msg: "x"}, want: reasonStorageNotFound},
{name: "template verdict wrapped", err: fmt.Errorf("check: %w", &checkFailure{reason: reasonTemplateNotFound, msg: "x"}), want: reasonTemplateNotFound},
{name: "token missing", err: fmt.Errorf("%w: %w: /t does not exist yet", provider.ErrTransient, errCredentials), want: reasonCredentialsUnavailable},
{name: "token malformed", err: fmt.Errorf("%w: %w: /t is not a token", provider.ErrPermanent, errCredentials), want: reasonCredentialsUnavailable},
{name: "CA file", err: fmt.Errorf("%w: %w: /ca holds no PEM certificate", provider.ErrPermanent, errCAFile), want: reasonCertificateInvalid},
{name: "unknown authority", err: &url.Error{Err: x509.UnknownAuthorityError{}}, want: reasonCertificateInvalid},
{name: "certificate invalid", err: &url.Error{Err: x509.CertificateInvalidError{Reason: x509.Expired}}, want: reasonCertificateInvalid},
{name: "401", err: loadAPIError(t, "err_unauthorized"), want: provider.ReasonUnauthorized},
{name: "403 wrapped", err: fmt.Errorf("reading: %w", loadAPIError(t, "err_forbidden_volume_delete")), want: provider.ReasonForbidden},
{name: "595", err: fakeErr(595, "Connection refused"), want: provider.ReasonUnreachable},
{name: "596", err: fakeErr(596, "Broken pipe"), want: provider.ReasonUnreachable},
{name: "400", err: loadAPIError(t, "err_bad_param"), want: provider.ReasonAPIError},
{name: "500", err: loadAPIError(t, "err_vm_missing"), want: provider.ReasonAPIError},
{name: "dial ran out of its own budget", err: dial, want: provider.ReasonUnreachable},
{name: "connection reset", err: &url.Error{Err: &net.OpError{Op: "read", Net: "tcp", Err: syscall.ECONNRESET}}, want: provider.ReasonUnreachable},
{name: "request timeout", err: fmt.Errorf("reading: %w", &url.Error{Err: context.DeadlineExceeded}), want: provider.ReasonTimeout},
{name: "cancelled", err: context.Canceled, want: provider.ReasonTimeout},
{name: "server closed mid response", err: &url.Error{Err: io.ErrUnexpectedEOF}, want: provider.ReasonUnreachable},
{name: "eof", err: io.EOF, want: provider.ReasonUnreachable},
{name: "decode failure", err: errors.New("decoding GET version: invalid character '<'"), want: provider.ReasonAPIError},
{name: "nil", err: nil, want: provider.ReasonAPIError},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := checkReason(tc.err); got != tc.want {
t.Errorf("checkReason(%v) = %s, want %s", tc.err, got, tc.want)
}
})
}
}