kubernetes/cmd/kubeadm/app/util/users/users_linux_test.go

//go:build linux
// +build linux

/*
Copyright 2021 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package users

import (
	"os"
	"reflect"
	"testing"
)

func TestParseLoginDef(t *testing.T) {
	testCases := []struct {
		name           string
		input          string
		expectedLimits *limits
		expectedError  bool
	}{
		{
			name:          "non number value for tracked limit",
			input:         "SYS_UID_MIN foo\n",
			expectedError: true,
		},
		{
			name:           "empty string must return defaults",
			expectedLimits: defaultLimits,
		},
		{
			name:           "no tracked limits in file must return defaults",
			input:          "# some comment\n",
			expectedLimits: defaultLimits,
		},
		{
			name:           "must parse all valid tracked limits",
			input:          "SYS_UID_MIN 101\nSYS_UID_MAX 998\nSYS_GID_MIN 102\nSYS_GID_MAX 999\n",
			expectedLimits: &limits{minUID: 101, maxUID: 998, minGID: 102, maxGID: 999},
		},
		{
			name:           "must return defaults for missing limits",
			input:          "SYS_UID_MIN 101\n#SYS_UID_MAX 998\nSYS_GID_MIN 102\n#SYS_GID_MAX 999\n",
			expectedLimits: &limits{minUID: 101, maxUID: defaultLimits.maxUID, minGID: 102, maxGID: defaultLimits.maxGID},
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			got, err := parseLoginDefs(tc.input)
			if err != nil != tc.expectedError {
				t.Fatalf("expected error: %v, got: %v, error: %v", tc.expectedError, err != nil, err)
			}
			if err == nil && *tc.expectedLimits != *got {
				t.Fatalf("expected limits %+v, got %+v", tc.expectedLimits, got)
			}
		})
	}
}

func TestParseEntries(t *testing.T) {
	testCases := []struct {
		name            string
		file            string
		expectedEntries []*entry
		totalFields     int
		expectedError   bool
	}{
		{
			name:          "totalFields must be a known value",
			expectedError: true,
		},
		{
			name:          "unexpected number of fields",
			file:          "foo:x:100::::::",
			totalFields:   totalFieldsUser,
			expectedError: true,
		},
		{
			name:          "cannot parse 'bar' as UID",
			file:          "foo:x:bar:101:::\n",
			totalFields:   totalFieldsUser,
			expectedError: true,
		},
		{
			name:          "cannot parse 'bar' as GID",
			file:          "foo:x:101:bar:::\n",
			totalFields:   totalFieldsUser,
			expectedError: true,
		},
		{
			name:        "valid file for users",
			file:        "\nfoo:x:100:101:foo:/home/foo:/bin/bash\n\nbar:x:102:103:bar::\n",
			totalFields: totalFieldsUser,
			expectedEntries: []*entry{
				{name: "foo", id: 100, gid: 101, shell: "/bin/bash"},
				{name: "bar", id: 102, gid: 103},
			},
		},
		{
			name:        "valid file for groups",
			file:        "\nfoo:x:100:bar,baz\n\nbar:x:101:baz\n",
			totalFields: totalFieldsGroup,
			expectedEntries: []*entry{
				{name: "foo", id: 100, userNames: []string{"bar", "baz"}},
				{name: "bar", id: 101, userNames: []string{"baz"}},
			},
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			got, err := parseEntries(tc.file, tc.totalFields)
			if err != nil != tc.expectedError {
				t.Fatalf("expected error: %v, got: %v, error: %v", tc.expectedError, err != nil, err)
			}
			if err != nil {
				return
			}
			if len(tc.expectedEntries) != len(got) {
				t.Fatalf("expected entries %d, got %d", len(tc.expectedEntries), len(got))
			}
			for i := range got {
				if !reflect.DeepEqual(tc.expectedEntries[i], got[i]) {
					t.Fatalf("expected entry at position %d: %+v, got: %+v", i, tc.expectedEntries[i], got[i])
				}
			}
		})
	}
}

func TestValidateEntries(t *testing.T) {
	testCases := []struct {
		name           string
		users          []*entry
		groups         []*entry
		expectedUsers  []*entry
		expectedGroups []*entry
		expectedError  bool
	}{
		{
			name: "UID for user is outside of system limits",
			users: []*entry{
				{name: "kubeadm-etcd", id: 2000, gid: 102, shell: noshell},
			},
			groups:        []*entry{},
			expectedError: true,
		},
		{
			name: "user has unexpected shell",
			users: []*entry{
				{name: "kubeadm-etcd", id: 102, gid: 102, shell: "foo"},
			},
			groups:        []*entry{},
			expectedError: true,
		},
		{
			name: "user is mapped to unknown group",
			users: []*entry{
				{name: "kubeadm-etcd", id: 102, gid: 102, shell: noshell},
			},
			groups:        []*entry{},
			expectedError: true,
		},
		{
			name: "user and group names do not match",
			users: []*entry{
				{name: "kubeadm-etcd", id: 102, gid: 102, shell: noshell},
			},
			groups: []*entry{
				{name: "foo", id: 102},
			},
			expectedError: true,
		},
		{
			name:  "GID is outside system limits",
			users: []*entry{},
			groups: []*entry{
				{name: "kubeadm-etcd", id: 2000},
			},
			expectedError: true,
		},
		{
			name:  "group is missing users",
			users: []*entry{},
			groups: []*entry{
				{name: "kubeadm-etcd", id: 100},
			},
			expectedError: true,
		},
		{
			name:           "empty input must return default users and groups",
			users:          []*entry{},
			groups:         []*entry{},
			expectedUsers:  usersToCreateSpec,
			expectedGroups: groupsToCreateSpec,
		},
		{
			name: "existing valid users mapped to groups",
			users: []*entry{
				{name: "kubeadm-etcd", id: 100, gid: 102, shell: noshell},
				{name: "kubeadm-kas", id: 101, gid: 103, shell: noshell},
			},
			groups: []*entry{
				{name: "kubeadm-etcd", id: 102, userNames: []string{"kubeadm-etcd"}},
				{name: "kubeadm-kas", id: 103, userNames: []string{"kubeadm-kas"}},
				{name: "kubeadm-sa-key-readers", id: 104, userNames: []string{"kubeadm-kas", "kubeadm-kcm"}},
			},
			expectedUsers: []*entry{
				{name: "kubeadm-kcm"},
				{name: "kubeadm-ks"},
			},
			expectedGroups: []*entry{
				{name: "kubeadm-kcm", userNames: []string{"kubeadm-kcm"}},
				{name: "kubeadm-ks", userNames: []string{"kubeadm-ks"}},
			},
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			users, groups, err := validateEntries(tc.users, tc.groups, defaultLimits)
			if err != nil != tc.expectedError {
				t.Fatalf("expected error: %v, got: %v, error: %v", tc.expectedError, err != nil, err)
			}
			if err != nil {
				return
			}
			if len(tc.expectedUsers) != len(users) {
				t.Fatalf("expected users %d, got %d", len(tc.expectedUsers), len(users))
			}
			for i := range users {
				if !reflect.DeepEqual(tc.expectedUsers[i], users[i]) {
					t.Fatalf("expected user at position %d: %+v, got: %+v", i, tc.expectedUsers[i], users[i])
				}
			}
			if len(tc.expectedGroups) != len(groups) {
				t.Fatalf("expected groups %d, got %d", len(tc.expectedGroups), len(groups))
			}
			for i := range groups {
				if !reflect.DeepEqual(tc.expectedGroups[i], groups[i]) {
					t.Fatalf("expected group at position %d: %+v, got: %+v", i, tc.expectedGroups[i], groups[i])
				}
			}
		})
	}
}

func TestAllocateIDs(t *testing.T) {
	testCases := []struct {
		name          string
		entries       []*entry
		min           int64
		max           int64
		total         int
		expectedIDs   []int64
		expectedError bool
	}{
		{
			name:        "zero total ids returns empty slice",
			expectedIDs: []int64{},
		},
		{
			name: "not enough free ids in range",
			entries: []*entry{
				{name: "foo", id: 101},
				{name: "bar", id: 103},
				{name: "baz", id: 105},
			},
			min:           100,
			max:           105,
			total:         4,
			expectedError: true,
		},
		{
			name: "successfully allocate ids",
			entries: []*entry{
				{name: "foo", id: 101},
				{name: "bar", id: 103},
				{name: "baz", id: 105},
			},
			min:           100,
			max:           110,
			total:         4,
			expectedIDs:   []int64{100, 102, 104, 106},
			expectedError: false,
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			got, err := allocateIDs(tc.entries, tc.min, tc.max, tc.total)
			if err != nil != tc.expectedError {
				t.Fatalf("expected error: %v, got: %v, error: %v", tc.expectedError, err != nil, err)
			}
			if err != nil {
				return
			}
			if len(tc.expectedIDs) != len(got) {
				t.Fatalf("expected id %d, got %d", len(tc.expectedIDs), len(got))
			}
			for i := range got {
				if !reflect.DeepEqual(tc.expectedIDs[i], got[i]) {
					t.Fatalf("expected id at position %d: %+v, got: %+v", i, tc.expectedIDs[i], got[i])
				}
			}
		})
	}
}

func TestAddEntries(t *testing.T) {
	testCases := []struct {
		name           string
		file           string
		entries        []*entry
		createEntry    func(*entry) string
		expectedOutput string
	}{
		{
			name: "user entries are added",
			file: "foo:x:101:101:::/bin/false\n",
			entries: []*entry{
				{name: "bar", id: 102, gid: 102},
				{name: "baz", id: 103, gid: 103},
			},
			expectedOutput: "foo:x:101:101:::/bin/false\nbar:x:102:102:::/bin/false\nbaz:x:103:103:::/bin/false\n",
			createEntry:    createUser,
		},
		{
			name: "user entries are added (new line is appended)",
			file: "foo:x:101:101:::/bin/false",
			entries: []*entry{
				{name: "bar", id: 102, gid: 102},
			},
			expectedOutput: "foo:x:101:101:::/bin/false\nbar:x:102:102:::/bin/false\n",
			createEntry:    createUser,
		},
		{
			name: "group entries are added",
			file: "foo:x:101:foo\n",
			entries: []*entry{
				{name: "bar", id: 102, userNames: []string{"bar"}},
				{name: "baz", id: 103, userNames: []string{"baz"}},
			},
			expectedOutput: "foo:x:101:foo\nbar:x:102:bar\nbaz:x:103:baz\n",
			createEntry:    createGroup,
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			got := addEntries(tc.file, tc.entries, tc.createEntry)
			if tc.expectedOutput != got {
				t.Fatalf("expected output:\n%s\ngot:\n%s\n", tc.expectedOutput, got)
			}
		})
	}
}

func TestRemoveEntries(t *testing.T) {
	testCases := []struct {
		name            string
		file            string
		entries         []*entry
		expectedRemoved int
		expectedOutput  string
	}{
		{
			name:            "entries that are missing do not cause an error",
			file:            "foo:x:102:102:::/bin/false\nbar:x:103:103:::/bin/false\n",
			entries:         []*entry{},
			expectedRemoved: 0,
			expectedOutput:  "foo:x:102:102:::/bin/false\nbar:x:103:103:::/bin/false\n",
		},
		{
			name: "user entry is removed",
			file: "foo:x:102:102:::/bin/false\nbar:x:103:103:::/bin/false\n",
			entries: []*entry{
				{name: "bar"},
			},
			expectedRemoved: 1,
			expectedOutput:  "foo:x:102:102:::/bin/false\n",
		},
		{
			name: "group entry is removed",
			file: "foo:x:102:foo\nbar:x:102:bar\n",
			entries: []*entry{
				{name: "bar"},
			},
			expectedRemoved: 1,
			expectedOutput:  "foo:x:102:foo\n",
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			got, removed := removeEntries(tc.file, tc.entries)
			if tc.expectedRemoved != removed {
				t.Fatalf("expected entries to be removed: %v, got: %v", tc.expectedRemoved, removed)
			}
			if tc.expectedOutput != got {
				t.Fatalf("expected output:\n%s\ngot:\n%s\n", tc.expectedOutput, got)
			}
		})
	}
}

func TestAssignUserAndGroupIDs(t *testing.T) {
	testCases := []struct {
		name           string
		users          []*entry
		groups         []*entry
		usersToCreate  []*entry
		groupsToCreate []*entry
		uids           []int64
		gids           []int64
		expectedUsers  []*entry
		expectedGroups []*entry
		expectedError  bool
	}{
		{
			name: "not enough UIDs",
			usersToCreate: []*entry{
				{name: "foo"},
				{name: "bar"},
			},
			uids:          []int64{100},
			expectedError: true,
		},
		{
			name: "not enough GIDs",
			groupsToCreate: []*entry{
				{name: "foo"},
				{name: "bar"},
			},
			gids:          []int64{100},
			expectedError: true,
		},
		{
			name: "valid UIDs and GIDs are assigned to input",
			groups: []*entry{
				{name: "foo", id: 110},
				{name: "bar", id: 111},
			},
			usersToCreate: []*entry{
				{name: "foo"},
				{name: "bar"},
				{name: "baz"},
			},
			groupsToCreate: []*entry{
				{name: "baz"},
			},
			uids: []int64{100, 101, 102},
			gids: []int64{112},
			expectedUsers: []*entry{
				{name: "foo", id: 100, gid: 110},
				{name: "bar", id: 101, gid: 111},
				{name: "baz", id: 102, gid: 112},
			},
			expectedGroups: []*entry{
				{name: "baz", id: 112},
			},
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			err := assignUserAndGroupIDs(tc.groups, tc.usersToCreate, tc.groupsToCreate, tc.uids, tc.gids)
			if err != nil != tc.expectedError {
				t.Fatalf("expected error: %v, got: %v, error: %v", tc.expectedError, err != nil, err)
			}
			if err != nil {
				return
			}
			if len(tc.expectedUsers) != len(tc.usersToCreate) {
				t.Fatalf("expected users %d, got %d", len(tc.expectedUsers), len(tc.usersToCreate))
			}
			for i := range tc.usersToCreate {
				if !reflect.DeepEqual(tc.expectedUsers[i], tc.usersToCreate[i]) {
					t.Fatalf("expected user at position %d: %+v, got: %+v", i, tc.expectedUsers[i], tc.usersToCreate[i])
				}
			}
			if len(tc.expectedGroups) != len(tc.groupsToCreate) {
				t.Fatalf("expected groups %d, got %d", len(tc.expectedGroups), len(tc.groupsToCreate))
			}
			for i := range tc.groupsToCreate {
				if !reflect.DeepEqual(tc.expectedGroups[i], tc.groupsToCreate[i]) {
					t.Fatalf("expected group at position %d: %+v, got: %+v", i, tc.expectedGroups[i], tc.groupsToCreate[i])
				}
			}
		})
	}
}

func TestID(t *testing.T) {
	e := &entry{name: "foo", id: 101}
	m := &EntryMap{entries: map[string]*entry{
		"foo": e,
	}}
	id := m.ID("foo")
	if *id != 101 {
		t.Fatalf("expected: id=%d; got: id=%d", 101, *id)
	}
	id = m.ID("bar")
	if id != nil {
		t.Fatalf("expected nil for unknown entry")
	}
}

func TestAddUsersAndGroupsImpl(t *testing.T) {
	const (
		loginDef       = "SYS_UID_MIN 101\nSYS_UID_MAX 998\nSYS_GID_MIN 102\nSYS_GID_MAX 999\n"
		passwd         = "root:x:0:0:::/bin/bash\nkubeadm-etcd:x:101:102:::/bin/false\n"
		group          = "root:x:0:root\nkubeadm-etcd:x:102:kubeadm-etcd\n"
		expectedUsers  = "kubeadm-etcd{101,102};kubeadm-kas{102,103};kubeadm-kcm{103,104};kubeadm-ks{104,105};"
		expectedGroups = "kubeadm-etcd{102,0};kubeadm-kas{103,0};kubeadm-kcm{104,0};kubeadm-ks{105,0};kubeadm-sa-key-readers{106,0};"
	)
	fileLoginDef, close := writeTempFile(t, loginDef)
	defer close()
	filePasswd, close := writeTempFile(t, passwd)
	defer close()
	fileGroup, close := writeTempFile(t, group)
	defer close()
	got, err := addUsersAndGroupsImpl(fileLoginDef, filePasswd, fileGroup)
	if err != nil {
		t.Fatalf("AddUsersAndGroups failed: %v", err)
	}
	if expectedUsers != got.Users.String() {
		t.Fatalf("expected users: %q, got: %q", expectedUsers, got.Users.String())
	}
	if expectedGroups != got.Groups.String() {
		t.Fatalf("expected groups: %q, got: %q", expectedGroups, got.Groups.String())
	}
}

func TestRemoveUsersAndGroups(t *testing.T) {
	const (
		passwd         = "root:x:0:0:::/bin/bash\nkubeadm-etcd:x:101:102:::/bin/false\n"
		group          = "root:x:0:root\nkubeadm-etcd:x:102:kubeadm-etcd\n"
		expectedPasswd = "root:x:0:0:::/bin/bash\n"
		expectedGroup  = "root:x:0:root\n"
	)
	filePasswd, close := writeTempFile(t, passwd)
	defer close()
	fileGroup, close := writeTempFile(t, group)
	defer close()
	if err := removeUsersAndGroupsImpl(filePasswd, fileGroup); err != nil {
		t.Fatalf("RemoveUsersAndGroups failed: %v", err)
	}
	contentsPasswd := readTempFile(t, filePasswd)
	if expectedPasswd != contentsPasswd {
		t.Fatalf("expected passwd:\n%s\ngot:\n%s\n", expectedPasswd, contentsPasswd)
	}
	contentsGroup := readTempFile(t, fileGroup)
	if expectedGroup != contentsGroup {
		t.Fatalf("expected passwd:\n%s\ngot:\n%s\n", expectedGroup, contentsGroup)
	}
}

func writeTempFile(t *testing.T, contents string) (string, func()) {
	file, err := os.CreateTemp("", "")
	if err != nil {
		t.Fatalf("could not create file: %v", err)
	}
	if err := os.WriteFile(file.Name(), []byte(contents), os.ModePerm); err != nil {
		t.Fatalf("could not write file: %v", err)
	}
	close := func() {
		os.Remove(file.Name())
	}
	return file.Name(), close
}

func readTempFile(t *testing.T, path string) string {
	b, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("could not read file: %v", err)
	}
	return string(b)
}