/*
Copyright (c) Edgeless Systems GmbH

SPDX-License-Identifier: AGPL-3.0-only
*/

package main

import (
	"context"
	"os"
	"path"
	"path/filepath"
	"strconv"
	"strings"
	"testing"

	"github.com/edgelesssys/constellation/internal/deploy/ssh"
	"github.com/edgelesssys/constellation/internal/deploy/user"
	"github.com/edgelesssys/constellation/internal/logger"
	"github.com/spf13/afero"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"go.uber.org/goleak"
	v1 "k8s.io/api/core/v1"
)

func TestMain(m *testing.M) {
	goleak.VerifyTestMain(m)
}

func TestEvictUser(t *testing.T) {
	require := require.New(t)
	assert := assert.New(t)

	fs := afero.NewMemMapFs()
	linuxUserManager := user.NewLinuxUserManagerFake(fs)

	// Create fake user directory
	homePath := path.Join(normalHomePath, "myuser")
	err := fs.MkdirAll(homePath, 0o700)
	require.NoError(err)

	// Try to evict the user
	assert.NoError(evictUser("myuser", fs, linuxUserManager))

	// Check if user has been evicted
	homeEntries, err := afero.ReadDir(fs, normalHomePath)
	require.NoError(err)
	evictedEntries, err := afero.ReadDir(fs, evictedHomePath)
	require.NoError(err)
	assert.Len(homeEntries, 0)
	assert.Len(evictedEntries, 1)
	for _, singleEntry := range evictedEntries {
		assert.Contains(singleEntry.Name(), "myuser")
	}

	/*
		Note: Unfourtunaly, due to a bug in afero, we cannot test that the files inside the directory have actually been moved.
		This works on the real filesystem, but not on the memory filesystem.
		See: https://github.com/spf13/afero/issues/141 (known since 2017, guess it will never get fixed ¯\_(ツ)_/¯)
		This limits the scope of this test, obviously... But I think as long as we can move the directory,
		the functionality on the real filesystem should be there (unless it throws an error).
	*/
}

func TestDeployKeys(t *testing.T) {
	require := require.New(t)
	assert := assert.New(t)

	testCases := map[string]struct {
		configMap     *v1.ConfigMap
		existingUsers map[string]uidGIDPair
	}{
		"undefined":                  {},
		"undefined map, empty users": {existingUsers: map[string]uidGIDPair{}},
		"empty map, undefined users": {configMap: &v1.ConfigMap{}},
		"both empty": {
			configMap: &v1.ConfigMap{
				Data: map[string]string{},
			},
			existingUsers: map[string]uidGIDPair{},
		},
		"create two users, no existing users": {
			configMap: &v1.ConfigMap{
				Data: map[string]string{
					"user1": "ssh-rsa abcdefgh",
					"user2": "ssh-ed25519 defghijklm",
				},
			},
			existingUsers: map[string]uidGIDPair{},
		},
		"empty configMap, user1 and user2 should be evicted": {
			configMap: &v1.ConfigMap{
				Data: map[string]string{},
			},
			existingUsers: map[string]uidGIDPair{
				"user1": {
					UID: 1000,
					GID: 1000,
				},
				"user2": {
					UID: 1001,
					GID: 1001,
				},
			},
		},
		"configMap contains user2, user1 should be evicted, user2 recreated": {
			configMap: &v1.ConfigMap{
				Data: map[string]string{
					"user2": "ssh-rsa abcdefg",
				},
			},
			existingUsers: map[string]uidGIDPair{
				"user1": {
					UID: 1000,
					GID: 1000,
				},
				"user2": {
					UID: 1001,
					GID: 1001,
				},
			},
		},
		"configMap contains user1 and user3, user1 should be recreated, user2 evicted, user3 created": {
			configMap: &v1.ConfigMap{
				Data: map[string]string{
					"user1": "ssh-rsa abcdefg",
					"user3": "ssh-ed25519 defghijklm",
				},
			},
			existingUsers: map[string]uidGIDPair{
				"user1": {
					UID: 1000,
					GID: 1000,
				},
				"user2": {
					UID: 1001,
					GID: 1001,
				},
			},
		},
		"configMap contains user1 and user3, both should be recreated": {
			configMap: &v1.ConfigMap{
				Data: map[string]string{
					"user1": "ssh-rsa abcdefg",
					"user3": "ssh-ed25519 defghijklm",
				},
			},
			existingUsers: map[string]uidGIDPair{
				"user1": {
					UID: 1000,
					GID: 1000,
				},
				"user3": {
					UID: 1002,
					GID: 1002,
				},
			},
		},
		"configMap contains user2, user1 and user3 should be evicted, user2 should be created": {
			configMap: &v1.ConfigMap{
				Data: map[string]string{
					"user2": "ssh-ed25519 defghijklm",
				},
			},
			existingUsers: map[string]uidGIDPair{
				"user1": {
					UID: 1000,
					GID: 1000,
				},
				"user3": {
					UID: 1002,
					GID: 1002,
				},
			},
		},
	}
	for name, tc := range testCases {
		t.Run(name, func(t *testing.T) {
			fs := afero.NewMemMapFs()
			require.NoError(fs.MkdirAll(normalHomePath, 0o700))
			require.NoError(fs.Mkdir("/etc", 0o644))
			_, err := fs.Create("/etc/passwd")
			require.NoError(err)

			// Create fake user directories
			for user := range tc.existingUsers {
				userHomePath := path.Join(normalHomePath, user)
				err := fs.MkdirAll(userHomePath, 0o700)
				require.NoError(err)
				require.NoError(fs.Chown(userHomePath, int(tc.existingUsers[user].UID), int(tc.existingUsers[user].GID)))
			}

			log := logger.NewTest(t)
			linuxUserManager := user.NewLinuxUserManagerFake(fs)
			sshAccess := ssh.NewAccess(log, linuxUserManager)
			deployKeys(context.Background(), log, tc.configMap, fs, linuxUserManager, tc.existingUsers, sshAccess)

			// Unfortunately, we cannot retrieve the UID/GID from afero's MemMapFs without weird hacks,
			// as it does not have getters and it is not exported.
			if tc.configMap != nil && tc.existingUsers != nil {
				// Parse /etc/passwd and check for users
				passwdEntries, err := linuxUserManager.Passwd.Parse(fs)
				require.NoError(err)

				// Check recreation or deletion
				for user := range tc.existingUsers {
					if _, ok := tc.configMap.Data[user]; ok {
						checkHomeDirectory(user, fs, assert, true)

						// Check if user exists in /etc/passwd
						userEntry, ok := passwdEntries[user]
						assert.True(ok)

						// Check if user has been recreated with correct UID/GID
						actualUID, err := strconv.Atoi(userEntry.UID)
						assert.NoError(err)
						assert.EqualValues(tc.existingUsers[user].UID, actualUID)
						actualGID, err := strconv.Atoi(userEntry.GID)
						assert.NoError(err)
						assert.EqualValues(tc.existingUsers[user].GID, actualGID)

						// Check if the user has the right keys
						checkSSHKeys(user, fs, assert, tc.configMap.Data[user]+"\n")

					} else {
						// Check if home directory is not available anymore under the regular path
						checkHomeDirectory(user, fs, assert, false)

						// Check if home directory has been evicted
						homeDirs, err := afero.ReadDir(fs, evictedHomePath)
						require.NoError(err)

						var userDirectoryName string
						for _, singleDir := range homeDirs {
							if strings.Contains(singleDir.Name(), user+"_") {
								userDirectoryName = singleDir.Name()
								break
							}
						}
						assert.NotEmpty(userDirectoryName)

						// Check if user does not exist in /etc/passwd
						_, ok := passwdEntries[user]
						assert.False(ok)
					}
				}

				// Check creation of new users
				for user := range tc.configMap.Data {
					// We already checked recreated or evicted users, so skip them.
					if _, ok := tc.existingUsers[user]; ok {
						continue
					}

					checkHomeDirectory(user, fs, assert, true)
					checkSSHKeys(user, fs, assert, tc.configMap.Data[user]+"\n")
				}
			}
		})
	}
}

