server/ssh/http/handlers_test.go
Rama master · réplica de lectura del repositorio original.
package http
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/labstack/echo/v5"
"github.com/shellhub-io/shellhub/pkg/api/authorizer"
"github.com/shellhub-io/shellhub/pkg/api/identity"
"github.com/shellhub-io/shellhub/pkg/api/identity/identitytest"
"github.com/shellhub-io/shellhub/pkg/api/responses"
"github.com/shellhub-io/shellhub/pkg/api/scope"
"github.com/shellhub-io/shellhub/pkg/models"
"github.com/shellhub-io/shellhub/server/api/pkg/echo/handlers"
"github.com/shellhub-io/shellhub/server/api/services"
servicemocks "github.com/shellhub-io/shellhub/server/api/services/mocks"
"github.com/shellhub-io/shellhub/server/api/store"
"github.com/shellhub-io/shellhub/server/ssh/pkg/dialer"
"github.com/shellhub-io/shellhub/server/ssh/pkg/dialer/dialertest"
"github.com/shellhub-io/shellhub/server/ssh/session"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func newCloseRequest(t *testing.T, role string) (*echo.Context, *httptest.ResponseRecorder) {
t.Helper()
e := echo.New()
e.Binder = handlers.NewBinder()
e.HTTPErrorHandler = handlers.NewErrors(nil)
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/sessions/session-uid/close", strings.NewReader(`{"device":"device-uid"}`))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
req = identitytest.As(req, identity.Identity{Role: authorizer.RoleFromString(role), TenantID: "tenant-id"})
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetPathValues(echo.PathValues{{Name: "uid", Value: "session-uid"}})
return c, rec
}
// TestHandleSSHCloseAsksTheTunnelToCloseTheSession pins what the handler states at the call
// site: the device the session is on, and that the connection is for closing that session.
func TestHandleSSHCloseAsksTheTunnelToCloseTheSession(t *testing.T) {
c, rec := newCloseRequest(t, "administrator")
registry := session.NewRegistry()
registry.Add("session-uid")
agent := dialertest.NewAgent(t)
h := &Handlers{Dialer: agent, Sessions: registry} //nolint:exhaustruct // an owned session never reaches the service
require.NoError(t, h.HandleSSHClose(c))
assert.Equal(t, http.StatusAccepted, rec.Code)
assert.Equal(t, []dialertest.Dial{{
Tenant: "tenant-id",
UID: "device-uid",
Target: dialer.SSHCloseTarget{SessionID: "session-uid"},
}}, agent.Dials())
}
func TestHandleSSHCloseReportsAnUnreachableDevice(t *testing.T) {
for _, failure := range []error{dialer.ErrNoConnection, dialer.ErrUnreachable, dialer.ErrInvalidArgument} {
t.Run(failure.Error(), func(t *testing.T) {
c, _ := newCloseRequest(t, "administrator")
registry := session.NewRegistry()
registry.Add("session-uid")
stub := &dialertest.Stub{Err: failure} //nolint:exhaustruct // the recording field starts empty and is appended to under the mutex
h := &Handlers{Dialer: stub, Sessions: registry} //nolint:exhaustruct // an owned session never reaches the service
require.ErrorIs(t, h.HandleSSHClose(c), ErrDeviceTunnelDial)
assert.Equal(t, []dialertest.Dial{{
Tenant: "tenant-id",
UID: "device-uid",
Target: dialer.SSHCloseTarget{SessionID: "session-uid"},
}}, stub.Dials(), "the failure path must hand the tunnel the same close target")
})
}
}
func TestHandleSSHCloseAuthorization(t *testing.T) {
forbiddenRoles := []string{"observer", "operator", "", "invalid"}
for _, role := range forbiddenRoles {
t.Run("rejects role "+role, func(t *testing.T) {
c, rec := newCloseRequest(t, role)
h := &Handlers{} //nolint:exhaustruct // Dialer must not be reached for forbidden roles.
if err := h.HandleSSHClose(c); err != nil {
c.Echo().HTTPErrorHandler(c, err)
}
assert.Equal(t, http.StatusForbidden, rec.Code)
body := responses.Error{} //nolint:exhaustruct // the decoder fills it from the response body
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body), "a refusal must answer a JSON error body")
assert.NotEmpty(t, body.Message, "a refusal's body must say why")
})
}
}
func TestHandleSSHCloseRetiresASessionNobodyOwns(t *testing.T) {
c, rec := newCloseRequest(t, "administrator")
service := servicemocks.NewMockService(t)
service.On("GetSession", mock.Anything, scope.MustBounded("tenant-id"), models.UID("session-uid")).
Return(&models.Session{UID: "session-uid", TenantID: "tenant-id"}, nil).Once()
service.On("DeactivateSession", mock.Anything, scope.MustBounded("tenant-id"), models.UID("session-uid")).Return(nil).Once()
h := &Handlers{ //nolint:exhaustruct // the close path reaches neither the tunnel registry nor the config
Dialer: dialertest.NewAgent(t),
Service: service,
Sessions: session.NewRegistry(),
}
require.NoError(t, h.HandleSSHClose(c))
assert.Equal(t, http.StatusOK, rec.Code, "200 means the session is closed, and here the handler closed it")
service.AssertExpectations(t)
}
func TestHandleSSHCloseLeavesAnotherNamespacesSessionAlone(t *testing.T) {
c, rec := newCloseRequest(t, "administrator")
service := servicemocks.NewMockService(t)
service.On("GetSession", mock.Anything, scope.MustBounded("tenant-id"), models.UID("session-uid")).
Return(nil, services.NewErrSessionNotFound("session-uid", store.ErrNoDocuments)).Once()
h := &Handlers{ //nolint:exhaustruct // the close path reaches neither the tunnel registry nor the config
Dialer: dialertest.NewAgent(t),
Service: service,
Sessions: session.NewRegistry(),
}
c.Echo().HTTPErrorHandler(c, h.HandleSSHClose(c))
assert.Equal(t, http.StatusNotFound, rec.Code, "a session outside the caller's namespace is not the caller's to retire")
body := responses.Error{} //nolint:exhaustruct // the decoder fills it from the response body
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body), "a refusal must answer a JSON error body")
assert.NotEmpty(t, body.Message, "a refusal's body must say why")
service.AssertNotCalled(t, "DeactivateSession", mock.Anything, mock.Anything, mock.Anything)
}
func TestHandleSSHCloseDelegatesASessionItOwns(t *testing.T) {
c, rec := newCloseRequest(t, "administrator")
registry := session.NewRegistry()
registry.Add("session-uid")
h := &Handlers{ //nolint:exhaustruct // the close path reaches neither the tunnel registry nor the config
Dialer: dialertest.NewAgent(t),
Service: servicemocks.NewMockService(t),
Sessions: registry,
}
require.NoError(t, h.HandleSSHClose(c))
assert.Equal(t, http.StatusAccepted, rec.Code, "202 means the request was delivered, not that the session closed")
}
func TestHandleSSHCloseKeepsADialFailureAnErrorForAnOwnedSession(t *testing.T) {
c, _ := newCloseRequest(t, "administrator")
registry := session.NewRegistry()
registry.Add("session-uid")
stub := &dialertest.Stub{Err: dialer.ErrUnreachable} //nolint:exhaustruct // the recording field starts empty and is appended to under the mutex
h := &Handlers{ //nolint:exhaustruct // the close path reaches neither the tunnel registry nor the config
Dialer: stub,
Service: servicemocks.NewMockService(t),
Sessions: registry,
}
assert.ErrorIs(t, h.HandleSSHClose(c), ErrDeviceTunnelDial)
}