[MM-67021] Fix 500 errors on check-cws-connection in non-Cloud environments (#34786)

* Fix 500 errors on check-cws-connection in non-Cloud environments

The check-cws-connection endpoint was returning 500 errors in
self-hosted enterprise environments because:

1. The client only checked BuildEnterpriseReady before making the
   request, which is true for all enterprise builds
2. The server handler didn't check for a Cloud license before
   attempting to connect to CWS
3. The CWS URL is not configured in non-Cloud environments, causing
   the connection check to fail

This fix:
- Server: Add IsCloud() license check to match other cloud endpoints,
  returning 403 instead of 500 for non-Cloud licenses
- Client: Add Cloud license check to skip the request entirely in
  non-Cloud environments

* Add unit tests for check-cws-connection license check

* Return JSON status from check-cws-connection endpoint

Change the check-cws-connection endpoint to return 200 with a JSON body
containing status (available/unavailable) instead of using HTTP error
codes. This allows the endpoint to be used for air-gap detection on
self-hosted instances, not just Cloud deployments.

* i18n

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Jesse Hallam 2026-02-02 09:41:14 -04:00 committed by GitHub
parent b74b5fe83f
commit 70a50edcf2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 99 additions and 12 deletions

View file

@ -360,6 +360,36 @@
$ref: "#/components/responses/Forbidden"
"501":
$ref: "#/components/responses/NotImplemented"
/api/v4/cloud/check-cws-connection:
get:
tags:
- cloud
summary: Check CWS connection
description: >
Checks whether the Customer Web Server (CWS) is reachable from this instance.
Used to detect if the deployment is air-gapped.
##### Permissions
No permissions required.
__Minimum server version__: 5.28
__Note:__ This is intended for internal use and is subject to change.
operationId: CheckCWSConnection
responses:
"200":
description: CWS connection status returned successfully
content:
application/json:
schema:
type: object
properties:
status:
type: string
description: Connection status - "available" if CWS is reachable, "unavailable" if not
enum:
- available
- unavailable
/api/v4/cloud/webhook:
post:
tags:

View file

@ -44,7 +44,7 @@ func (api *API) InitCloud() {
// GET /api/v4/cloud/installation
api.BaseRoutes.Cloud.Handle("/installation", api.APISessionRequired(getInstallation)).Methods(http.MethodGet)
// GET /api/v4/cloud/cws-health-check
// GET /api/v4/cloud/check-cws-connection
api.BaseRoutes.Cloud.Handle("/check-cws-connection", api.APIHandler(handleCheckCWSConnection)).Methods(http.MethodGet)
// GET /api/v4/cloud/preview/modal_data
@ -605,12 +605,16 @@ func handleCheckCWSConnection(c *Context, w http.ResponseWriter, r *http.Request
return
}
status := "available"
if err := c.App.Cloud().CheckCWSConnection(c.AppContext.Session().UserId); err != nil {
c.Err = model.NewAppError("Api4.handleCWSHealthCheck", "api.server.cws.health_check.app_error", nil, "CWS Server is not available.", http.StatusInternalServerError)
return
status = "unavailable"
}
ReturnStatusOK(w)
response := map[string]string{"status": status}
if err := json.NewEncoder(w).Encode(response); err != nil {
c.Err = model.NewAppError("Api4.handleCheckCWSConnection", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
}
func getPreviewModalData(c *Context, w http.ResponseWriter, r *http.Request) {

View file

@ -5,10 +5,12 @@ package api4
import (
"context"
"encoding/json"
"errors"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
@ -430,3 +432,57 @@ func TestGetCloudProducts(t *testing.T) {
require.Equal(t, returnedProducts[2].CrossSellsTo, "prod_test2")
})
}
func TestCheckCWSConnection(t *testing.T) {
mainHelper.Parallel(t)
t.Run("returns available when CWS is reachable", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicense())
cloud := mocks.CloudInterface{}
cloud.Mock.On("CheckCWSConnection", mock.Anything).Return(nil)
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
r, err := th.Client.DoAPIGet(context.Background(), "/cloud/check-cws-connection", "")
require.NoError(t, err)
defer closeBody(r)
require.Equal(t, http.StatusOK, r.StatusCode)
var response map[string]string
require.NoError(t, json.NewDecoder(r.Body).Decode(&response))
assert.Equal(t, "available", response["status"])
})
t.Run("returns unavailable when CWS is not reachable", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicense())
cloud := mocks.CloudInterface{}
cloud.Mock.On("CheckCWSConnection", mock.Anything).Return(errors.New("connection failed"))
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
r, err := th.Client.DoAPIGet(context.Background(), "/cloud/check-cws-connection", "")
require.NoError(t, err)
defer closeBody(r)
require.Equal(t, http.StatusOK, r.StatusCode)
var response map[string]string
require.NoError(t, json.NewDecoder(r.Body).Decode(&response))
assert.Equal(t, "unavailable", response["status"])
})
}

View file

@ -3146,10 +3146,6 @@
"id": "api.server.cws.disabled",
"translation": "Interactions with the Mattermost Customer Portal have been disabled by the system admin."
},
{
"id": "api.server.cws.health_check.app_error",
"translation": "CWS Server is not available."
},
{
"id": "api.server.cws.needs_enterprise_edition",
"translation": "Service only available in Mattermost Enterprise edition"

View file

@ -121,9 +121,10 @@ export function checkCWSAvailability(): ActionFuncAsync {
dispatch({type: GeneralTypes.CWS_AVAILABILITY_CHECK_REQUEST});
try {
await Client4.cwsAvailabilityCheck();
dispatch({type: GeneralTypes.CWS_AVAILABILITY_CHECK_SUCCESS, data: 'available'});
return {data: 'available'};
const response = await Client4.cwsAvailabilityCheck();
const status = response.status;
dispatch({type: GeneralTypes.CWS_AVAILABILITY_CHECK_SUCCESS, data: status});
return {data: status};
} catch (error) {
dispatch({type: GeneralTypes.CWS_AVAILABILITY_CHECK_FAILURE});
return {data: 'unavailable'};

View file

@ -4242,7 +4242,7 @@ export default class Client4 {
};
cwsAvailabilityCheck = () => {
return this.doFetchWithResponse(
return this.doFetch<{status: string}>(
`${this.getCloudRoute()}/check-cws-connection`,
{method: 'get'},
);