Files
headscale/hscontrol/grpcv1_test.go
Kristoffer Dalby 580dcad683 hscontrol: add tests for SetTags user_id database persistence
Add four tests that verify the tags-as-identity ownership transition
correctly persists to the database when converting a user-owned node
to a tagged node via SetTags:

- TestSetTags_ClearsUserIDInDatabase: verifies user_id is NULL in DB
- TestSetTags_NodeDisappearsFromUserListing: verifies ListNodes by user
- TestSetTags_NodeStoreAndDBConsistency: verifies in-memory and DB agree
- TestSetTags_UserDeletionDoesNotCascadeToTaggedNode: verifies user
  deletion does not cascade-delete tagged nodes

Three of these tests currently fail because GORM's struct-based
Updates() silently skips nil pointer fields, so user_id is never
written as NULL to the database after SetNodeTags clears it in memory.

Updates #3161
2026-04-08 14:32:08 +01:00

824 lines
25 KiB
Go

package hscontrol
import (
"context"
"testing"
"time"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
func Test_validateTag(t *testing.T) {
type args struct {
tag string
}
tests := []struct {
name string
args args
wantErr bool
}{
{
name: "valid tag",
args: args{tag: "tag:test"},
wantErr: false,
},
{
name: "tag without tag prefix",
args: args{tag: "test"},
wantErr: true,
},
{
name: "uppercase tag",
args: args{tag: "tag:tEST"},
wantErr: true,
},
{
name: "tag that contains space",
args: args{tag: "tag:this is a spaced tag"},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateTag(tt.args.tag)
if (err != nil) != tt.wantErr {
t.Errorf("validateTag() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
// TestSetTags_Conversion tests the conversion of user-owned nodes to tagged nodes.
// The tags-as-identity model allows one-way conversion from user-owned to tagged.
// Tag authorization is checked via the policy manager - unauthorized tags are rejected.
func TestSetTags_Conversion(t *testing.T) {
t.Parallel()
app := createTestApp(t)
// Create test user and nodes
user := app.state.CreateUserForTest("test-user")
// Create a pre-auth key WITHOUT tags for user-owned node
pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, nil)
require.NoError(t, err)
machineKey1 := key.NewMachine()
nodeKey1 := key.NewNode()
// Register a user-owned node (via untagged PreAuthKey)
userOwnedReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{
AuthKey: pak.Key,
},
NodeKey: nodeKey1.Public(),
Hostinfo: &tailcfg.Hostinfo{
Hostname: "user-owned-node",
},
}
_, err = app.handleRegisterWithAuthKey(userOwnedReq, machineKey1.Public())
require.NoError(t, err)
// Get the created node
userOwnedNode, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
require.True(t, found)
// Create API server instance
apiServer := newHeadscaleV1APIServer(app)
tests := []struct {
name string
nodeID uint64
tags []string
wantErr bool
wantCode codes.Code
wantErrMessage string
}{
{
// Conversion is allowed, but tag authorization fails without tagOwners
name: "reject unauthorized tags on user-owned node",
nodeID: uint64(userOwnedNode.ID()),
tags: []string{"tag:server"},
wantErr: true,
wantCode: codes.InvalidArgument,
wantErrMessage: "requested tags",
},
{
// Conversion is allowed, but tag authorization fails without tagOwners
name: "reject multiple unauthorized tags",
nodeID: uint64(userOwnedNode.ID()),
tags: []string{"tag:server", "tag:database"},
wantErr: true,
wantCode: codes.InvalidArgument,
wantErrMessage: "requested tags",
},
{
name: "reject non-existent node",
nodeID: 99999,
tags: []string{"tag:server"},
wantErr: true,
wantCode: codes.NotFound,
wantErrMessage: "node not found",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
resp, err := apiServer.SetTags(context.Background(), &v1.SetTagsRequest{
NodeId: tt.nodeID,
Tags: tt.tags,
})
if tt.wantErr {
require.Error(t, err)
st, ok := status.FromError(err)
require.True(t, ok, "error should be a gRPC status error")
assert.Equal(t, tt.wantCode, st.Code())
assert.Contains(t, st.Message(), tt.wantErrMessage)
assert.Nil(t, resp.GetNode())
} else {
require.NoError(t, err)
assert.NotNil(t, resp)
assert.NotNil(t, resp.GetNode())
}
})
}
}
// TestSetTags_TaggedNode tests that SetTags correctly identifies tagged nodes
// and doesn't reject them with the "user-owned nodes" error.
// Note: This test doesn't validate ACL tag authorization - that's tested elsewhere.
func TestSetTags_TaggedNode(t *testing.T) {
t.Parallel()
app := createTestApp(t)
// Create test user and tagged pre-auth key
user := app.state.CreateUserForTest("test-user")
pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, []string{"tag:initial"})
require.NoError(t, err)
machineKey := key.NewMachine()
nodeKey := key.NewNode()
// Register a tagged node (via tagged PreAuthKey)
taggedReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{
AuthKey: pak.Key,
},
NodeKey: nodeKey.Public(),
Hostinfo: &tailcfg.Hostinfo{
Hostname: "tagged-node",
},
}
_, err = app.handleRegisterWithAuthKey(taggedReq, machineKey.Public())
require.NoError(t, err)
// Get the created node
taggedNode, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
assert.True(t, taggedNode.IsTagged(), "Node should be tagged")
assert.False(t, taggedNode.UserID().Valid(), "Tagged node should not have UserID")
// Create API server instance
apiServer := newHeadscaleV1APIServer(app)
// Test: SetTags should work on tagged nodes.
resp, err := apiServer.SetTags(context.Background(), &v1.SetTagsRequest{
NodeId: uint64(taggedNode.ID()),
Tags: []string{"tag:initial"}, // Keep existing tag to avoid ACL validation issues
})
// The call should NOT fail with "cannot set tags on user-owned nodes"
if err != nil {
st, ok := status.FromError(err)
require.True(t, ok)
// If error is about unauthorized tags, that's fine - ACL validation is working
// If error is about user-owned nodes, that's the bug we're testing for
assert.NotContains(t, st.Message(), "user-owned nodes", "Should not reject tagged nodes as user-owned")
} else {
// Success is also fine
assert.NotNil(t, resp)
}
}
// TestSetTags_CannotRemoveAllTags tests that SetTags rejects attempts to remove
// all tags from a tagged node, enforcing Tailscale's requirement that tagged
// nodes must have at least one tag.
func TestSetTags_CannotRemoveAllTags(t *testing.T) {
t.Parallel()
app := createTestApp(t)
// Create test user and tagged pre-auth key
user := app.state.CreateUserForTest("test-user")
pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, []string{"tag:server"})
require.NoError(t, err)
machineKey := key.NewMachine()
nodeKey := key.NewNode()
// Register a tagged node
taggedReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{
AuthKey: pak.Key,
},
NodeKey: nodeKey.Public(),
Hostinfo: &tailcfg.Hostinfo{
Hostname: "tagged-node",
},
}
_, err = app.handleRegisterWithAuthKey(taggedReq, machineKey.Public())
require.NoError(t, err)
// Get the created node
taggedNode, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
assert.True(t, taggedNode.IsTagged())
// Create API server instance
apiServer := newHeadscaleV1APIServer(app)
// Attempt to remove all tags (empty array)
resp, err := apiServer.SetTags(context.Background(), &v1.SetTagsRequest{
NodeId: uint64(taggedNode.ID()),
Tags: []string{}, // Empty - attempting to remove all tags
})
// Should fail with InvalidArgument error
require.Error(t, err)
st, ok := status.FromError(err)
require.True(t, ok, "error should be a gRPC status error")
assert.Equal(t, codes.InvalidArgument, st.Code())
assert.Contains(t, st.Message(), "cannot remove all tags")
assert.Nil(t, resp.GetNode())
}
// TestSetTags_ClearsUserIDInDatabase tests that converting a user-owned node
// to a tagged node via SetTags correctly persists user_id = NULL in the
// database, not just in-memory.
// https://github.com/juanfont/headscale/issues/3161
func TestSetTags_ClearsUserIDInDatabase(t *testing.T) {
t.Parallel()
app := createTestApp(t)
user := app.state.CreateUserForTest("tag-owner")
err := app.state.UpdatePolicyManagerUsersForTest()
require.NoError(t, err)
_, err = app.state.SetPolicy([]byte(`{
"tagOwners": {"tag:server": ["tag-owner@"]},
"acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}]
}`))
require.NoError(t, err)
// Register a user-owned node (untagged PreAuthKey).
pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, nil)
require.NoError(t, err)
machineKey := key.NewMachine()
nodeKey := key.NewNode()
regReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{
AuthKey: pak.Key,
},
NodeKey: nodeKey.Public(),
Hostinfo: &tailcfg.Hostinfo{
Hostname: "user-owned-node",
},
}
_, err = app.handleRegisterWithAuthKey(regReq, machineKey.Public())
require.NoError(t, err)
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
require.False(t, node.IsTagged(), "node should start as user-owned")
require.True(t, node.UserID().Valid(), "user-owned node must have UserID")
nodeID := node.ID()
// Convert to tagged via SetTags API.
apiServer := newHeadscaleV1APIServer(app)
_, err = apiServer.SetTags(context.Background(), &v1.SetTagsRequest{
NodeId: uint64(nodeID),
Tags: []string{"tag:server"},
})
require.NoError(t, err)
// Verify in-memory state is correct.
nsNode, found := app.state.GetNodeByID(nodeID)
require.True(t, found)
assert.True(t, nsNode.IsTagged(), "NodeStore: node should be tagged")
assert.False(t, nsNode.UserID().Valid(),
"NodeStore: UserID should be nil for tagged node")
// THE CRITICAL CHECK: verify database has user_id = NULL.
dbNode, err := app.state.DB().GetNodeByID(nodeID)
require.NoError(t, err)
assert.Nil(t, dbNode.UserID,
"Database: user_id must be NULL after converting to tagged node")
assert.True(t, dbNode.IsTagged(),
"Database: tags must be set")
}
// TestSetTags_NodeDisappearsFromUserListing tests issue #3161:
// after converting a user-owned node to tagged, it must no longer appear
// when listing nodes filtered by the original user.
// https://github.com/juanfont/headscale/issues/3161
func TestSetTags_NodeDisappearsFromUserListing(t *testing.T) {
t.Parallel()
app := createTestApp(t)
user := app.state.CreateUserForTest("list-user")
err := app.state.UpdatePolicyManagerUsersForTest()
require.NoError(t, err)
_, err = app.state.SetPolicy([]byte(`{
"tagOwners": {"tag:web": ["list-user@"]},
"acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}]
}`))
require.NoError(t, err)
// Register a user-owned node.
pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, nil)
require.NoError(t, err)
machineKey := key.NewMachine()
nodeKey := key.NewNode()
regReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{
AuthKey: pak.Key,
},
NodeKey: nodeKey.Public(),
Hostinfo: &tailcfg.Hostinfo{
Hostname: "web-server",
},
}
_, err = app.handleRegisterWithAuthKey(regReq, machineKey.Public())
require.NoError(t, err)
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
// Verify node appears under user before tagging.
apiServer := newHeadscaleV1APIServer(app)
resp, err := apiServer.ListNodes(context.Background(), &v1.ListNodesRequest{
User: "list-user",
})
require.NoError(t, err)
assert.Len(t, resp.GetNodes(), 1, "user-owned node should appear under user")
// Convert to tagged.
_, err = apiServer.SetTags(context.Background(), &v1.SetTagsRequest{
NodeId: uint64(node.ID()),
Tags: []string{"tag:web"},
})
require.NoError(t, err)
// Node must NOT appear when listing by original user.
resp, err = apiServer.ListNodes(context.Background(), &v1.ListNodesRequest{
User: "list-user",
})
require.NoError(t, err)
assert.Empty(t, resp.GetNodes(),
"tagged node must not appear when listing nodes for original user")
// Node must still appear in unfiltered listing.
allResp, err := apiServer.ListNodes(context.Background(), &v1.ListNodesRequest{})
require.NoError(t, err)
require.Len(t, allResp.GetNodes(), 1)
assert.Contains(t, allResp.GetNodes()[0].GetTags(), "tag:web")
}
// TestSetTags_NodeStoreAndDBConsistency verifies that after SetTags, the
// in-memory NodeStore and the database agree on the node's ownership state.
// https://github.com/juanfont/headscale/issues/3161
func TestSetTags_NodeStoreAndDBConsistency(t *testing.T) {
t.Parallel()
app := createTestApp(t)
user := app.state.CreateUserForTest("consistency-user")
err := app.state.UpdatePolicyManagerUsersForTest()
require.NoError(t, err)
_, err = app.state.SetPolicy([]byte(`{
"tagOwners": {"tag:db": ["consistency-user@"]},
"acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}]
}`))
require.NoError(t, err)
pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, nil)
require.NoError(t, err)
machineKey := key.NewMachine()
nodeKey := key.NewNode()
regReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{
AuthKey: pak.Key,
},
NodeKey: nodeKey.Public(),
Hostinfo: &tailcfg.Hostinfo{
Hostname: "db-node",
},
}
_, err = app.handleRegisterWithAuthKey(regReq, machineKey.Public())
require.NoError(t, err)
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
nodeID := node.ID()
// Convert to tagged.
apiServer := newHeadscaleV1APIServer(app)
_, err = apiServer.SetTags(context.Background(), &v1.SetTagsRequest{
NodeId: uint64(nodeID),
Tags: []string{"tag:db"},
})
require.NoError(t, err)
// In-memory state.
nsNode, found := app.state.GetNodeByID(nodeID)
require.True(t, found)
// Database state.
dbNode, err := app.state.DB().GetNodeByID(nodeID)
require.NoError(t, err)
// Both must agree: tagged, no UserID.
assert.True(t, nsNode.IsTagged(), "NodeStore: should be tagged")
assert.True(t, dbNode.IsTagged(), "Database: should be tagged")
assert.False(t, nsNode.UserID().Valid(),
"NodeStore: UserID should be nil")
assert.Nil(t, dbNode.UserID,
"Database: user_id should be NULL")
assert.Equal(t,
nsNode.UserID().Valid(),
dbNode.UserID != nil,
"NodeStore and database must agree on UserID state")
}
// TestSetTags_UserDeletionDoesNotCascadeToTaggedNode tests that deleting the
// original user does not cascade-delete a node that was converted to tagged
// via SetTags. This catches the real-world consequence of stale user_id:
// ON DELETE CASCADE would destroy the tagged node.
// https://github.com/juanfont/headscale/issues/3161
func TestSetTags_UserDeletionDoesNotCascadeToTaggedNode(t *testing.T) {
t.Parallel()
app := createTestApp(t)
user := app.state.CreateUserForTest("doomed-user")
err := app.state.UpdatePolicyManagerUsersForTest()
require.NoError(t, err)
_, err = app.state.SetPolicy([]byte(`{
"tagOwners": {"tag:survivor": ["doomed-user@"]},
"acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}]
}`))
require.NoError(t, err)
pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, nil)
require.NoError(t, err)
machineKey := key.NewMachine()
nodeKey := key.NewNode()
regReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{
AuthKey: pak.Key,
},
NodeKey: nodeKey.Public(),
Hostinfo: &tailcfg.Hostinfo{
Hostname: "survivor-node",
},
}
_, err = app.handleRegisterWithAuthKey(regReq, machineKey.Public())
require.NoError(t, err)
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
nodeID := node.ID()
// Convert to tagged.
apiServer := newHeadscaleV1APIServer(app)
_, err = apiServer.SetTags(context.Background(), &v1.SetTagsRequest{
NodeId: uint64(nodeID),
Tags: []string{"tag:survivor"},
})
require.NoError(t, err)
// Delete the original user.
_, err = app.state.DeleteUser(*user.TypedID())
require.NoError(t, err)
// The tagged node must survive in both NodeStore and database.
nsNode, found := app.state.GetNodeByID(nodeID)
require.True(t, found, "tagged node must survive user deletion in NodeStore")
assert.True(t, nsNode.IsTagged())
dbNode, err := app.state.DB().GetNodeByID(nodeID)
require.NoError(t, err, "tagged node must survive user deletion in database")
assert.True(t, dbNode.IsTagged())
assert.Nil(t, dbNode.UserID)
}
// TestDeleteUser_ReturnsProperChangeSignal tests issue #2967 fix:
// When a user is deleted, the state should return a non-empty change signal
// to ensure policy manager is updated and clients are notified immediately.
func TestDeleteUser_ReturnsProperChangeSignal(t *testing.T) {
t.Parallel()
app := createTestApp(t)
// Create a user
user := app.state.CreateUserForTest("test-user-to-delete")
require.NotNil(t, user)
// Delete the user and verify a non-empty change is returned
// Issue #2967: Without the fix, DeleteUser returned an empty change,
// causing stale policy state until another user operation triggered an update.
changeSignal, err := app.state.DeleteUser(*user.TypedID())
require.NoError(t, err, "DeleteUser should succeed")
assert.False(t, changeSignal.IsEmpty(), "DeleteUser should return a non-empty change signal (issue #2967)")
}
// TestDeleteUser_TaggedNodeSurvives tests that deleting a user succeeds when
// the user's only nodes are tagged, and that those nodes remain in the
// NodeStore with nil UserID.
// https://github.com/juanfont/headscale/issues/3077
func TestDeleteUser_TaggedNodeSurvives(t *testing.T) {
t.Parallel()
app := createTestApp(t)
user := app.state.CreateUserForTest("legacy-user")
// Register a tagged node via the full auth flow.
tags := []string{"tag:server"}
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, tags)
require.NoError(t, err)
machineKey := key.NewMachine()
nodeKey := key.NewNode()
regReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{
AuthKey: pak.Key,
},
NodeKey: nodeKey.Public(),
Hostinfo: &tailcfg.Hostinfo{
Hostname: "tagged-server",
},
Expiry: time.Now().Add(24 * time.Hour),
}
resp, err := app.handleRegisterWithAuthKey(regReq, machineKey.Public())
require.NoError(t, err)
require.True(t, resp.MachineAuthorized)
// Verify the registered node has nil UserID (enforced invariant).
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
require.True(t, node.IsTagged())
assert.False(t, node.UserID().Valid(),
"tagged node should have nil UserID after registration")
nodeID := node.ID()
// NodeStore should not list the tagged node under any user.
nodesForUser := app.state.ListNodesByUser(types.UserID(user.ID))
assert.Equal(t, 0, nodesForUser.Len(),
"tagged nodes should not appear in nodesByUser index")
// Delete the user.
changeSignal, err := app.state.DeleteUser(*user.TypedID())
require.NoError(t, err)
assert.False(t, changeSignal.IsEmpty())
// Tagged node survives in the NodeStore.
nodeAfter, found := app.state.GetNodeByID(nodeID)
require.True(t, found, "tagged node should survive user deletion")
assert.True(t, nodeAfter.IsTagged())
assert.False(t, nodeAfter.UserID().Valid())
// Tagged node appears in the global list.
allNodes := app.state.ListNodes()
foundInAll := false
for _, n := range allNodes.All() {
if n.ID() == nodeID {
foundInAll = true
break
}
}
assert.True(t, foundInAll, "tagged node should appear in the global node list")
}
// TestExpireApiKey_ByID tests that API keys can be expired by ID.
func TestExpireApiKey_ByID(t *testing.T) {
t.Parallel()
app := createTestApp(t)
apiServer := newHeadscaleV1APIServer(app)
// Create an API key
createResp, err := apiServer.CreateApiKey(context.Background(), &v1.CreateApiKeyRequest{})
require.NoError(t, err)
require.NotEmpty(t, createResp.GetApiKey())
// List keys to get the ID
listResp, err := apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{})
require.NoError(t, err)
require.Len(t, listResp.GetApiKeys(), 1)
keyID := listResp.GetApiKeys()[0].GetId()
// Expire by ID
_, err = apiServer.ExpireApiKey(context.Background(), &v1.ExpireApiKeyRequest{
Id: keyID,
})
require.NoError(t, err)
// Verify key is expired (expiration is set to now or in the past)
listResp, err = apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{})
require.NoError(t, err)
require.Len(t, listResp.GetApiKeys(), 1)
assert.NotNil(t, listResp.GetApiKeys()[0].GetExpiration(), "expiration should be set")
}
// TestExpireApiKey_ByPrefix tests that API keys can still be expired by prefix.
func TestExpireApiKey_ByPrefix(t *testing.T) {
t.Parallel()
app := createTestApp(t)
apiServer := newHeadscaleV1APIServer(app)
// Create an API key
createResp, err := apiServer.CreateApiKey(context.Background(), &v1.CreateApiKeyRequest{})
require.NoError(t, err)
require.NotEmpty(t, createResp.GetApiKey())
// List keys to get the prefix
listResp, err := apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{})
require.NoError(t, err)
require.Len(t, listResp.GetApiKeys(), 1)
keyPrefix := listResp.GetApiKeys()[0].GetPrefix()
// Expire by prefix
_, err = apiServer.ExpireApiKey(context.Background(), &v1.ExpireApiKeyRequest{
Prefix: keyPrefix,
})
require.NoError(t, err)
}
// TestDeleteApiKey_ByID tests that API keys can be deleted by ID.
func TestDeleteApiKey_ByID(t *testing.T) {
t.Parallel()
app := createTestApp(t)
apiServer := newHeadscaleV1APIServer(app)
// Create an API key
createResp, err := apiServer.CreateApiKey(context.Background(), &v1.CreateApiKeyRequest{})
require.NoError(t, err)
require.NotEmpty(t, createResp.GetApiKey())
// List keys to get the ID
listResp, err := apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{})
require.NoError(t, err)
require.Len(t, listResp.GetApiKeys(), 1)
keyID := listResp.GetApiKeys()[0].GetId()
// Delete by ID
_, err = apiServer.DeleteApiKey(context.Background(), &v1.DeleteApiKeyRequest{
Id: keyID,
})
require.NoError(t, err)
// Verify key is deleted
listResp, err = apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{})
require.NoError(t, err)
assert.Empty(t, listResp.GetApiKeys())
}
// TestDeleteApiKey_ByPrefix tests that API keys can still be deleted by prefix.
func TestDeleteApiKey_ByPrefix(t *testing.T) {
t.Parallel()
app := createTestApp(t)
apiServer := newHeadscaleV1APIServer(app)
// Create an API key
createResp, err := apiServer.CreateApiKey(context.Background(), &v1.CreateApiKeyRequest{})
require.NoError(t, err)
require.NotEmpty(t, createResp.GetApiKey())
// List keys to get the prefix
listResp, err := apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{})
require.NoError(t, err)
require.Len(t, listResp.GetApiKeys(), 1)
keyPrefix := listResp.GetApiKeys()[0].GetPrefix()
// Delete by prefix
_, err = apiServer.DeleteApiKey(context.Background(), &v1.DeleteApiKeyRequest{
Prefix: keyPrefix,
})
require.NoError(t, err)
// Verify key is deleted
listResp, err = apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{})
require.NoError(t, err)
assert.Empty(t, listResp.GetApiKeys())
}
// TestExpireApiKey_NoIdentifier tests that an error is returned when neither ID nor prefix is provided.
func TestExpireApiKey_NoIdentifier(t *testing.T) {
t.Parallel()
app := createTestApp(t)
apiServer := newHeadscaleV1APIServer(app)
_, err := apiServer.ExpireApiKey(context.Background(), &v1.ExpireApiKeyRequest{})
require.Error(t, err)
st, ok := status.FromError(err)
require.True(t, ok, "error should be a gRPC status error")
assert.Equal(t, codes.InvalidArgument, st.Code())
assert.Contains(t, st.Message(), "must provide id or prefix")
}
// TestDeleteApiKey_NoIdentifier tests that an error is returned when neither ID nor prefix is provided.
func TestDeleteApiKey_NoIdentifier(t *testing.T) {
t.Parallel()
app := createTestApp(t)
apiServer := newHeadscaleV1APIServer(app)
_, err := apiServer.DeleteApiKey(context.Background(), &v1.DeleteApiKeyRequest{})
require.Error(t, err)
st, ok := status.FromError(err)
require.True(t, ok, "error should be a gRPC status error")
assert.Equal(t, codes.InvalidArgument, st.Code())
assert.Contains(t, st.Message(), "must provide id or prefix")
}
// TestExpireApiKey_BothIdentifiers tests that an error is returned when both ID and prefix are provided.
func TestExpireApiKey_BothIdentifiers(t *testing.T) {
t.Parallel()
app := createTestApp(t)
apiServer := newHeadscaleV1APIServer(app)
_, err := apiServer.ExpireApiKey(context.Background(), &v1.ExpireApiKeyRequest{
Id: 1,
Prefix: "test",
})
require.Error(t, err)
st, ok := status.FromError(err)
require.True(t, ok, "error should be a gRPC status error")
assert.Equal(t, codes.InvalidArgument, st.Code())
assert.Contains(t, st.Message(), "provide either id or prefix, not both")
}
// TestDeleteApiKey_BothIdentifiers tests that an error is returned when both ID and prefix are provided.
func TestDeleteApiKey_BothIdentifiers(t *testing.T) {
t.Parallel()
app := createTestApp(t)
apiServer := newHeadscaleV1APIServer(app)
_, err := apiServer.DeleteApiKey(context.Background(), &v1.DeleteApiKeyRequest{
Id: 1,
Prefix: "test",
})
require.Error(t, err)
st, ok := status.FromError(err)
require.True(t, ok, "error should be a gRPC status error")
assert.Equal(t, codes.InvalidArgument, st.Code())
assert.Contains(t, st.Message(), "provide either id or prefix, not both")
}