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
2 changes: 2 additions & 0 deletions apps/csm-portal/backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ func main() {
mux.HandleFunc("PATCH /change-requests/{id}", changeRequestHandler.PatchChangeRequest)
mux.HandleFunc("POST /change-requests/search", changeRequestHandler.SearchChangeRequests)
mux.HandleFunc("POST /time-cards/search", timeCardHandler.SearchTimeCards)
mux.HandleFunc("POST /time-cards", timeCardHandler.CreateTimeCard)
mux.HandleFunc("PATCH /time-cards/{id}", timeCardHandler.UpdateTimeCard)
mux.HandleFunc("POST /catalogs/search", catalogHandler.SearchCatalogs)
mux.HandleFunc("GET /catalogs/{catalogId}/items/{catalogItemId}/variables", catalogHandler.GetCatalogItemVariables)
mux.HandleFunc("POST /products/vulnerabilities/search", productVulnerabilityHandler.SearchProductVulnerabilities)
Expand Down
13 changes: 13 additions & 0 deletions apps/csm-portal/backend/internal/entity/entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,19 @@ func (c *Client) SearchTimeCards(ctx context.Context, body []byte) ([]byte, erro
return c.do(ctx, http.MethodPost, "/time-cards/search", body)
}

// CreateTimeCard calls POST /time-cards on the entity service.
// Response is returned as raw JSON.
func (c *Client) CreateTimeCard(ctx context.Context, body []byte) ([]byte, error) {
return c.do(ctx, http.MethodPost, "/time-cards", body)
}

// UpdateTimeCard calls PATCH /time-cards/{id} on the entity service. The body may
// carry editable fields, or a state transition ({"state":"approved"} or
// {"state":"rejected","leadComment":"..."}). Response is returned as raw JSON.
func (c *Client) UpdateTimeCard(ctx context.Context, id string, body []byte) ([]byte, error) {
return c.do(ctx, http.MethodPatch, fmt.Sprintf("/time-cards/%s", url.PathEscape(id)), body)
}

// CreateCaseAttachment calls POST /attachments on the entity service.
// Response is returned as raw JSON.
func (c *Client) CreateCaseAttachment(ctx context.Context, body []byte) ([]byte, error) {
Expand Down
41 changes: 35 additions & 6 deletions apps/csm-portal/backend/internal/handler/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -354,15 +354,44 @@ func (m *mockEntityChangeRequestClient) PatchChangeRequest(ctx context.Context,
return []byte(`{"id":"11111111-1111-1111-1111-111111111111","updatedOn":"2026-01-01T00:00:00Z","updatedBy":"user@example.com"}`), nil
}

// ----- mock entity time-card client -----

type mockEntityTimeCardClient struct {
searchTimeCardsFn func(ctx context.Context, body []byte) ([]byte, error)
createTimeCardFn func(ctx context.Context, body []byte) ([]byte, error)
updateTimeCardFn func(ctx context.Context, id string, body []byte) ([]byte, error)
}

func (m *mockEntityTimeCardClient) SearchTimeCards(ctx context.Context, body []byte) ([]byte, error) {
if m.searchTimeCardsFn != nil {
return m.searchTimeCardsFn(ctx, body)
}
return []byte(`{"timeCards":[],"total":0,"limit":20,"offset":0}`), nil
}

func (m *mockEntityTimeCardClient) CreateTimeCard(ctx context.Context, body []byte) ([]byte, error) {
if m.createTimeCardFn != nil {
return m.createTimeCardFn(ctx, body)
}
return []byte(`{"timeCard":{"id":"11111111-1111-1111-1111-111111111111","state":"submitted"}}`), nil
}

func (m *mockEntityTimeCardClient) UpdateTimeCard(ctx context.Context, id string, body []byte) ([]byte, error) {
if m.updateTimeCardFn != nil {
return m.updateTimeCardFn(ctx, id, body)
}
return []byte(`{"timeCard":{"id":"` + id + `","state":"submitted"}}`), nil
}

// ----- mock entity deployment client -----

type mockEntityDeploymentClient struct {
postDeploymentFn func(ctx context.Context, body []byte) ([]byte, error)
searchDeploymentsFn func(ctx context.Context, body []byte) ([]byte, error)
searchDeployedProductsFn func(ctx context.Context, body []byte) ([]byte, error)
patchDeploymentFn func(ctx context.Context, deploymentID string, body []byte) ([]byte, error)
postDeployedProductFn func(ctx context.Context, body []byte) ([]byte, error)
patchDeployedProductFn func(ctx context.Context, deployedProductID string, body []byte) ([]byte, error)
postDeploymentFn func(ctx context.Context, body []byte) ([]byte, error)
searchDeploymentsFn func(ctx context.Context, body []byte) ([]byte, error)
searchDeployedProductsFn func(ctx context.Context, body []byte) ([]byte, error)
patchDeploymentFn func(ctx context.Context, deploymentID string, body []byte) ([]byte, error)
postDeployedProductFn func(ctx context.Context, body []byte) ([]byte, error)
patchDeployedProductFn func(ctx context.Context, deployedProductID string, body []byte) ([]byte, error)
}

func (m *mockEntityDeploymentClient) PostDeployment(ctx context.Context, body []byte) ([]byte, error) {
Expand Down
75 changes: 75 additions & 0 deletions apps/csm-portal/backend/internal/handler/time_cards.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import (
// entityTimeCardClient abstracts the entity service time-card operations.
type entityTimeCardClient interface {
SearchTimeCards(ctx context.Context, body []byte) ([]byte, error)
CreateTimeCard(ctx context.Context, body []byte) ([]byte, error)
UpdateTimeCard(ctx context.Context, id string, body []byte) ([]byte, error)
}

// TimeCardHandler handles HTTP requests for time-card operations.
Expand Down Expand Up @@ -76,3 +78,76 @@ func (h *TimeCardHandler) SearchTimeCards(w http.ResponseWriter, r *http.Request

writeJSON(w, http.StatusOK, result)
}

// readTimeCardBody applies the 1 MiB cap and JSON-validity guard, returning the
// body and true on success; on failure it has already written the error response.
func readTimeCardBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
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 nil, false
}
writeError(w, http.StatusBadRequest, errMsgReadBody)
return nil, false
}
if len(body) > 0 && !json.Valid(body) {
writeError(w, http.StatusBadRequest, ErrMsgBadRequest)
return nil, false
}
return body, true
}

// CreateTimeCard handles POST /time-cards.
func (h *TimeCardHandler) CreateTimeCard(w http.ResponseWriter, r *http.Request) {
user := middleware.UserInfoFromContext(r.Context())
if user == nil {
writeError(w, http.StatusUnauthorized, ErrMsgUnauthorized)
return
}

body, ok := readTimeCardBody(w, r)
if !ok {
return
}

result, err := h.entity.CreateTimeCard(r.Context(), body)
if err != nil {
slog.ErrorContext(r.Context(), "entity CreateTimeCard failed", "userID", user.UserID, "err", err)
mapUpstreamError(w, err, "Failed to create time card.")
return
}

writeJSON(w, http.StatusCreated, result)
}

// UpdateTimeCard handles PATCH /time-cards/{id}.
func (h *TimeCardHandler) UpdateTimeCard(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
}

body, ok := readTimeCardBody(w, r)
if !ok {
return
}

result, err := h.entity.UpdateTimeCard(r.Context(), id, body)
if err != nil {
slog.ErrorContext(r.Context(), "entity UpdateTimeCard failed", "userID", user.UserID, "id", id, "err", err)
mapUpstreamError(w, err, "Failed to update time card.")
return
}

writeJSON(w, http.StatusOK, result)
}
149 changes: 149 additions & 0 deletions apps/csm-portal/backend/internal/handler/time_cards_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com).
//
// WSO2 LLC. licenses this file to you under the Apache License,
// Version 2.0 (the "License"); you may not use this file except
// in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