func TestEvictRootKey(t *testing.T) {
	assert := assert.New(t)
	require := require.New(t)
	fs := afero.NewMemMapFs()

	// Create /etc/passwd with root entry
	require.NoError(fs.Mkdir("/etc", 0o644))
	file, err := fs.Create("/etc/passwd")
	require.NoError(err)
	passwdRootEntry := "root:x:0:0:root:/root:/bin/bash\n"
	n, err := file.WriteString(passwdRootEntry)
	require.NoError(err)
	require.Equal(len(passwdRootEntry), n)

	// Deploy a fake key for root
	require.NoError(fs.MkdirAll("/root/.ssh/authorized_keys.d", 0o700))
	file, err = fs.Create(filepath.Join("/root", relativePathToSSHKeys))
	require.NoError(err)
	_, err = file.WriteString("ssh-ed25519 abcdefghijklm\n")
	require.NoError(err)

	linuxUserManager := user.NewLinuxUserManagerFake(fs)

	// Parse /etc/passwd and check for users
	passwdEntries, err := linuxUserManager.Passwd.Parse(fs)
	require.NoError(err)

	// Check if user exists in /etc/passwd
	userEntry, ok := passwdEntries["root"]
	assert.True(ok)

	// Check if user has been recreated with correct UID/GID
	actualUID, err := strconv.Atoi(userEntry.UID)
	assert.NoError(err)
	assert.EqualValues(0, actualUID)
	actualGID, err := strconv.Atoi(userEntry.GID)
	assert.NoError(err)
	assert.EqualValues(0, actualGID)

	// Delete the key
	assert.NoError(evictRootKey(fs, linuxUserManager))

	// Check if the key has been deleted
	_, err = fs.Stat(filepath.Join("/root", relativePathToSSHKeys))
	assert.True(os.IsNotExist(err))
}

func checkSSHKeys(user string, fs afero.Fs, assert *assert.Assertions, expectedValue string) {
	// Do the same check as above
	_, err := fs.Stat(path.Join(normalHomePath, user))
	assert.NoError(err)

	// Check if the user has the right keys
	authorizedKeys, err := afero.ReadFile(fs, filepath.Join(normalHomePath, user, relativePathToSSHKeys))
	assert.NoError(err)
	assert.EqualValues(expectedValue, string(authorizedKeys))
}

func checkHomeDirectory(user string, fs afero.Fs, assert *assert.Assertions, shouldExist bool) {
	_, err := fs.Stat(path.Join(normalHomePath, user))
	if shouldExist {
		assert.NoError(err)
	} else {
		assert.Error(err)
		assert.True(os.IsNotExist(err))
	}
}
