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
1 change: 1 addition & 0 deletions apps/csm-portal/backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ func main() {
mux.HandleFunc("POST /change-requests", changeRequestHandler.CreateChangeRequest)
mux.HandleFunc("GET /change-requests/{id}", changeRequestHandler.GetChangeRequest)
mux.HandleFunc("GET /change-requests/{id}/approvals", changeRequestHandler.GetChangeRequestApprovals)
mux.HandleFunc("POST /change-requests/{id}/approvals/decision", changeRequestHandler.DecideChangeRequestApproval)
mux.HandleFunc("PATCH /change-requests/{id}", changeRequestHandler.PatchChangeRequest)
mux.HandleFunc("POST /change-requests/search", changeRequestHandler.SearchChangeRequests)
mux.HandleFunc("POST /services/search", itServiceHandler.SearchITServices)
Expand Down
7 changes: 7 additions & 0 deletions apps/csm-portal/backend/internal/entity/entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,13 @@ func (c *Client) GetChangeRequestApprovals(ctx context.Context, id string) ([]by
return c.do(ctx, http.MethodGet, fmt.Sprintf("/change-requests/%s/approvals", url.PathEscape(id)), nil)
}

// DecideChangeRequestApproval calls POST /change-requests/{id}/approvals/decision on the
// entity service to submit the caller's decision on their own pending approval.
// Response is returned as raw JSON; typed response structs are deferred.
func (c *Client) DecideChangeRequestApproval(ctx context.Context, id string, body []byte) ([]byte, error) {
return c.do(ctx, http.MethodPost, fmt.Sprintf("/change-requests/%s/approvals/decision", url.PathEscape(id)), body)
}

// SearchTimeCards calls POST /time-cards/search on the entity service.
// Response is returned as raw JSON.
func (c *Client) SearchTimeCards(ctx context.Context, body []byte) ([]byte, error) {
Expand Down
59 changes: 59 additions & 0 deletions apps/csm-portal/backend/internal/handler/change_requests.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package handler

import (
"bytes"
"context"
"encoding/json"
"errors"
Expand All @@ -27,13 +28,21 @@ import (
"github.com/wso2-open-operations/cs-tools/apps/csm-portal/backend/internal/middleware"
)

// changeRequestApprovalDecisionPayload is the strict shape accepted by
// DecideChangeRequestApproval: exactly one field, and Decision must be
// "approved" or "rejected".
type changeRequestApprovalDecisionPayload struct {
Decision string `json:"decision"`
}

// entityChangeRequestClient abstracts the entity service change-request operations.
type entityChangeRequestClient interface {
CreateChangeRequest(ctx context.Context, body []byte) ([]byte, error)
SearchChangeRequests(ctx context.Context, body []byte) ([]byte, error)
GetChangeRequest(ctx context.Context, id string) ([]byte, error)
PatchChangeRequest(ctx context.Context, id string, body []byte) ([]byte, error)
GetChangeRequestApprovals(ctx context.Context, id string) ([]byte, error)
DecideChangeRequestApproval(ctx context.Context, id string, body []byte) ([]byte, error)
}

// ChangeRequestHandler handles HTTP requests for change-request operations.
Expand Down Expand Up @@ -170,6 +179,56 @@ func (h *ChangeRequestHandler) GetChangeRequestApprovals(w http.ResponseWriter,
writeJSON(w, http.StatusOK, result)
}

// DecideChangeRequestApproval handles POST /change-requests/{id}/approvals/decision. Any user
// with access to the change request may attempt a decision; ServiceNow itself enforces that
// only the caller's own pending approval can be acted on, so this is not a bypass-only endpoint.
func (h *ChangeRequestHandler) DecideChangeRequestApproval(w http.ResponseWriter, r *http.Request) {
user := middleware.UserInfoFromContext(r.Context())
if user == nil {
writeError(w, http.StatusUnauthorized, ErrMsgUnauthorized)
return
}

id := r.PathValue("id")
if id == "" || !uuidRe.MatchString(id) {
writeError(w, http.StatusBadRequest, ErrMsgInvalidUUID)
return
}

r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes)
body, err := io.ReadAll(r.Body)
if err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
writeError(w, http.StatusRequestEntityTooLarge, ErrMsgTooLarge)
return
}
writeError(w, http.StatusBadRequest, errMsgReadBody)
return
}

var payload changeRequestApprovalDecisionPayload
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&payload); err != nil || decoder.More() {
writeError(w, http.StatusBadRequest, ErrMsgBadRequest)
return
}
if payload.Decision != "approved" && payload.Decision != "rejected" {
writeError(w, http.StatusBadRequest, ErrMsgBadRequest)
return
}

