package main import ( "net/http" "testing" "github.com/go-resty/resty/v2" "github.com/shellhub-io/shellhub/pkg/api/authorizer" "github.com/shellhub-io/shellhub/pkg/models" "github.com/shellhub-io/shellhub/tests/environment" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestPairedDevicesFollowTheirMember covers the devices a member pairs: they belong to that member, // and they leave the namespace with them, when the member is demoted below accepting devices, // removed, or leaves. The owner may keep some of them as team devices on a demotion or a removal, // or make one a team device ahead of time, which a member who cannot create provisioning keys may not. // The cases share one stack, so each signs in its own member and pairs devices under hostnames and // MAC addresses no other case uses. func TestPairedDevicesFollowTheirMember(t *testing.T) { t.Parallel() compose := newSSHEnvironment(t, t.Context(), newNamespace) t.Run("accepting a pairing makes the accepting member the device's owner", func(t *testing.T) { member := signInPairingMember(t, compose, "pairing-owner") accepted := pairDeviceAs(t, compose, member, "owned-by-acceptor", "02:00:00:00:a6:01") assert.Equal(t, member.ID, accepted.OwnerID) assert.Equal(t, member.ID, requireDevice(t, compose, accepted.UID).OwnerID) }) t.Run("demoting a member to observer removes their paired devices but the ones kept as team devices", func(t *testing.T) { member := signInPairingMember(t, compose, "demoted-pairer") kept := pairDeviceAs(t, compose, member, "kept-on-demotion", "02:00:00:00:a6:11") gone := pairDeviceAs(t, compose, member, "gone-on-demotion", "02:00:00:00:a6:12") resp, err := compose.R(t.Context()). SetBody(map[string]any{"role": authorizer.RoleObserver, "keep_devices": []string{kept.UID}}). Patch("/api/namespaces/" + ShellHubNamespace + "/members/" + member.ID) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) requireTeamDevice(t, compose, kept.UID) requireRemovedDevice(t, compose, gone.UID) }) t.Run("removing a member removes their paired devices but the ones kept as team devices", func(t *testing.T) { member := signInPairingMember(t, compose, "removed-pairer") kept := pairDeviceAs(t, compose, member, "kept-on-removal", "02:00:00:00:a6:21") gone := pairDeviceAs(t, compose, member, "gone-on-removal", "02:00:00:00:a6:22") resp, err := compose.R(t.Context()). SetQueryParam("keep_devices", kept.UID). Delete("/api/namespaces/" + ShellHubNamespace + "/members/" + member.ID) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) requireTeamDevice(t, compose, kept.UID) requireRemovedDevice(t, compose, gone.UID) }) t.Run("a paired device made a team device ahead of time stays when its member is removed", func(t *testing.T) { member := signInPairingMember(t, compose, "team-ahead-pairer") paired := pairDeviceAs(t, compose, member, "team-ahead", "02:00:00:00:a6:41") resp, err := deleteDeviceOwner(compose.R(t.Context()), paired.UID) require.NoError(t, err) require.Equal(t, http.StatusNoContent, resp.StatusCode(), resp.String()) requireTeamDevice(t, compose, paired.UID) resp, err = compose.R(t.Context()).Delete("/api/namespaces/" + ShellHubNamespace + "/members/" + member.ID) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) requireTeamDevice(t, compose, paired.UID) }) t.Run("a member who cannot create provisioning keys cannot make a device a team device", func(t *testing.T) { member := signInPairingMember(t, compose, "team-refused-pairer") paired := pairDeviceAs(t, compose, member, "team-refused", "02:00:00:00:a6:51") resp, err := deleteDeviceOwner(asBearer(t, compose, member.Token), paired.UID) require.NoError(t, err) require.Equal(t, http.StatusForbidden, resp.StatusCode(), resp.String()) assert.Equal(t, member.ID, requireDevice(t, compose, paired.UID).OwnerID, "a refused request cleared the device's owner") }) t.Run("leaving the namespace removes the member's paired devices", func(t *testing.T) { member := signInPairingMember(t, compose, "leaving-pairer") paired := pairDeviceAs(t, compose, member, "gone-on-leave", "02:00:00:00:a6:31") resp, err := asBearer(t, compose, member.Token).Delete("/api/namespaces/" + ShellHubNamespace + "/members") require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) requireRemovedDevice(t, compose, paired.UID) }) } func signInPairingMember(t *testing.T, compose *environment.DockerCompose, username string) *models.UserAuthResponse { t.Helper() compose.NewUser(t, username, username+"@ossystems.com.br", ShellHubPassword) compose.NewMember(t, username, ShellHubNamespaceName, string(authorizer.RoleOperator)) return signInto(t, compose, username, ShellHubPassword) } func pairDeviceAs(t *testing.T, compose *environment.DockerCompose, member *models.UserAuthResponse, hostname, mac string) *models.DevicePairingAccepted { t.Helper() pairing := startPairing(t, compose, newPairingRequest(t, hostname, mac)) return acceptPairing(t, asBearer(t, compose, member.Token), pairing.Code) } func deleteDeviceOwner(req *resty.Request, uid string) (*resty.Response, error) { return req.Delete("/api/devices/" + uid + "/owner") } func requireTeamDevice(t *testing.T, compose *environment.DockerCompose, uid string) { t.Helper() device := requireDevice(t, compose, uid) assert.Equal(t, models.DeviceStatusAccepted, device.Status, "a device kept as a team device left the namespace") assert.Empty(t, device.OwnerID, "a device kept as a team device still has an owner") } func requireRemovedDevice(t *testing.T, compose *environment.DockerCompose, uid string) { t.Helper() device := requireDevice(t, compose, uid) assert.Equal(t, models.DeviceStatusRemoved, device.Status, "a paired device stayed after its member lost it") assert.Empty(t, device.OwnerID, "a removed device still has an owner") }