package proxmox import ( "context" "crypto/tls" "crypto/x509" "errors" "fmt" "io" "net" "net/url" "strings" "testing" "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider" ) func TestClassify_table(t *testing.T) { t.Parallel() fixture := func(name string) error { return loadAPIError(t, name) } tests := []struct { name string op string err error want error }{ {name: "nil is transient", op: "get", err: nil, want: provider.ErrTransient}, {name: "missing VM config on get", op: "get", err: fixture("err_vm_missing"), want: provider.ErrNotFound}, {name: "missing VM status on get", op: "get", err: fixture("err_vm_missing_status"), want: provider.ErrNotFound}, {name: "missing VM on delete", op: "delete", err: fixture("err_destroy_missing"), want: provider.ErrNotFound}, {name: "missing VM on list", op: "list", err: fixture("err_vm_missing"), want: provider.ErrNotFound}, {name: "missing VM inside create is transient", op: "create", err: fixture("err_vm_missing"), want: provider.ErrTransient}, {name: "wrapped missing VM keeps its class", op: "get", err: fmt.Errorf("reading config: %w", fixture("err_vm_missing")), want: provider.ErrNotFound}, {name: "locked by a clone", op: "create", err: fixture("err_config_while_clone_locked"), want: provider.ErrTransient}, {name: "VMID taken escaping the allocator", op: "create", err: fixture("err_newid_in_use"), want: provider.ErrTransient}, {name: "agent not running escaping Get", op: "get", err: fixture("err_agent_not_running"), want: provider.ErrTransient}, {name: "agent not configured escaping Get", op: "get", err: fixture("err_agent_vm_stopped"), want: provider.ErrTransient}, {name: "VM not running escaping Get", op: "get", err: fixture("err_agent_vm_stopped_agent_enabled"), want: provider.ErrTransient}, {name: "wrong secret", op: "get", err: fixture("err_unauthorized"), want: provider.ErrPermanent}, {name: "unknown token id", op: "list", err: fixture("err_unauthorized_unknown_token"), want: provider.ErrPermanent}, {name: "no credentials", op: "list", err: fixture("err_no_credentials"), want: provider.ErrPermanent}, {name: "missing privilege", op: "get", err: fixture("err_forbidden"), want: provider.ErrPermanent}, {name: "missing Datastore.Allocate", op: "delete", err: fixture("err_forbidden_volume_delete"), want: provider.ErrPermanent}, {name: "parameter verification", op: "create", err: fixture("err_bad_param"), want: provider.ErrPermanent}, {name: "invalid tag", op: "create", err: fixture("err_tag_invalid"), want: provider.ErrPermanent}, {name: "upload without .iso", op: "create", err: fixture("err_upload_bad_name"), want: provider.ErrPermanent}, {name: "clone onto a storage without images", op: "create", err: fixture("err_clone_bad_storage"), want: provider.ErrPermanent}, {name: "any other 4xx", op: "get", err: &apiError{Status: 404, Message: "Not Found"}, want: provider.ErrPermanent}, {name: "other 500", op: "get", err: &apiError{Status: 500, Message: "got timeout"}, want: provider.ErrTransient}, {name: "storage does not exist is not a missing VM", op: "delete", err: &apiError{Status: 500, Message: "storage 'x' does not exist"}, want: provider.ErrTransient}, {name: "502", op: "get", err: &apiError{Status: 502, Message: "Bad Gateway"}, want: provider.ErrTransient}, {name: "pveproxy 595", op: "get", err: &apiError{Status: 595, Message: "No route to host"}, want: provider.ErrTransient}, {name: "pveproxy 596", op: "list", err: &apiError{Status: 596, Message: "Connection timed out"}, want: provider.ErrTransient}, {name: "unknown authority", op: "get", err: x509.UnknownAuthorityError{}, want: provider.ErrPermanent}, {name: "hostname mismatch", op: "create", err: x509.HostnameError{Certificate: &x509.Certificate{}, Host: "192.168.2.8"}, want: provider.ErrPermanent}, {name: "expired certificate", op: "list", err: x509.CertificateInvalidError{Reason: x509.Expired}, want: provider.ErrPermanent}, {name: "tls verification wrapped in url.Error", op: "get", err: &url.Error{Op: "Get", URL: "https://pve", Err: &tls.CertificateVerificationError{Err: x509.UnknownAuthorityError{}}}, want: provider.ErrPermanent}, {name: "context canceled", op: "get", err: context.Canceled, want: provider.ErrTransient}, {name: "context deadline", op: "get", err: context.DeadlineExceeded, want: provider.ErrTransient}, {name: "net.OpError", op: "get", err: &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("connection refused")}, want: provider.ErrTransient}, {name: "io.EOF", op: "list", err: io.EOF, want: provider.ErrTransient}, {name: "io.ErrUnexpectedEOF", op: "list", err: io.ErrUnexpectedEOF, want: provider.ErrTransient}, {name: "anything else is transient", op: "get", err: errors.New("anything"), want: provider.ErrTransient}, {name: "pre-classified NotFound stays", op: "create", err: fmt.Errorf("x: %w", provider.ErrNotFound), want: provider.ErrNotFound}, {name: "pre-classified Quota stays", op: "create", err: fmt.Errorf("x: %w", provider.ErrQuotaExceeded), want: provider.ErrQuotaExceeded}, {name: "pre-classified Permanent stays", op: "get", err: permanentf("bad %s", "thing"), want: provider.ErrPermanent}, {name: "pre-classified Transient beats a 4xx underneath", op: "get", err: fmt.Errorf("%w: %w", provider.ErrTransient, fixture("err_forbidden")), want: provider.ErrTransient}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := classify(tc.op, tc.err); !errors.Is(got, tc.want) { t.Errorf("classify(%q, %v) = %v, want %v", tc.op, tc.err, got, tc.want) } }) } } // TestAPIError_matchers runs every matcher over every captured error, so a // matcher that also fires on another message fails here. func TestAPIError_matchers(t *testing.T) { t.Parallel() matchers := map[string]func(error) bool{ "isNotExist": isNotExist, "isLocked": isLocked, "isAlreadyExists": isAlreadyExists, "isAgentNotRunning": isAgentNotRunning, "isAgentNotConfigured": isAgentNotConfigured, "isVMNotRunning": isVMNotRunning, } tests := []struct { capture string want string // the one matcher that fires, or "" for none }{ {"err_vm_missing", "isNotExist"}, {"err_vm_missing_status", "isNotExist"}, {"err_destroy_missing", "isNotExist"}, {"err_config_while_clone_locked", "isLocked"}, {"err_newid_in_use", "isAlreadyExists"}, {"err_agent_not_running", "isAgentNotRunning"}, {"err_agent_vm_stopped", "isAgentNotConfigured"}, {"err_agent_vm_stopped_agent_enabled", "isVMNotRunning"}, {"err_unauthorized", ""}, {"err_forbidden", ""}, {"err_bad_param", ""}, {"err_clone_bad_storage", ""}, } for _, tc := range tests { t.Run(tc.capture, func(t *testing.T) { t.Parallel() err := fmt.Errorf("wrapped: %w", loadAPIError(t, tc.capture)) for name, match := range matchers { if got, want := match(err), name == tc.want; got != want { t.Errorf("%s(%v) = %v, want %v", name, err, got, want) } } }) } if isNotExist(errors.New("Configuration file 'x' does not exist")) { t.Error("isNotExist matched an error that is not an apiError") } } func TestNewAPIError_fromCaptures(t *testing.T) { t.Parallel() tests := []struct { capture string wantStatus int wantMessage string wantErrors map[string]string }{ {capture: "err_vm_missing", wantStatus: 500, wantMessage: "Configuration file 'nodes/pv1-1/qemu-server/999999.conf' does not exist"}, {capture: "err_unauthorized", wantStatus: 401, wantMessage: "Authentication failed!"}, {capture: "err_no_credentials", wantStatus: 401, wantMessage: "No ticket"}, {capture: "err_forbidden", wantStatus: 403, wantMessage: "Permission check failed (/nodes/pv1-1, Sys.Syslog)"}, {capture: "err_bad_param", wantStatus: 400, wantMessage: "Parameter verification failed.", wantErrors: map[string]string{"newid": "type check ('integer') failed - got 'abc'"}}, } for _, tc := range tests { t.Run(tc.capture, func(t *testing.T) { t.Parallel() got := loadAPIError(t, tc.capture) if got.Status != tc.wantStatus || got.Message != tc.wantMessage { t.Errorf("newAPIError = {%d %q}, want {%d %q}", got.Status, got.Message, tc.wantStatus, tc.wantMessage) } if len(got.Errors) != len(tc.wantErrors) { t.Fatalf("Errors = %v, want %v", got.Errors, tc.wantErrors) } for k, v := range tc.wantErrors { if got.Errors[k] != v { t.Errorf("Errors[%q] = %q, want %q", k, got.Errors[k], v) } } }) } } func TestNewAPIError_fallbacks(t *testing.T) { t.Parallel() if got := newAPIError(503, "", []byte("")); got.Message != "Service Unavailable" { t.Errorf("no reason and no JSON body: Message = %q, want the status text", got.Message) } if got := newAPIError(500, "reason text", []byte(`{"data":null}`)); got.Message != "reason text" { t.Errorf("body without message: Message = %q, want the reason phrase", got.Message) } } func TestAPIError_errorString(t *testing.T) { t.Parallel() got := loadAPIError(t, "err_bad_param").Error() want := "proxmox API 400: Parameter verification failed. (newid: type check ('integer') failed - got 'abc')" if got != want { t.Errorf("Error() = %q, want %q", got, want) } e := &apiError{Status: 400, Message: "m", Errors: map[string]string{"b": "2", "a": "1"}} if got := e.Error(); !strings.HasSuffix(got, "(a: 1; b: 2)") { t.Errorf("Error() = %q, want field errors sorted by key", got) } } func TestPermanentfAndTransientf(t *testing.T) { t.Parallel() perm := permanentf("node %q missing", "x") if !errors.Is(perm, provider.ErrPermanent) || perm.Error() != `provider: permanent error: node "x" missing` { t.Errorf("permanentf() = %q, want ErrPermanent with the message", perm) } trans := transientf("retry %d", 3) if !errors.Is(trans, provider.ErrTransient) || trans.Error() != "provider: transient error: retry 3" { t.Errorf("transientf() = %q, want ErrTransient with the message", trans) } }