result, err := h.entity.DecideChangeRequestApproval(r.Context(), id, body)
if err != nil {
slog.ErrorContext(r.Context(), "entity DecideChangeRequestApproval failed", "userID", user.UserID, "id", id, "err", err)
mapUpstreamError(w, err, "Failed to submit change request approval decision.")
return
}

writeJSON(w, http.StatusOK, result)
}

// SearchChangeRequests handles POST /change-requests/search.
func (h *ChangeRequestHandler) SearchChangeRequests(w http.ResponseWriter, r *http.Request) {
user := middleware.UserInfoFromContext(r.Context())
Expand Down
153 changes: 153 additions & 0 deletions apps/csm-portal/backend/internal/handler/change_requests_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,159 @@ func TestGetChangeRequestApprovals(t *testing.T) {
})
}

func TestDecideChangeRequestApproval(t *testing.T) {
t.Run("requires authenticated user", func(t *testing.T) {
h := NewChangeRequestHandler(&mockEntityChangeRequestClient{})
r := httptest.NewRequest(http.MethodPost, "/change-requests/"+testCRID+"/approvals/decision", strings.NewReader(`{"decision":"approved"}`))
r.SetPathValue("id", testCRID)
w := httptest.NewRecorder()
h.DecideChangeRequestApproval(w, r)
assertStatus(t, w, http.StatusUnauthorized)
assertErrorMessage(t, w, ErrMsgUnauthorized)
assertContentType(t, w, "application/json")
})

t.Run("rejects malformed UUID", func(t *testing.T) {
h := NewChangeRequestHandler(&mockEntityChangeRequestClient{})
r := withUser(httptest.NewRequest(http.MethodPost, "/change-requests/not-a-uuid/approvals/decision", strings.NewReader(`{"decision":"approved"}`)))
r.SetPathValue("id", "not-a-uuid")
w := httptest.NewRecorder()
h.DecideChangeRequestApproval(w, r)
assertStatus(t, w, http.StatusBadRequest)
assertErrorMessage(t, w, ErrMsgInvalidUUID)
assertContentType(t, w, "application/json")
})

t.Run("rejects empty id", func(t *testing.T) {
h := NewChangeRequestHandler(&mockEntityChangeRequestClient{})
r := withUser(httptest.NewRequest(http.MethodPost, "/change-requests//approvals/decision", strings.NewReader(`{"decision":"approved"}`)))
w := httptest.NewRecorder()
h.DecideChangeRequestApproval(w, r)
assertStatus(t, w, http.StatusBadRequest)
assertErrorMessage(t, w, ErrMsgInvalidUUID)
assertContentType(t, w, "application/json")
})

t.Run("rejects body exceeding 1 MiB", func(t *testing.T) {
h := NewChangeRequestHandler(&mockEntityChangeRequestClient{})
r := withUser(httptest.NewRequest(http.MethodPost, "/change-requests/"+testCRID+"/approvals/decision", strings.NewReader(strings.Repeat("x", maxRequestBodyBytes+1))))
r.SetPathValue("id", testCRID)
w := httptest.NewRecorder()
h.DecideChangeRequestApproval(w, r)
assertStatus(t, w, http.StatusRequestEntityTooLarge)
assertErrorMessage(t, w, ErrMsgTooLarge)
assertContentType(t, w, "application/json")
})

t.Run("rejects invalid JSON body", func(t *testing.T) {
h := NewChangeRequestHandler(&mockEntityChangeRequestClient{})
r := withUser(httptest.NewRequest(http.MethodPost, "/change-requests/"+testCRID+"/approvals/decision", strings.NewReader(`not-json`)))
r.SetPathValue("id", testCRID)
w := httptest.NewRecorder()
h.DecideChangeRequestApproval(w, r)
assertStatus(t, w, http.StatusBadRequest)
assertErrorMessage(t, w, ErrMsgBadRequest)
assertContentType(t, w, "application/json")
})

t.Run("rejects unknown fields", func(t *testing.T) {
h := NewChangeRequestHandler(&mockEntityChangeRequestClient{})
r := withUser(httptest.NewRequest(http.MethodPost, "/change-requests/"+testCRID+"/approvals/decision", strings.NewReader(`{"decision":"approved","comment":"lgtm"}`)))
r.SetPathValue("id", testCRID)
w := httptest.NewRecorder()
h.DecideChangeRequestApproval(w, r)
assertStatus(t, w, http.StatusBadRequest)
assertErrorMessage(t, w, ErrMsgBadRequest)
assertContentType(t, w, "application/json")
})

t.Run("rejects trailing data after the JSON value", func(t *testing.T) {
h := NewChangeRequestHandler(&mockEntityChangeRequestClient{})
r := withUser(httptest.NewRequest(http.MethodPost, "/change-requests/"+testCRID+"/approvals/decision", strings.NewReader(`{"decision":"approved"}{"decision":"rejected"}`)))
r.SetPathValue("id", testCRID)
w := httptest.NewRecorder()
h.DecideChangeRequestApproval(w, r)
assertStatus(t, w, http.StatusBadRequest)
assertErrorMessage(t, w, ErrMsgBadRequest)
assertContentType(t, w, "application/json")
})

t.Run("rejects a decision value outside approved/rejected", func(t *testing.T) {
h := NewChangeRequestHandler(&mockEntityChangeRequestClient{})
r := withUser(httptest.NewRequest(http.MethodPost, "/change-requests/"+testCRID+"/approvals/decision", strings.NewReader(`{"decision":"maybe"}`)))
r.SetPathValue("id", testCRID)
w := httptest.NewRecorder()
h.DecideChangeRequestApproval(w, r)
assertStatus(t, w, http.StatusBadRequest)
assertErrorMessage(t, w, ErrMsgBadRequest)
assertContentType(t, w, "application/json")
})

t.Run("rejects an empty decision value", func(t *testing.T) {
h := NewChangeRequestHandler(&mockEntityChangeRequestClient{})
r := withUser(httptest.NewRequest(http.MethodPost, "/change-requests/"+testCRID+"/approvals/decision", strings.NewReader(`{}`)))
r.SetPathValue("id", testCRID)
w := httptest.NewRecorder()
h.DecideChangeRequestApproval(w, r)
assertStatus(t, w, http.StatusBadRequest)
assertErrorMessage(t, w, ErrMsgBadRequest)
assertContentType(t, w, "application/json")
})

t.Run("forwards body to upstream and returns 200 with response", func(t *testing.T) {
const reqPayload = `{"decision":"approved"}`
var capturedID string
var capturedBody []byte
client := &mockEntityChangeRequestClient{
decideChangeRequestApprovalFn: func(_ context.Context, id string, body []byte) ([]byte, error) {
capturedID = id
capturedBody = body
return []byte(`{"id":"11111111-1111-1111-1111-111111111111","state":"approved"}`), nil
},
}
h := NewChangeRequestHandler(client)
r := withUser(httptest.NewRequest(http.MethodPost, "/change-requests/"+testCRID+"/approvals/decision", strings.NewReader(reqPayload)))
r.SetPathValue("id", testCRID)
w := httptest.NewRecorder()
h.DecideChangeRequestApproval(w, r)

assertStatus(t, w, http.StatusOK)
assertContentType(t, w, "application/json")

if capturedID != testCRID {
t.Errorf("upstream received id %q, want %q", capturedID, testCRID)
}
if string(capturedBody) != reqPayload {
t.Errorf("upstream received body %q, want %q", capturedBody, reqPayload)
}
resp := decodeJSON[map[string]any](t, w)
if resp["state"] != "approved" {
t.Errorf("state = %v, want approved", resp["state"])
}
})

t.Run("upstream errors are mapped correctly", func(t *testing.T) {
for _, tc := range upstreamErrors("Failed to submit change request approval decision.") {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
client := &mockEntityChangeRequestClient{
decideChangeRequestApprovalFn: func(_ context.Context, _ string, _ []byte) ([]byte, error) {
return nil, tc.err
},
}
h := NewChangeRequestHandler(client)
r := withUser(httptest.NewRequest(http.MethodPost, "/change-requests/"+testCRID+"/approvals/decision", strings.NewReader(`{"decision":"approved"}`)))
r.SetPathValue("id", testCRID)
w := httptest.NewRecorder()
h.DecideChangeRequestApproval(w, r)
assertStatus(t, w, tc.wantCode)
assertErrorMessage(t, w, tc.wantMsg)
assertContentType(t, w, "application/json")
})
}
})
}