package handler

import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

const testTCID = "dddddddd-eeee-ffff-0000-111111111111"

func TestCreateTimeCard(t *testing.T) {
t.Run("rejects unauthenticated requests", func(t *testing.T) {
h := NewTimeCardHandler(&mockEntityTimeCardClient{})
r := httptest.NewRequest(http.MethodPost, "/time-cards", strings.NewReader(`{}`))
w := httptest.NewRecorder()
h.CreateTimeCard(w, r)
assertStatus(t, w, http.StatusUnauthorized)
})

t.Run("rejects malformed JSON", func(t *testing.T) {
h := NewTimeCardHandler(&mockEntityTimeCardClient{})
r := withUser(httptest.NewRequest(http.MethodPost, "/time-cards", strings.NewReader(`{bad`)))
w := httptest.NewRecorder()
h.CreateTimeCard(w, r)
assertStatus(t, w, http.StatusBadRequest)
})

t.Run("forwards body and returns 201", func(t *testing.T) {
var captured []byte
client := &mockEntityTimeCardClient{
createTimeCardFn: func(_ context.Context, body []byte) ([]byte, error) {
captured = body
return []byte(`{"timeCard":{"id":"` + testTCID + `","state":"submitted"}}`), nil
},
}
h := NewTimeCardHandler(client)
r := withUser(httptest.NewRequest(http.MethodPost, "/time-cards", strings.NewReader(`{"caseId":"x"}`)))
w := httptest.NewRecorder()
h.CreateTimeCard(w, r)

assertStatus(t, w, http.StatusCreated)
assertContentType(t, w, "application/json")
if string(captured) != `{"caseId":"x"}` {
t.Errorf("upstream received body %q", captured)
}
})

t.Run("upstream errors are mapped correctly", func(t *testing.T) {
for _, tc := range upstreamErrors("Failed to create time card.") {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
client := &mockEntityTimeCardClient{
createTimeCardFn: func(_ context.Context, _ []byte) ([]byte, error) {
return nil, tc.err
},
}
h := NewTimeCardHandler(client)
r := withUser(httptest.NewRequest(http.MethodPost, "/time-cards", strings.NewReader(`{}`)))
w := httptest.NewRecorder()
h.CreateTimeCard(w, r)
assertStatus(t, w, tc.wantCode)
assertErrorMessage(t, w, tc.wantMsg)
})
}
})
}

