Files
headscale/hscontrol/db/users_test.go
Kristoffer Dalby 7a20db9f49 types: persist Node JSON slices via named IsZero types
Endpoints, Tags and ApprovedRoutes serialize as JSON on Node. GORM's
struct Updates path skips fields it considers zero, and reflect treats
a nil slice as zero — clearing any of these columns via the State
persist path would leave the previous value in the database.

Introduce Strings, Prefixes and AddrPorts as named slice types whose
IsZero() always reports false, so GORM keeps the column in the UPDATE
regardless of the slice being nil or empty. JSON marshalling is
unchanged: nil serializes to null, empty to []. List() returns the
underlying unnamed slice for callers (mainly testify assertions over
reflect.DeepEqual) that distinguish the named type from its base.

Regenerated types_clone.go and types_view.go follow the field-type
swap. Test assertions across hscontrol/{db,state,servertest} updated
to call .List() where reflect.DeepEqual previously matched the raw
slice type.

Fixes #3110
2026-05-15 11:21:58 +02:00

282 lines
7.3 KiB
Go

package db
import (
"testing"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestCreateAndDestroyUser(t *testing.T) {
db, err := newSQLiteTestDB()
require.NoError(t, err)
user := db.CreateUserForTest("test")
assert.Equal(t, "test", user.Name)
users, err := db.ListUsers()
require.NoError(t, err)
assert.Len(t, users, 1)
err = db.DestroyUser(types.UserID(user.ID))
require.NoError(t, err)
_, err = db.GetUserByID(types.UserID(user.ID))
assert.Error(t, err)
}
func TestDestroyUserErrors(t *testing.T) {
tests := []struct {
name string
test func(*testing.T, *HSDatabase)
}{
{
name: "error_user_not_found",
test: func(t *testing.T, db *HSDatabase) {
t.Helper()
err := db.DestroyUser(9998)
assert.ErrorIs(t, err, ErrUserNotFound)
},
},
{
name: "success_deletes_preauthkeys",
test: func(t *testing.T, db *HSDatabase) {
t.Helper()
user := db.CreateUserForTest("test")
pak, err := db.CreatePreAuthKey(user.TypedID(), false, false, nil, nil)
require.NoError(t, err)
err = db.DestroyUser(types.UserID(user.ID))
require.NoError(t, err)
// Verify preauth key was deleted (need to search by prefix for new keys)
var foundPak types.PreAuthKey
result := db.DB.First(&foundPak, "id = ?", pak.ID)
assert.ErrorIs(t, result.Error, gorm.ErrRecordNotFound)
},
},
{
name: "error_user_has_nodes",
test: func(t *testing.T, db *HSDatabase) {
t.Helper()
user, err := db.CreateUser(types.User{Name: "test"})
require.NoError(t, err)
pak, err := db.CreatePreAuthKey(user.TypedID(), false, false, nil, nil)
require.NoError(t, err)
pakID := pak.ID
node := types.Node{
ID: 0,
Hostname: "testnode",
UserID: &user.ID,
RegisterMethod: util.RegisterMethodAuthKey,
AuthKeyID: &pakID,
}
trx := db.DB.Save(&node)
require.NoError(t, trx.Error)
err = db.DestroyUser(types.UserID(user.ID))
assert.ErrorIs(t, err, ErrUserStillHasNodes)
},
},
{
// https://github.com/juanfont/headscale/issues/3077
// Tagged nodes have user_id = NULL, so they do not block
// user deletion and are unaffected by ON DELETE CASCADE.
name: "success_user_only_has_tagged_nodes",
test: func(t *testing.T, db *HSDatabase) {
t.Helper()
user, err := db.CreateUser(types.User{Name: "test"})
require.NoError(t, err)
// Create a tagged node with no user_id (the rule for tagged nodes).
node := types.Node{
ID: 0,
Hostname: "tagged-node",
RegisterMethod: util.RegisterMethodAuthKey,
Tags: []string{"tag:server"},
}
trx := db.DB.Save(&node)
require.NoError(t, trx.Error)
err = db.DestroyUser(types.UserID(user.ID))
require.NoError(t, err)
// User is gone.
_, err = db.GetUserByID(types.UserID(user.ID))
require.ErrorIs(t, err, ErrUserNotFound)
// Tagged node survives.
var survivingNode types.Node
result := db.DB.First(&survivingNode, "id = ?", node.ID)
require.NoError(t, result.Error)
assert.Nil(t, survivingNode.UserID)
assert.Equal(t, []string{"tag:server"}, survivingNode.Tags.List())
},
},
{
// A user who has both tagged and user-owned nodes cannot
// be deleted; the user-owned nodes still block deletion.
name: "error_user_has_tagged_and_owned_nodes",
test: func(t *testing.T, db *HSDatabase) {
t.Helper()
user, err := db.CreateUser(types.User{Name: "test"})
require.NoError(t, err)
// Tagged node: no user_id.
taggedNode := types.Node{
ID: 0,
Hostname: "tagged-node",
RegisterMethod: util.RegisterMethodAuthKey,
Tags: []string{"tag:server"},
}
trx := db.DB.Save(&taggedNode)
require.NoError(t, trx.Error)
// User-owned node: has user_id.
ownedNode := types.Node{
ID: 0,
Hostname: "owned-node",
UserID: &user.ID,
RegisterMethod: util.RegisterMethodAuthKey,
}
trx = db.DB.Save(&ownedNode)
require.NoError(t, trx.Error)
err = db.DestroyUser(types.UserID(user.ID))
require.ErrorIs(t, err, ErrUserStillHasNodes)
},
},
{
// Regression test for https://github.com/juanfont/headscale/issues/3154
// DestroyUser must only delete the target user's pre-auth keys,
// not all pre-auth keys in the database.
name: "success_only_deletes_own_preauthkeys",
test: func(t *testing.T, db *HSDatabase) {
t.Helper()
userA := db.CreateUserForTest("usera")
userB := db.CreateUserForTest("userb")
// Create 2 keys for userA, 1 key for userB.
_, err := db.CreatePreAuthKey(userA.TypedID(), false, false, nil, nil)
require.NoError(t, err)
_, err = db.CreatePreAuthKey(userA.TypedID(), false, false, nil, nil)
require.NoError(t, err)
_, err = db.CreatePreAuthKey(userB.TypedID(), false, false, nil, nil)
require.NoError(t, err)
// Sanity check: 3 keys exist.
allKeys, err := db.ListPreAuthKeys()
require.NoError(t, err)
require.Len(t, allKeys, 3)
// Delete userB.
err = db.DestroyUser(types.UserID(userB.ID))
require.NoError(t, err)
// Only userA's 2 keys should remain.
remaining, err := db.ListPreAuthKeys()
require.NoError(t, err)
assert.Len(t, remaining, 2,
"expected 2 keys for userA, got %d — DestroyUser deleted keys from other users",
len(remaining))
for _, key := range remaining {
assert.NotNil(t, key.UserID)
assert.Equal(t, userA.ID, *key.UserID,
"remaining key should belong to userA")
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db, err := newSQLiteTestDB()
require.NoError(t, err)
tt.test(t, db)
})
}
}
func TestRenameUser(t *testing.T) {
tests := []struct {
name string
test func(*testing.T, *HSDatabase)
}{
{
name: "success_rename",
test: func(t *testing.T, db *HSDatabase) {
t.Helper()
userTest := db.CreateUserForTest("test")
assert.Equal(t, "test", userTest.Name)
users, err := db.ListUsers()
require.NoError(t, err)
assert.Len(t, users, 1)
err = db.RenameUser(types.UserID(userTest.ID), "test-renamed")
require.NoError(t, err)
users, err = db.ListUsers(&types.User{Name: "test"})
require.NoError(t, err)
assert.Empty(t, users)
users, err = db.ListUsers(&types.User{Name: "test-renamed"})
require.NoError(t, err)
assert.Len(t, users, 1)
},
},
{
name: "error_user_not_found",
test: func(t *testing.T, db *HSDatabase) {
t.Helper()
err := db.RenameUser(99988, "test")
assert.ErrorIs(t, err, ErrUserNotFound)
},
},
{
name: "error_duplicate_name",
test: func(t *testing.T, db *HSDatabase) {
t.Helper()
userTest := db.CreateUserForTest("test")
userTest2 := db.CreateUserForTest("test2")
assert.Equal(t, "test", userTest.Name)
assert.Equal(t, "test2", userTest2.Name)
err := db.RenameUser(types.UserID(userTest2.ID), "test")
require.Error(t, err)
assert.Contains(t, err.Error(), "UNIQUE constraint failed")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db, err := newSQLiteTestDB()
require.NoError(t, err)
tt.test(t, db)
})
}
}