tests/tags_test.go
Rama master · réplica de lectura del repositorio original.
package main
import (
"context"
"net/http"
"testing"
"github.com/go-resty/resty/v2"
"github.com/shellhub-io/shellhub/pkg/api/authorizer"
"github.com/shellhub-io/shellhub/pkg/api/requests"
"github.com/shellhub-io/shellhub/pkg/api/responses"
"github.com/shellhub-io/shellhub/pkg/models"
"github.com/shellhub-io/shellhub/tests/environment"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
)
// TestTagsSelectPublicKeyDevices covers a tag as the device selector of a legacy public key. The key
// holds the tag itself, not its name, so a rename keeps the key reaching the devices carrying it.
// A filter left without its only tag would select every device, so the tag cannot be deleted
// while a key's filter holds it.
func TestTagsSelectPublicKeyDevices(t *testing.T) {
t.Parallel()
ctx := context.Background()
compose := newSSHEnvironment(t, ctx, legacyNamespace)
_, tagged := startAcceptedAgent(t, ctx, compose)
_, untagged := startAcceptedAgent(t, ctx, compose)
t.Run("a renamed tag keeps selecting the devices carrying it", func(t *testing.T) {
createTag(t, compose, "keybefore")
t.Cleanup(func() { deleteTag(t, compose, "keyafter") })
attachTag(t, compose, tagged.UID, "keybefore")
signer := registerPublicKey(t, compose, ".*", requests.PublicKeyFilter{Tags: []string{"keybefore"}})
renameTag(t, compose, "keybefore", "keyafter")
assert.Equal(t, []string{"keyafter"}, tagNames(publicKey(t, compose, signer).Filter.Tags))
requireKeyLogsIn(t, compose, deviceSSHID(tagged), signer)
requireKeyRefused(t, compose, deviceSSHID(untagged), signer, errPublicKeyNotEvaluated)
})
t.Run("a tag a key's filter holds is not deleted", func(t *testing.T) {
createTag(t, compose, "keyheld")
t.Cleanup(func() { deleteTag(t, compose, "keyheld") })
attachTag(t, compose, tagged.UID, "keyheld")
signer := registerPublicKey(t, compose, ".*", requests.PublicKeyFilter{Tags: []string{"keyheld"}})
requireTagInUse(t, compose, "keyheld", `public key "`+t.Name()+`"`)
assert.Contains(t, listedTags(t, compose.R(t.Context())), "keyheld")
assert.Equal(t, []string{"keyheld"}, tagNames(publicKey(t, compose, signer).Filter.Tags))
requireDeviceTags(t, compose, tagged.UID, "keyheld")
requireKeyLogsIn(t, compose, deviceSSHID(tagged), signer)
requireKeyRefused(t, compose, deviceSSHID(untagged), signer, errPublicKeyNotEvaluated)
})
}
// TestTagsSelectAccessPolicyDevices covers a tag as the device filter of an access policy. The
// policy holds the tag itself, not its name, so a rename keeps it granting the devices carrying the
// tag. A deny left without a tag it holds would block fewer devices and so widen access, so the
// tag cannot be deleted while a policy's filter holds it. Each case logs in as a member only its
// own policy grants, so the owner's starter policy never decides it.
func TestTagsSelectAccessPolicyDevices(t *testing.T) {
t.Parallel()
ctx := context.Background()
compose := newSSHEnvironment(t, ctx, newNamespace)
_, tagged := startAcceptedAgent(t, ctx, compose)
_, untagged := startAcceptedAgent(t, ctx, compose)
t.Run("a renamed tag keeps granting the devices carrying it", func(t *testing.T) {
member, signer := memberWithKey(t, compose, "renamed", authorizer.RoleOperator)
createTag(t, compose, "policybefore")
t.Cleanup(func() { deleteTag(t, compose, "policyafter") })
attachTag(t, compose, tagged.UID, "policybefore")
policy := grant(t, compose, &requests.AccessPolicyCreate{
Name: "renamed",
Subject: userSubject(member.ID),
Filter: requests.AccessPolicyFilter{Tags: []string{"policybefore"}},
Logins: []string{"*"},
})
renameTag(t, compose, "policybefore", "policyafter")
assert.Equal(t, []string{"policyafter"}, tagNames(accessPolicy(t, compose, policy.ID).Filter.Tags))
requireStraightThrough(t, compose, deviceSSHID(tagged), signer)
requireRefusedAtAuth(t, compose, deviceSSHID(untagged), signer)
})
t.Run("a tag a policy's filter holds is not deleted", func(t *testing.T) {
member, signer := memberWithKey(t, compose, "held", authorizer.RoleOperator)
createTag(t, compose, "policyheld")
t.Cleanup(func() { deleteTag(t, compose, "policyheld") })
attachTag(t, compose, tagged.UID, "policyheld")
policy := grant(t, compose, &requests.AccessPolicyCreate{
Name: "held",
Subject: userSubject(member.ID),
Filter: requests.AccessPolicyFilter{Tags: []string{"policyheld"}},
Logins: []string{"*"},
})
requireTagInUse(t, compose, "policyheld", `access policy "held"`)
assert.Contains(t, listedTags(t, compose.R(t.Context())), "policyheld")
assert.Equal(t, []string{"policyheld"}, tagNames(accessPolicy(t, compose, policy.ID).Filter.Tags))
requireDeviceTags(t, compose, tagged.UID, "policyheld")
requireStraightThrough(t, compose, deviceSSHID(tagged), signer)
requireRefusedAtAuth(t, compose, deviceSSHID(untagged), signer)
})
}
// TestEnterpriseTagsSelectFirewallRuleDevices covers a tag as the device filter of a firewall rule.
// A rule left without its only tag matches every device, so an allow rule would let through what a
// lower-priority deny blocks; the tag cannot be deleted while a rule's filter holds it.
func TestEnterpriseTagsSelectFirewallRuleDevices(t *testing.T) {
t.Parallel()
ctx := context.Background()
compose := newEnterpriseEnvironment(t, ctx, environment.New(t, run))
signer := registerDeviceKey(t, ctx, compose)
_, device := startAcceptedAgent(t, ctx, compose)
sshid := deviceSSHID(device)
t.Run("a tag a firewall rule's filter holds is not deleted", func(t *testing.T) {
createTag(t, compose, "ruleheld")
t.Cleanup(func() { deleteTag(t, compose, "ruleheld") })
addFirewallRule(t, compose, deny(100, matching(anyValue, anyValue, anyDevice)))
rule := compose.CreateFirewallRule(t, allow(1, matching(anyValue, anyValue, environment.FirewallRuleFilter{Tags: []string{"ruleheld"}})))
t.Cleanup(func() { compose.DeleteFirewallRule(t, rule) })
requireAccessDenied(t, compose, sshid, signer, firewallBlockedLog, errFirewallRuleBlock)
requireTagInUse(t, compose, "ruleheld", `firewall rule "`+rule+`"`)
assert.Contains(t, listedTags(t, compose.R(t.Context())), "ruleheld")
requireAccessDenied(t, compose, sshid, signer, firewallBlockedLog, errFirewallRuleBlock)
})
}
// TestTagDeleteDetachesItEverywhere covers a tag no filter holds: deleting it takes it off every
// device carrying it, whatever the device's status, and leaves the devices' other tags in place.
func TestTagDeleteDetachesItEverywhere(t *testing.T) {
t.Parallel()
compose := newSSHEnvironment(t, t.Context(), legacyNamespace)
accepted := enrollDevice(t, compose, "detachaccepted", "02:00:00:00:11:01")
compose.UpdateDeviceStatus(t, accepted, environment.DeviceActionAccept)
pending := enrollDevice(t, compose, "detachpending", "02:00:00:00:11:02")
createTag(t, compose, "detached")
createTag(t, compose, "kept")
for _, uid := range []string{accepted, pending} {
attachTag(t, compose, uid, "detached")
attachTag(t, compose, uid, "kept")
}
deleteTag(t, compose, "detached")
assert.NotContains(t, listedTags(t, compose.R(t.Context())), "detached")
requireDeviceTags(t, compose, accepted, "kept")
requireDeviceTags(t, compose, pending, "kept")
}
// TestTagNamesAreUniqueInNamespace covers a tag name already taken in the namespace: creating a tag
// or renaming another to it answers 409 naming the field, and leaves both tags as they were.
func TestTagNamesAreUniqueInNamespace(t *testing.T) {
t.Parallel()
compose := newSSHEnvironment(t, t.Context(), legacyNamespace)
createTag(t, compose, "taken")
createTag(t, compose, "other")
attempts := []struct {
name string
method string
path string
}{
{"creating a tag with a taken name answers 409", http.MethodPost, "/api/tags"},
{"renaming a tag to a taken name answers 409", http.MethodPatch, "/api/tags/other"},
}
for _, attempt := range attempts {
t.Run(attempt.name, func(t *testing.T) {
refusal := new(responses.Error)
resp, err := compose.R(t.Context()).SetBody(map[string]string{"name": "taken"}).SetError(refusal).Execute(attempt.method, attempt.path)
require.NoError(t, err)
require.Equal(t, http.StatusConflict, resp.StatusCode(), resp.String())
assert.Equal(t, map[string]string{"name": "duplicated"}, refusal.Fields)
assert.ElementsMatch(t, []string{"taken", "other"}, listedTags(t, compose.R(t.Context())))
})
}
}
// TestTagsAppliedByProvisioningKey covers a tag a provisioning key applies to the devices it enrolls.
// The key holds the tag itself, not its name, so a rename carries over to the devices it enrolls
// next. A key left without a tag would silently stop applying it, so the tag cannot be deleted while
// a key holds it, unless the key is revoked and so enrolls nothing more.
func TestTagsAppliedByProvisioningKey(t *testing.T) {
t.Parallel()
compose := newSSHEnvironment(t, t.Context(), newNamespace)
newKey := func(t *testing.T, name, tag string) string {
t.Helper()
return compose.CreateProvisioningKey(t, &requests.CreateProvisioningKey{
Name: name,
Mode: string(models.ProvisioningKeyModeAutomatic),
Tags: []string{tag},
}).Key
}
t.Run("a renamed tag keeps being applied under its new name", func(t *testing.T) {
key := newKey(t, "renamedtag", "keybefore")
before := enroll(t, compose, newKeyedDeviceAuthRequest(t, key, "renamedbefore", "02:00:00:00:13:01"))
require.Equal(t, []string{"keybefore"}, tagNames(before.Tags))
renameTag(t, compose, "keybefore", "keyafter")
after := enroll(t, compose, newKeyedDeviceAuthRequest(t, key, "renamedafter", "02:00:00:00:13:02"))
assert.Equal(t, []string{"keyafter"}, compose.ProvisioningKey(t, "renamedtag").Tags)
requireDeviceTags(t, compose, before.UID, "keyafter")
assert.Equal(t, []string{"keyafter"}, tagNames(after.Tags))
assert.NotContains(t, listedTags(t, compose.R(t.Context())), "keybefore")
})
t.Run("a tag a provisioning key applies is not deleted", func(t *testing.T) {
key := newKey(t, "heldtag", "keyheld")
before := enroll(t, compose, newKeyedDeviceAuthRequest(t, key, "heldbefore", "02:00:00:00:13:03"))
require.Equal(t, []string{"keyheld"}, tagNames(before.Tags))
requireTagInUse(t, compose, "keyheld", `provisioning key "heldtag"`)
after := enroll(t, compose, newKeyedDeviceAuthRequest(t, key, "heldafter", "02:00:00:00:13:04"))
assert.Contains(t, listedTags(t, compose.R(t.Context())), "keyheld")
assert.Equal(t, []string{"keyheld"}, compose.ProvisioningKey(t, "heldtag").Tags)
requireDeviceTags(t, compose, before.UID, "keyheld")
assert.Equal(t, []string{"keyheld"}, tagNames(after.Tags))
})
t.Run("a tag only a revoked provisioning key holds is deleted", func(t *testing.T) {
key := newKey(t, "revokedtag", "keyrevoked")
before := enroll(t, compose, newKeyedDeviceAuthRequest(t, key, "revokedbefore", "02:00:00:00:13:05"))
require.Equal(t, []string{"keyrevoked"}, tagNames(before.Tags))
compose.UpdateProvisioningKey(t, "revokedtag", map[string]any{"revoked": true})
deleteTag(t, compose, "keyrevoked")
assert.NotContains(t, listedTags(t, compose.R(t.Context())), "keyrevoked")
assert.Empty(t, compose.ProvisioningKey(t, "revokedtag").Tags)
requireDeviceTags(t, compose, before.UID)
})
}
// TestTagsAreNamespaceScoped covers two namespaces using the same tag name. Each namespace sees,
// attaches, renames and deletes only its own tag, so a change in one leaves the other untouched.
func TestTagsAreNamespaceScoped(t *testing.T) {
t.Parallel()
const (
neighbourUsername = "neighbour"
neighbourTenant = "11111111-1111-4111-1111-111111111111"
)
compose := newSSHEnvironment(t, t.Context(), legacyNamespace)
compose.NewUser(t, neighbourUsername, "neighbour@ossystems.com.br", ShellHubPassword)
compose.NewLegacyNamespace(t, neighbourUsername, "neighbourhood", neighbourTenant)
neighbour := compose.AuthUser(t, neighbourUsername, ShellHubPassword)
asNeighbour := func(ctx context.Context) *resty.Request {
return compose.Anonymous(ctx).SetAuthToken(neighbour.Token)
}
own := enrollDevice(t, compose, "owndevice", "02:00:00:00:12:01")
createTag(t, compose, "shared")
attachTag(t, compose, own, "shared")
neighbourDevice := newDeviceAuthRequest(t, "neighbourdevice", "02:00:00:00:12:02")
neighbourDevice.TenantID = neighbourTenant
theirs := authDevice(t, compose, neighbourDevice).UID
t.Run("another namespace does not list the tag", func(t *testing.T) {
assert.NotContains(t, listedTags(t, asNeighbour(t.Context())), "shared")
})
t.Run("another namespace cannot attach, rename or delete the tag", func(t *testing.T) {
attempts := []struct {
method string
path string
body any
}{
{method: http.MethodPost, path: "/api/devices/" + theirs + "/tags/shared"},
{method: http.MethodPatch, path: "/api/tags/shared", body: map[string]string{"name": "taken"}},
{method: http.MethodDelete, path: "/api/tags/shared"},
}
for _, attempt := range attempts {
req := asNeighbour(t.Context())
if attempt.body != nil {
req = req.SetBody(attempt.body)
}
resp, err := req.Execute(attempt.method, attempt.path)
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, resp.StatusCode(), "%s %s: %s", attempt.method, attempt.path, resp.String())
}
assert.Contains(t, listedTags(t, compose.R(t.Context())), "shared")
requireDeviceTags(t, compose, own, "shared")
})
t.Run("another namespace keeps its own tag of that name through the first one's rename and delete", func(t *testing.T) {
resp, err := asNeighbour(t.Context()).SetBody(map[string]string{"name": "shared"}).Post("/api/tags")
require.NoError(t, err)
require.Equal(t, http.StatusCreated, resp.StatusCode(), resp.String())
resp, err = asNeighbour(t.Context()).Post("/api/devices/" + theirs + "/tags/shared")
require.NoError(t, err)
require.Equal(t, http.StatusNoContent, resp.StatusCode(), resp.String())
renameTag(t, compose, "shared", "renamed")
deleteTag(t, compose, "renamed")
assert.Equal(t, []string{"shared"}, listedTags(t, asNeighbour(t.Context())))
device := new(models.Device)
resp, err = asNeighbour(t.Context()).SetResult(device).Get("/api/devices/" + theirs)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
assert.Equal(t, []string{"shared"}, tagNames(device.Tags))
})
}
func attachTag(t *testing.T, compose *environment.DockerCompose, uid, tag string) {
t.Helper()
resp, err := compose.R(t.Context()).Post("/api/devices/" + uid + "/tags/" + tag)
require.NoError(t, err)
require.Equal(t, http.StatusNoContent, resp.StatusCode(), resp.String())
}
func renameTag(t *testing.T, compose *environment.DockerCompose, from, to string) {
t.Helper()
resp, err := compose.R(t.Context()).SetBody(map[string]string{"name": to}).Patch("/api/tags/" + from)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
}
func deleteTag(t *testing.T, compose *environment.DockerCompose, name string) {
t.Helper()
resp, err := compose.R(context.WithoutCancel(t.Context())).Delete("/api/tags/" + name)
require.NoError(t, err)
require.Equal(t, http.StatusNoContent, resp.StatusCode(), resp.String())
}
func requireTagInUse(t *testing.T, compose *environment.DockerCompose, name, holder string) {
t.Helper()
refusal := new(responses.Error)
resp, err := compose.R(t.Context()).SetError(refusal).Delete("/api/tags/" + name)
require.NoError(t, err)
require.Equal(t, http.StatusConflict, resp.StatusCode(), resp.String())
assert.Equal(t, map[string]string{"name": holder}, refusal.Fields)
}
func listedTags(t *testing.T, req *resty.Request) []string {
t.Helper()
tags := []models.Tag{}
resp, err := req.SetQueryParam("per_page", "100").SetResult(&tags).Get("/api/tags")
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
return tagNames(tags)
}
func requireDeviceTags(t *testing.T, compose *environment.DockerCompose, uid string, tags ...string) {
t.Helper()
device, resp, err := compose.GetDevice(t.Context(), uid)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
assert.ElementsMatch(t, tags, tagNames(device.Tags))
}
func publicKey(t *testing.T, compose *environment.DockerCompose, signer ssh.Signer) models.PublicKey {
t.Helper()
keys := []models.PublicKey{}
resp, err := compose.R(t.Context()).
SetQueryParam("filter", propertyFilter(t, "fingerprint", "eq", ssh.FingerprintLegacyMD5(signer.PublicKey()))).
SetResult(&keys).
Get("/api/sshkeys/public-keys")
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
require.Len(t, keys, 1)
return keys[0]
}
func accessPolicy(t *testing.T, compose *environment.DockerCompose, id string) models.AccessPolicy {
t.Helper()
policy := models.AccessPolicy{}
resp, err := compose.R(t.Context()).SetResult(&policy).Get("/api/access-policies/" + id)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
return policy
}