func TestUpdateTimeCard(t *testing.T) {
t.Run("rejects unauthenticated requests", func(t *testing.T) {
h := NewTimeCardHandler(&mockEntityTimeCardClient{})
r := httptest.NewRequest(http.MethodPatch, "/time-cards/"+testTCID, strings.NewReader(`{}`))
r.SetPathValue("id", testTCID)
w := httptest.NewRecorder()
h.UpdateTimeCard(w, r)
assertStatus(t, w, http.StatusUnauthorized)
})

t.Run("rejects malformed UUID", func(t *testing.T) {
h := NewTimeCardHandler(&mockEntityTimeCardClient{})
r := withUser(httptest.NewRequest(http.MethodPatch, "/time-cards/not-a-uuid", strings.NewReader(`{}`)))
r.SetPathValue("id", "not-a-uuid")
w := httptest.NewRecorder()
h.UpdateTimeCard(w, r)
assertStatus(t, w, http.StatusBadRequest)
})

t.Run("forwards id and body for a state transition and returns 200", func(t *testing.T) {
var capturedID string
var capturedBody []byte
client := &mockEntityTimeCardClient{
updateTimeCardFn: func(_ context.Context, id string, body []byte) ([]byte, error) {
capturedID, capturedBody = id, body
return []byte(`{"timeCard":{"id":"` + id + `","state":"approved"}}`), nil
},
}
h := NewTimeCardHandler(client)
r := withUser(httptest.NewRequest(http.MethodPatch, "/time-cards/"+testTCID, strings.NewReader(`{"state":"approved"}`)))
r.SetPathValue("id", testTCID)
w := httptest.NewRecorder()
h.UpdateTimeCard(w, r)

assertStatus(t, w, http.StatusOK)
assertContentType(t, w, "application/json")
if capturedID != testTCID {
t.Errorf("upstream received id %q, want %q", capturedID, testTCID)
}
if string(capturedBody) != `{"state":"approved"}` {
t.Errorf("upstream received body %q", capturedBody)
}
})

t.Run("upstream errors are mapped correctly", func(t *testing.T) {
for _, tc := range upstreamErrors("Failed to update time card.") {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
client := &mockEntityTimeCardClient{
updateTimeCardFn: func(_ context.Context, _ string, _ []byte) ([]byte, error) {
return nil, tc.err
},
}
h := NewTimeCardHandler(client)
r := withUser(httptest.NewRequest(http.MethodPatch, "/time-cards/"+testTCID, strings.NewReader(`{}`)))
r.SetPathValue("id", testTCID)
w := httptest.NewRecorder()
h.UpdateTimeCard(w, r)
assertStatus(t, w, tc.wantCode)
assertErrorMessage(t, w, tc.wantMsg)
})
}
})
}
Loading