Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 99 additions & 6 deletions api/create_admission.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,17 +195,47 @@ func (s *server) resolveCreateAdmission(ctx context.Context, principal principal
switch response.Status {
case "", extensionStatusResolved:
case extensionStatusUnresolved:
return newAdmissionError(http.StatusConflict, "preset inputs are unresolved", map[string]any{"error": "preset_create_unresolved"}, errors.New("preset inputs are unresolved"))
return newAdmissionError(
http.StatusConflict,
"preset inputs are unresolved",
presetCreatePublicError(response.Status, body.RequestID, body.PresetID).responseData(),
errors.New("preset inputs are unresolved"),
)
case extensionStatusForbidden:
return newAdmissionError(http.StatusForbidden, "preset create resolution is forbidden", map[string]any{"error": "preset_create_forbidden"}, errors.New("preset create resolution is forbidden"))
return newAdmissionError(
http.StatusForbidden,
"preset create resolution is forbidden",
presetCreatePublicError(response.Status, body.RequestID, body.PresetID).responseData(),
errors.New("preset create resolution is forbidden"),
)
case extensionStatusAmbiguous:
return newAdmissionError(http.StatusConflict, "preset inputs are ambiguous", map[string]any{"error": "preset_create_ambiguous"}, errors.New("preset inputs are ambiguous"))
return newAdmissionError(
http.StatusConflict,
"preset inputs are ambiguous",
presetCreatePublicError(response.Status, body.RequestID, body.PresetID).responseData(),
errors.New("preset inputs are ambiguous"),
)
case extensionStatusInvalid:
return newAdmissionError(http.StatusBadRequest, "preset inputs are invalid", map[string]any{"error": "preset_create_invalid"}, errors.New("preset inputs are invalid"))
return newAdmissionError(
http.StatusBadRequest,
"preset inputs are invalid",
presetCreatePublicError(response.Status, body.RequestID, body.PresetID).responseData(),
errors.New("preset inputs are invalid"),
)
case extensionStatusUnavailable:
return newAdmissionError(http.StatusServiceUnavailable, "preset create resolution is unavailable", map[string]any{"error": "preset_create_unavailable"}, errors.New("preset create resolution is unavailable"))
return newAdmissionError(
http.StatusServiceUnavailable,
"preset create resolution is unavailable",
presetCreatePublicError(response.Status, body.RequestID, body.PresetID).responseData(),
errors.New("preset create resolution is unavailable"),
)
default:
return newAdmissionError(http.StatusServiceUnavailable, "preset create resolution returned an unsupported status", nil, fmt.Errorf("unsupported preset create status %q", response.Status))
return newAdmissionError(
http.StatusServiceUnavailable,
"preset create resolution returned an unsupported status",
presetCreatePublicError(response.Status, body.RequestID, body.PresetID).responseData(),
fmt.Errorf("unsupported preset create status %q", response.Status),
)
}
}
if selectedClass != nil {
Expand All @@ -224,6 +254,69 @@ func (s *server) resolveCreateAdmission(ctx context.Context, principal principal
return nil
}

func presetCreatePublicError(status extensionResolverStatus, requestID, presetID string) publicError {
subject := map[string]string{}
if trimmedPresetID := strings.TrimSpace(presetID); trimmedPresetID != "" {
subject["presetId"] = trimmedPresetID
}
switch status {
case extensionStatusUnresolved:
return createPublicError(
publicErrorCodeIdentityUnresolved,
"This request could not be linked to the required preset inputs yet.",
false,
requestID,
subject,
nil,
)
case extensionStatusForbidden:
return createPublicError(
publicErrorCodePolicyForbidden,
"This request is not allowed to use the preset create resolver.",
false,
requestID,
subject,
nil,
)
case extensionStatusAmbiguous:
return createPublicError(
publicErrorCodeIdentityAmbiguous,
"This request matched more than one possible preset input state.",
false,
requestID,
subject,
nil,
)
case extensionStatusInvalid:
return createPublicError(
publicErrorCodeResolverInvalid,
"This request included invalid preset inputs.",
false,
requestID,
subject,
nil,
)
case extensionStatusUnavailable:
return createPublicError(
publicErrorCodeResolverUnavailable,
"The preset create resolver is temporarily unavailable.",
true,
requestID,
subject,
nil,
)
default:
return createPublicError(
publicErrorCodeInternalError,
"The preset create resolver returned an unsupported result.",
true,
requestID,
subject,
nil,
)
}
}

type presetCreateMutationResult struct {
serviceAccountResolved bool
runtimePolicyResolved bool
Expand Down
66 changes: 66 additions & 0 deletions api/create_admission_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,72 @@ func TestCreateSpritzRejectsPresetInputsWithoutMatchingResolver(t *testing.T) {
}
}

func TestCreateSpritzReturnsStructuredPublicErrorForUnresolvedPresetInputs(t *testing.T) {
s := newCreateSpritzTestServer(t)
resolver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "unresolved",
})
}))
defer resolver.Close()
configurePresetResolverTestServer(s, resolver.URL, "")

e := echo.New()
secured := e.Group("", s.authMiddleware())
secured.POST("/api/spritzes", s.createSpritz)

body := []byte(`{
"name":"zeno-lake",
"presetId":"zeno",
"presetInputs":{"agentId":"ag-123"},
"requestId":"create-unresolved-1",
"spec":{}
}`)
req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
req.Header.Set("X-Spritz-User-Id", "user-1")
rec := httptest.NewRecorder()

e.ServeHTTP(rec, req)

if rec.Code != http.StatusConflict {
t.Fatalf("expected status 409, got %d: %s", rec.Code, rec.Body.String())
}

var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("failed to decode response json: %v", err)
}
if payload["status"] != "fail" {
t.Fatalf("expected jsend fail status, got %#v", payload["status"])
}
data := payload["data"].(map[string]any)
publicError, ok := data["error"].(map[string]any)
if !ok {
t.Fatalf("expected structured public error, got %#v", data["error"])
}
if publicError["code"] != "identity.unresolved" {
t.Fatalf("expected identity.unresolved code, got %#v", publicError["code"])
}
if publicError["operation"] != "spritz.create" {
t.Fatalf("expected spritz.create operation, got %#v", publicError["operation"])
}
if publicError["requestId"] != "create-unresolved-1" {
t.Fatalf("expected requestId create-unresolved-1, got %#v", publicError["requestId"])
}
if publicError["retryable"] != false {
t.Fatalf("expected retryable false, got %#v", publicError["retryable"])
}
subject, ok := publicError["subject"].(map[string]any)
if !ok {
t.Fatalf("expected subject payload, got %#v", publicError["subject"])
}
if subject["presetId"] != "zeno" {
t.Fatalf("expected presetId zeno, got %#v", subject["presetId"])
}
}

func TestCreateSpritzRejectsMissingRequiredResolvedFieldFromInstanceClass(t *testing.T) {
s := newCreateSpritzTestServer(t)
s.presets = presetCatalog{
Expand Down
Loading
Loading