func TestSearchChangeRequests(t *testing.T) {
t.Run("requires authenticated user", func(t *testing.T) {
h := NewChangeRequestHandler(&mockEntityChangeRequestClient{})
Expand Down
18 changes: 13 additions & 5 deletions apps/csm-portal/backend/internal/handler/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -434,11 +434,12 @@ func (m *mockEntityProblemClient) CreateProblem(ctx context.Context, body []byte
// ----- mock entity change request client -----

type mockEntityChangeRequestClient struct {
createChangeRequestFn func(ctx context.Context, body []byte) ([]byte, error)
searchChangeRequestsFn func(ctx context.Context, body []byte) ([]byte, error)
getChangeRequestFn func(ctx context.Context, id string) ([]byte, error)
patchChangeRequestFn func(ctx context.Context, id string, body []byte) ([]byte, error)
getChangeRequestApprovalsFn func(ctx context.Context, id string) ([]byte, error)
createChangeRequestFn func(ctx context.Context, body []byte) ([]byte, error)
searchChangeRequestsFn func(ctx context.Context, body []byte) ([]byte, error)
getChangeRequestFn func(ctx context.Context, id string) ([]byte, error)
patchChangeRequestFn func(ctx context.Context, id string, body []byte) ([]byte, error)
getChangeRequestApprovalsFn func(ctx context.Context, id string) ([]byte, error)
decideChangeRequestApprovalFn func(ctx context.Context, id string, body []byte) ([]byte, error)
}

func (m *mockEntityChangeRequestClient) CreateChangeRequest(ctx context.Context, body []byte) ([]byte, error) {
Expand Down Expand Up @@ -476,6 +477,13 @@ func (m *mockEntityChangeRequestClient) GetChangeRequestApprovals(ctx context.Co
return []byte(`{"approvals":[]}`), nil
}

func (m *mockEntityChangeRequestClient) DecideChangeRequestApproval(ctx context.Context, id string, body []byte) ([]byte, error) {
if m.decideChangeRequestApprovalFn != nil {
return m.decideChangeRequestApprovalFn(ctx, id, body)
}
return []byte(`{"id":"11111111-1111-1111-1111-111111111111","state":"approved"}`), nil
}

// ----- mock entity IT service client -----

type mockEntityITServiceClient struct {
Expand Down
Loading