mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
feat(cli): classify every provisioning failure with stable codes
This commit is contained in:
@@ -37,6 +37,16 @@ const (
|
||||
sendLogTimeout = 300 * time.Second
|
||||
)
|
||||
|
||||
// Provisioning-token rejection reasons the API sends in error.metadata.reason
|
||||
// (HTTP 400, code 40003). This list can grow; a value outside it is not an
|
||||
// error, just one cmd/cli does not classify yet.
|
||||
const (
|
||||
ReasonTokenInvalid = "token_invalid"
|
||||
ReasonTokenExpired = "token_expired"
|
||||
ReasonTokenLimitReached = "token_limit_reached"
|
||||
ReasonTokenDisabled = "token_disabled"
|
||||
)
|
||||
|
||||
// ResolverConfig represents Control D resolver data.
|
||||
type ResolverConfig struct {
|
||||
DOH string `json:"doh"`
|
||||
@@ -64,10 +74,41 @@ type utilityResponse struct {
|
||||
} `json:"body"`
|
||||
}
|
||||
|
||||
// errorMetadata carries additive, optional detail on top of Code/Message.
|
||||
// Older API deployments omit it, so it must decode to its zero value rather
|
||||
// than fail the whole response. Its custom UnmarshalJSON gives the same
|
||||
// tolerance to a malformed value: a metadata that is not an object, or a
|
||||
// Reason that is not a string (a number, an object, or null), degrades to
|
||||
// the zero value rather than failing the response that contains it.
|
||||
type errorMetadata struct {
|
||||
// Reason is a machine-readable rejection reason sent on provisioning-token
|
||||
// errors (HTTP 400, code 40003): token_invalid, token_expired,
|
||||
// token_limit_reached, or token_disabled. Empty when absent or malformed;
|
||||
// callers must treat any other value as unknown rather than reject the
|
||||
// response.
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func (m *errorMetadata) UnmarshalJSON(data []byte) error {
|
||||
var raw struct {
|
||||
Reason json.RawMessage `json:"reason"`
|
||||
}
|
||||
// Best-effort: a metadata that is not an object, or a reason that is not
|
||||
// a string (number, object, null), leaves the zero value instead of
|
||||
// failing this decode. Code and Message still classify the failure.
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
*m = errorMetadata{}
|
||||
return nil
|
||||
}
|
||||
_ = json.Unmarshal(raw.Reason, &m.Reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
ErrorField struct {
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
Metadata errorMetadata `json:"metadata"`
|
||||
} `json:"error"`
|
||||
// StatusCode is the HTTP status the API answered with. It is not part of the JSON
|
||||
// body: this type is built for *any* non-200 whose body decodes, so the body alone
|
||||
|
||||
@@ -98,6 +98,116 @@ func TestAPIErrorRecordsHTTPStatus(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestAPIErrorDecodesRejectionReason pins the additive metadata.reason field the
|
||||
// API sends on provisioning-token rejections. cmd/cli maps known reasons to their
|
||||
// own failure codes, and must fall back cleanly when the field is absent or holds
|
||||
// a value this build does not recognize yet.
|
||||
func TestAPIErrorDecodesRejectionReason(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantReason string
|
||||
}{
|
||||
{
|
||||
name: "known reason",
|
||||
body: `{"error":{"message":"invalid token","code":40003,"metadata":{"reason":"token_disabled"}}}`,
|
||||
wantReason: "token_disabled",
|
||||
},
|
||||
{
|
||||
name: "reason absent",
|
||||
body: `{"error":{"message":"invalid token","code":40003}}`,
|
||||
wantReason: "",
|
||||
},
|
||||
{
|
||||
name: "unknown reason value",
|
||||
body: `{"error":{"message":"invalid token","code":40003,"metadata":{"reason":"something_new"}}}`,
|
||||
wantReason: "something_new",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
d := json.NewDecoder(strings.NewReader(tc.body))
|
||||
errResp, err := apiErrorFromResponse(http.StatusBadRequest, d)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected decode error: %v", err)
|
||||
}
|
||||
if errResp.ErrorField.Metadata.Reason != tc.wantReason {
|
||||
t.Errorf("reason = %q, want %q", errResp.ErrorField.Metadata.Reason, tc.wantReason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIErrorToleratesMalformedRejectionReason pins the fix for a decode error
|
||||
// confined to metadata.reason: a reason sent as the wrong JSON type must not
|
||||
// discard the rest of the response. Before this fix, apiErrorFromResponse
|
||||
// returned the raw decode error and nothing else, which cmd/cli's
|
||||
// apiFailureCode cannot recognize as an *ErrorResponse - it falls back to
|
||||
// API_UNREACHABLE (a retryable bootstrap failure) instead of the permanent
|
||||
// rejection the HTTP status and code actually describe.
|
||||
func TestAPIErrorToleratesMalformedRejectionReason(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{name: "reason as a number", body: `{"error":{"message":"invalid token","code":40003,"metadata":{"reason":12345}}}`},
|
||||
{name: "reason as an object", body: `{"error":{"message":"invalid token","code":40003,"metadata":{"reason":{"inner":"value"}}}}`},
|
||||
{name: "reason as null", body: `{"error":{"message":"invalid token","code":40003,"metadata":{"reason":null}}}`},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
d := json.NewDecoder(strings.NewReader(tc.body))
|
||||
errResp, err := apiErrorFromResponse(http.StatusBadRequest, d)
|
||||
if err != nil {
|
||||
t.Fatalf("a malformed reason must not fail the whole decode: %v", err)
|
||||
}
|
||||
if errResp.ErrorField.Message != "invalid token" {
|
||||
t.Errorf("message = %q, want it to survive the malformed reason", errResp.ErrorField.Message)
|
||||
}
|
||||
if errResp.ErrorField.Code != 40003 {
|
||||
t.Errorf("code = %d, want it to survive the malformed reason", errResp.ErrorField.Code)
|
||||
}
|
||||
if errResp.ErrorField.Metadata.Reason != "" {
|
||||
t.Errorf("reason = %q, want empty for a malformed value", errResp.ErrorField.Metadata.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIErrorToleratesMalformedMetadata pins the same tolerance one level
|
||||
// up: a metadata field that is not a JSON object must not discard the rest
|
||||
// of the response. Code and Message still classify the failure, and Reason
|
||||
// stays empty.
|
||||
func TestAPIErrorToleratesMalformedMetadata(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{name: "metadata as a string", body: `{"error":{"message":"invalid token","code":40003,"metadata":"foo"}}`},
|
||||
{name: "metadata as a number", body: `{"error":{"message":"invalid token","code":40003,"metadata":7}}`},
|
||||
{name: "metadata as an array", body: `{"error":{"message":"invalid token","code":40003,"metadata":["reason"]}}`},
|
||||
{name: "metadata as a boolean", body: `{"error":{"message":"invalid token","code":40003,"metadata":true}}`},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
d := json.NewDecoder(strings.NewReader(tc.body))
|
||||
errResp, err := apiErrorFromResponse(http.StatusBadRequest, d)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected decode error: %v", err)
|
||||
}
|
||||
if errResp.ErrorField.Code != 40003 {
|
||||
t.Errorf("code = %d, want 40003", errResp.ErrorField.Code)
|
||||
}
|
||||
if errResp.ErrorField.Message != "invalid token" {
|
||||
t.Errorf("message = %q, want %q", errResp.ErrorField.Message, "invalid token")
|
||||
}
|
||||
if errResp.ErrorField.Metadata.Reason != "" {
|
||||
t.Errorf("reason = %q, want empty for a malformed metadata", errResp.ErrorField.Metadata.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUtilityResponseDecodesDestinationIPs pins the API field that carries the
|
||||
// organization's effective Allowed Destination IP list. The list is enforced as a
|
||||
// set of Firewall Mode exceptions, so a silent decode change - a renamed field, a
|
||||
|
||||
Reference in New Issue
Block a user