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
4 changes: 0 additions & 4 deletions apps/csm-portal/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,6 @@ backend/

### Updates

- `GET /updates/recommended-update-levels` — Get recommended update levels (user email sourced from JWT)
- `GET /updates/product-update-levels` — Get product update levels
- `POST /updates/levels/search` — Search updates between update levels

Expand Down Expand Up @@ -196,9 +195,6 @@ curl -X PATCH http://localhost:8080/users/me \
-H "Content-Type: application/json" \
-d '{"phoneNumber":"+94771234567"}'

# Get recommended update levels (user email is read from the JWT)
curl -H "x-jwt-assertion: $JWT" http://localhost:8080/updates/recommended-update-levels

# Get product update levels
curl -H "x-jwt-assertion: $JWT" http://localhost:8080/updates/product-update-levels

Expand Down
5 changes: 3 additions & 2 deletions apps/csm-portal/backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ func main() {
accountHandler := handler.NewAccountHandler(entityClient)
projectHandler := handler.NewProjectHandler(entityClient)
productHandler := handler.NewProductHandler(entityClient)
deploymentHandler := handler.NewDeploymentHandler(entityClient)

updatesCfg := updates.Config{
BaseURL: mustEnv("UPDATES_BASE_URL"),
Expand Down Expand Up @@ -81,7 +82,6 @@ func main() {
mux.HandleFunc("POST /cases", caseHandler.CreateCase)
mux.HandleFunc("POST /cases/search", caseHandler.SearchCases)
mux.HandleFunc("GET /cases/{id}", caseHandler.GetCase)
mux.HandleFunc("GET /updates/recommended-update-levels", updatesHandler.GetRecommendedUpdateLevels)
mux.HandleFunc("GET /updates/product-update-levels", updatesHandler.GetProductUpdateLevels)
mux.HandleFunc("POST /updates/levels/search", updatesHandler.SearchUpdatesBetweenUpdateLevels)
mux.HandleFunc("GET /users/me", usersHandler.GetMe)
Expand All @@ -90,7 +90,8 @@ func main() {
mux.HandleFunc("POST /accounts/search", accountHandler.SearchAccounts)
mux.HandleFunc("POST /projects/search", projectHandler.SearchProjects)
mux.HandleFunc("POST /products/search", productHandler.SearchProducts)
mux.HandleFunc("POST /product/{id}/versions/search", productHandler.SearchProductVersions)
mux.HandleFunc("POST /products/{id}/versions/search", productHandler.SearchProductVersions)
mux.HandleFunc("POST /deployments/search", deploymentHandler.SearchDeployments)

addr := envOrDefault("PORT", ":8080")
slog.Info("starting csm-portal backend", "addr", addr)
Expand Down
10 changes: 8 additions & 2 deletions apps/csm-portal/backend/internal/entity/entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,14 @@ func (c *Client) SearchProducts(ctx context.Context, body []byte) ([]byte, error
return c.do(ctx, http.MethodPost, "/products/search", body)
}

// SearchProductVersions calls POST /product/{id}/versions/search on the entity service.
// SearchProductVersions calls POST /products/{id}/versions/search on the entity service.
// Response is returned as raw JSON; field filtering to the portal shape is deferred.
func (c *Client) SearchProductVersions(ctx context.Context, productID string, body []byte) ([]byte, error) {
return c.do(ctx, http.MethodPost, fmt.Sprintf("/product/%s/versions/search", url.PathEscape(productID)), body)
return c.do(ctx, http.MethodPost, fmt.Sprintf("/products/%s/versions/search", url.PathEscape(productID)), body)
}

// SearchDeployments calls POST /deployments/search on the entity service.
// Response is returned as raw JSON; field filtering to the portal shape is deferred.
func (c *Client) SearchDeployments(ctx context.Context, body []byte) ([]byte, error) {
return c.do(ctx, http.MethodPost, "/deployments/search", body)
}
82 changes: 82 additions & 0 deletions apps/csm-portal/backend/internal/handler/deployments.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// 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"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"

"github.com/wso2-open-operations/cs-tools/apps/csm-portal/backend/internal/middleware"
)

// entityDeploymentClient abstracts the entity service deployment operations used by DeploymentHandler.
type entityDeploymentClient interface {
SearchDeployments(ctx context.Context, body []byte) ([]byte, error)
}

// DeploymentHandler handles HTTP requests for deployment operations, delegating to the
// entity service for data access.
type DeploymentHandler struct {
entity entityDeploymentClient
}

// NewDeploymentHandler creates a DeploymentHandler backed by the given entity client.
func NewDeploymentHandler(entity entityDeploymentClient) *DeploymentHandler {
return &DeploymentHandler{entity: entity}
}

// SearchDeployments handles POST /deployments/search.
func (h *DeploymentHandler) SearchDeployments(w http.ResponseWriter, r *http.Request) {
user := middleware.UserInfoFromContext(r.Context())
if user == nil {
writeError(w, http.StatusUnauthorized, ErrMsgUnauthorized)
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
}

if !json.Valid(body) {
writeError(w, http.StatusBadRequest, ErrMsgBadRequest)
return
}

// TODO: Decode into a typed SearchDeploymentsRequest and validate fields before forwarding.

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

// TODO: Unmarshal result and filter to only the fields required by the frontend.
writeJSON(w, http.StatusOK, result)
}
102 changes: 102 additions & 0 deletions apps/csm-portal/backend/internal/handler/deployments_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// 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"
)

func TestSearchDeployments(t *testing.T) {
t.Run("requires authenticated user", func(t *testing.T) {
h := NewDeploymentHandler(&mockEntityDeploymentClient{})
r := httptest.NewRequest(http.MethodPost, "/deployments/search", strings.NewReader(`{}`))
w := httptest.NewRecorder()
h.SearchDeployments(w, r)
assertStatus(t, w, http.StatusUnauthorized)
assertErrorMessage(t, w, ErrMsgUnauthorized)
assertContentType(t, w, "application/json")
})

t.Run("rejects body exceeding 1 MiB", func(t *testing.T) {
h := NewDeploymentHandler(&mockEntityDeploymentClient{})
r := withUser(httptest.NewRequest(http.MethodPost, "/deployments/search", strings.NewReader(strings.Repeat("x", maxRequestBodyBytes+1))))
w := httptest.NewRecorder()
h.SearchDeployments(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 := NewDeploymentHandler(&mockEntityDeploymentClient{})
r := withUser(httptest.NewRequest(http.MethodPost, "/deployments/search", strings.NewReader(`not-json`)))
w := httptest.NewRecorder()
h.SearchDeployments(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 = `{"searchQuery":"prod","projectIds":["proj-1"],"deploymentTypeKeys":["primary_production"]}`
var capturedBody []byte
client := &mockEntityDeploymentClient{
searchDeploymentsFn: func(_ context.Context, body []byte) ([]byte, error) {
capturedBody = body
return []byte(`{"deployments":[{"id":"dep-1","name":"Production"}],"total":1}`), nil
},
}
h := NewDeploymentHandler(client)
r := withUser(httptest.NewRequest(http.MethodPost, "/deployments/search", strings.NewReader(reqPayload)))
w := httptest.NewRecorder()
h.SearchDeployments(w, r)

assertStatus(t, w, http.StatusOK)
assertContentType(t, w, "application/json")
if string(capturedBody) != reqPayload {
t.Errorf("upstream received body %q, want %q", capturedBody, reqPayload)
}
resp := decodeJSON[map[string]any](t, w)
if resp["total"] != float64(1) {
t.Errorf("total = %v, want 1", resp["total"])
}
})

t.Run("upstream errors are mapped correctly", func(t *testing.T) {
for _, tc := range upstreamErrors("Failed to search deployments.") {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
client := &mockEntityDeploymentClient{
searchDeploymentsFn: func(_ context.Context, _ []byte) ([]byte, error) {
return nil, tc.err
},
}
h := NewDeploymentHandler(client)
r := withUser(httptest.NewRequest(http.MethodPost, "/deployments/search", strings.NewReader(`{}`)))
w := httptest.NewRecorder()
h.SearchDeployments(w, r)
assertStatus(t, w, tc.wantCode)
assertErrorMessage(t, w, tc.wantMsg)
assertContentType(t, w, "application/json")
})
}
})
}
25 changes: 15 additions & 10 deletions apps/csm-portal/backend/internal/handler/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,16 +114,8 @@ func (m *mockEntityCaseClient) GetCase(ctx context.Context, caseID string) ([]by
// ----- mock updates client -----

type mockUpdatesClient struct {
recommendedFn func(ctx context.Context, email string) ([]updates.RecommendedUpdateLevel, error)
productFn func(ctx context.Context) ([]updates.ProductUpdateLevel, error)
searchFn func(ctx context.Context, payload updates.SearchPayload, email string) (map[string]updates.UpdateLevelGroup, error)
}

func (m *mockUpdatesClient) GetRecommendedUpdateLevels(ctx context.Context, email string) ([]updates.RecommendedUpdateLevel, error) {
if m.recommendedFn != nil {
return m.recommendedFn(ctx, email)
}
return []updates.RecommendedUpdateLevel{}, nil
productFn func(ctx context.Context) ([]updates.ProductUpdateLevel, error)
searchFn func(ctx context.Context, payload updates.SearchPayload, email string) (map[string]updates.UpdateLevelGroup, error)
}

func (m *mockUpdatesClient) GetProductUpdateLevels(ctx context.Context) ([]updates.ProductUpdateLevel, error) {
Expand Down Expand Up @@ -220,3 +212,16 @@ func (m *mockEntityProductClient) SearchProductVersions(ctx context.Context, pro
}
return []byte(`{}`), nil
}

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

type mockEntityDeploymentClient struct {
searchDeploymentsFn func(ctx context.Context, body []byte) ([]byte, error)
}

func (m *mockEntityDeploymentClient) SearchDeployments(ctx context.Context, body []byte) ([]byte, error) {
if m.searchDeploymentsFn != nil {
return m.searchDeploymentsFn(ctx, body)
}
return []byte(`{}`), nil
}
2 changes: 1 addition & 1 deletion apps/csm-portal/backend/internal/handler/products.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ func (h *ProductHandler) SearchProducts(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, result)
}

// SearchProductVersions handles POST /product/{id}/versions/search.
// SearchProductVersions handles POST /products/{id}/versions/search.
func (h *ProductHandler) SearchProductVersions(w http.ResponseWriter, r *http.Request) {
user := middleware.UserInfoFromContext(r.Context())
if user == nil {
Expand Down
20 changes: 0 additions & 20 deletions apps/csm-portal/backend/internal/handler/updates.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import (
// updatesClient abstracts the updates service operations used by UpdatesHandler,
// allowing the handler to be tested independently of the real HTTP client.
type updatesClient interface {
GetRecommendedUpdateLevels(ctx context.Context, userEmail string) ([]updates.RecommendedUpdateLevel, error)
GetProductUpdateLevels(ctx context.Context) ([]updates.ProductUpdateLevel, error)
SearchUpdatesBetweenUpdateLevels(ctx context.Context, payload updates.SearchPayload, userEmail string) (map[string]updates.UpdateLevelGroup, error)
}
Expand All @@ -46,25 +45,6 @@ func NewUpdatesHandler(updates updatesClient) *UpdatesHandler {
return &UpdatesHandler{updates: updates}
}

// GetRecommendedUpdateLevels handles GET /updates/recommended-update-levels.
// The user email is sourced from the JWT claims, not a query parameter.
func (h *UpdatesHandler) GetRecommendedUpdateLevels(w http.ResponseWriter, r *http.Request) {
user := middleware.UserInfoFromContext(r.Context())
if user == nil {
writeError(w, http.StatusUnauthorized, ErrMsgUnauthorized)
return
}

result, err := h.updates.GetRecommendedUpdateLevels(r.Context(), user.Email)
if err != nil {
slog.ErrorContext(r.Context(), "updates GetRecommendedUpdateLevels failed", "userID", user.UserID, "err", err)
mapUpstreamError(w, err, "Failed to get recommended update levels.")
return
}

writeJSONValue(w, http.StatusOK, result)
}

// GetProductUpdateLevels handles GET /updates/product-update-levels.
func (h *UpdatesHandler) GetProductUpdateLevels(w http.ResponseWriter, r *http.Request) {
user := middleware.UserInfoFromContext(r.Context())
Expand Down
60 changes: 0 additions & 60 deletions apps/csm-portal/backend/internal/handler/updates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,66 +26,6 @@ import (
"github.com/wso2-open-operations/cs-tools/apps/csm-portal/backend/internal/updates"
)

// ----- GetRecommendedUpdateLevels -----

func TestGetRecommendedUpdateLevels(t *testing.T) {
t.Run("requires authenticated user", func(t *testing.T) {
h := NewUpdatesHandler(&mockUpdatesClient{})
r := httptest.NewRequest(http.MethodGet, "/updates/recommended-update-levels", nil)
w := httptest.NewRecorder()
h.GetRecommendedUpdateLevels(w, r)
assertStatus(t, w, http.StatusUnauthorized)
assertErrorMessage(t, w, ErrMsgUnauthorized)
assertContentType(t, w, "application/json")
})

t.Run("passes JWT email to upstream and returns 200 with levels", func(t *testing.T) {
var capturedEmail string
client := &mockUpdatesClient{
recommendedFn: func(_ context.Context, email string) ([]updates.RecommendedUpdateLevel, error) {
capturedEmail = email
return []updates.RecommendedUpdateLevel{
{ProductName: "wso2am", ProductBaseVersion: "4.3.0", RecommendedUpdateLevel: 10},
}, nil
},
}
h := NewUpdatesHandler(client)
r := withUser(httptest.NewRequest(http.MethodGet, "/updates/recommended-update-levels", nil))
w := httptest.NewRecorder()
h.GetRecommendedUpdateLevels(w, r)

assertStatus(t, w, http.StatusOK)
assertContentType(t, w, "application/json")
if capturedEmail != testUser.Email {
t.Errorf("upstream called with email %q, want %q", capturedEmail, testUser.Email)
}
resp := decodeJSON[[]updates.RecommendedUpdateLevel](t, w)
if len(resp) != 1 || resp[0].ProductName != "wso2am" {
t.Errorf("unexpected response: %+v", resp)
}
})

t.Run("upstream errors are mapped correctly", func(t *testing.T) {
for _, tc := range upstreamErrors("Failed to get recommended update levels.") {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
client := &mockUpdatesClient{
recommendedFn: func(_ context.Context, _ string) ([]updates.RecommendedUpdateLevel, error) {
return nil, tc.err
},
}
h := NewUpdatesHandler(client)
r := withUser(httptest.NewRequest(http.MethodGet, "/updates/recommended-update-levels", nil))
w := httptest.NewRecorder()
h.GetRecommendedUpdateLevels(w, r)
assertStatus(t, w, tc.wantCode)
assertErrorMessage(t, w, tc.wantMsg)
assertContentType(t, w, "application/json")
})
}
})
}

// ----- GetProductUpdateLevels -----

func TestGetProductUpdateLevels(t *testing.T) {
Expand Down
Loading