Files
ThothII/tools/tht/internal/setup/files_test.go
marcopan 610ae8c85a fix(auth): make local verification portable
Keep upstream identity visible while limiting logout to local auth. Inject the restore privilege gate so the deterministic core tests do not depend on the host OS, and confine descriptor-backed projection tests to Linux. Accept the real remaining Pi timeout budget instead of an exact millisecond.
2026-08-25 10:50:30 +02:00

518 lines
19 KiB
Go

package setup
import (
"bytes"
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
"github.com/aritmolab/thothii/tools/tht/internal/testsupport"
)
func TestEnsureFilesCreatesDiscoverableConfigurationInProjectWithSpaces(t *testing.T) {
root := newProject(t, "checkout with spaces")
secrets := newExternalSecrets(t, root)
setNonInteractiveAnswers(t, secrets)
trackedExample := filepath.Join(root, "deploy", "env", "local.env.example")
before, err := os.ReadFile(trackedExample)
if err != nil {
t.Fatal(err)
}
result, err := EnsureFiles(Request{
ProjectRoot: root, InstallationID: "local-dev", Profile: "local", NonInteractive: true,
}, strings.NewReader(""), ioDiscard{})
if err != nil {
t.Fatal(err)
}
wantDirectory := filepath.Join(root, "deploy", "local-dev")
if result.DescriptorPath != filepath.Join(wantDirectory, "thothii-installation.yaml") {
t.Fatalf("descriptor = %q", result.DescriptorPath)
}
if result.EnvironmentPath != filepath.Join(wantDirectory, "operator.env") {
t.Fatalf("environment = %q", result.EnvironmentPath)
}
for _, path := range []string{result.DescriptorPath, result.EnvironmentPath} {
info, statErr := os.Stat(path)
if statErr != nil {
t.Fatalf("generated file %s: %v", path, statErr)
}
if info.Mode().Perm()&0o077 != 0 {
t.Errorf("generated file %s has permissions %o, want owner-only", path, info.Mode().Perm())
}
}
installation, err := config.Load(result.DescriptorPath)
if err != nil {
t.Fatal(err)
}
if err := safeio.ValidatePrivateDirectory(installation.AuthenticationDirectory()); err != nil {
t.Fatalf("authentication directory is not private: %v", err)
}
descriptor, err := os.ReadFile(result.DescriptorPath)
if err != nil {
t.Fatal(err)
}
environment, err := os.ReadFile(result.EnvironmentPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(environment), "THT_AUTH_CONFIG_ROOT=") {
t.Fatalf("generated environment does not declare the authentication config root: %s", environment)
}
for _, secretValue := range []string{"super-secret-value", "pi-secret-value", "private-key-value"} {
if bytes.Contains(descriptor, []byte(secretValue)) || bytes.Contains(environment, []byte(secretValue)) {
t.Fatalf("generated configuration contains a secret value %q", secretValue)
}
}
for _, path := range []string{secrets.secrets, secrets.piAuth, secrets.sshKey, secrets.knownHosts} {
contents, readErr := os.ReadFile(path)
if readErr != nil || len(contents) == 0 {
t.Fatalf("existing secret file %s was not preserved: %v", path, readErr)
}
}
if _, err := config.Resolve("", nil, root); err != nil {
t.Fatalf("generated descriptor was not discoverable: %v", err)
}
after, err := os.ReadFile(trackedExample)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(before, after) {
t.Fatal("tracked example was modified")
}
}
func TestEnsureFilesIsIdempotentForCompatibleFiles(t *testing.T) {
root := newProject(t, "linked worktree")
setNonInteractiveAnswers(t, newExternalSecrets(t, root))
request := Request{ProjectRoot: root, InstallationID: "worktree", Profile: "local", NonInteractive: true}
first, err := EnsureFiles(request, strings.NewReader(""), ioDiscard{})
if err != nil {
t.Fatal(err)
}
before, err := os.ReadFile(first.EnvironmentPath)
if err != nil {
t.Fatal(err)
}
second, err := EnsureFiles(request, strings.NewReader(""), ioDiscard{})
if err != nil {
t.Fatal(err)
}
if len(second.Created) != 0 {
t.Fatalf("compatible rerun created %v, want no files", second.Created)
}
after, err := os.ReadFile(first.EnvironmentPath)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(before, after) {
t.Fatal("compatible environment was rewritten")
}
}
func TestEnsureFilesRefusesConflictingConfiguration(t *testing.T) {
root := newProject(t, "conflict")
setNonInteractiveAnswers(t, newExternalSecrets(t, root))
directory := filepath.Join(root, "deploy", "existing")
if err := os.Mkdir(directory, 0o700); err != nil {
t.Fatal(err)
}
descriptor := filepath.Join(directory, "thothii-installation.yaml")
if err := os.WriteFile(descriptor, []byte("profile: server\n"), 0o600); err != nil {
t.Fatal(err)
}
_, err := EnsureFiles(Request{ProjectRoot: root, InstallationID: "existing", Profile: "local", NonInteractive: true}, strings.NewReader(""), ioDiscard{})
if err == nil || !strings.Contains(err.Error(), descriptor) || !strings.Contains(err.Error(), "different installation ID") {
t.Fatalf("EnsureFiles() error = %v, want exact file and corrective action", err)
}
if _, statErr := os.Stat(filepath.Join(directory, "operator.env")); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("operator.env was created after conflict: %v", statErr)
}
}
func TestEnsureFilesRemovesOwnFilesWhenAtomicWriteIsInterrupted(t *testing.T) {
root := newProject(t, "interrupted")
setNonInteractiveAnswers(t, newExternalSecrets(t, root))
previous := atomicWriteNewFile
t.Cleanup(func() { atomicWriteNewFile = previous })
calls := 0
atomicWriteNewFile = func(path string, contents []byte, mode os.FileMode) error {
calls++
if calls == 2 {
return errors.New("interrupted write")
}
return previous(path, contents, mode)
}
_, err := EnsureFiles(Request{ProjectRoot: root, InstallationID: "interrupted", Profile: "local", NonInteractive: true}, strings.NewReader(""), ioDiscard{})
if err == nil || !strings.Contains(err.Error(), "interrupted write") {
t.Fatalf("EnsureFiles() error = %v, want interrupted write", err)
}
directory := filepath.Join(root, "deploy", "interrupted")
for _, name := range []string{"thothii-installation.yaml", "operator.env"} {
if _, statErr := os.Stat(filepath.Join(directory, name)); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("%s remains after interrupted write: %v", name, statErr)
}
}
}
func TestEnsureFilesCreatesSecretTemplatesOnlyAfterExplicitConfirmation(t *testing.T) {
root := newProject(t, "secret prompt")
var output bytes.Buffer
input := strings.Join([]string{
"demo", "local", "", "", "https://git.example.invalid/workspaces.git", "main", "https", "", "", "", "", "yes",
}, "\n") + "\n"
result, err := EnsureFiles(Request{ProjectRoot: root}, strings.NewReader(input), &output)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(output.String(), "Create blank secret-file templates") {
t.Fatalf("prompt = %q, want explicit secret-template confirmation", output.String())
}
for _, path := range []string{
filepath.Join(root, "deploy", "demo", "secrets", "thothii.secrets"),
filepath.Join(root, "deploy", "demo", "secrets", "pi-auth.json"),
filepath.Join(root, "deploy", "demo", "secrets", "workspace-git-credentials"),
filepath.Join(root, "deploy", "demo", "secrets", "workspace-git-ca.pem"),
} {
info, statErr := os.Stat(path)
if statErr != nil {
t.Fatalf("secret template %s: %v", path, statErr)
}
if info.Mode().Perm()&0o077 != 0 {
t.Errorf("secret template %s has permissions %o, want owner-only", path, info.Mode().Perm())
}
}
if _, err := os.Stat(result.DescriptorPath); err != nil {
t.Fatal(err)
}
}
func TestEnsureFilesRequiresExplicitNonInteractiveAnswers(t *testing.T) {
root := newProject(t, "noninteractive")
_, err := EnsureFiles(Request{ProjectRoot: root, InstallationID: "ci", Profile: "local", NonInteractive: true}, strings.NewReader(""), ioDiscard{})
if err == nil || !strings.Contains(err.Error(), "THT_SETUP_WORKSPACE_REMOTE") {
t.Fatalf("EnsureFiles() error = %v, want non-interactive environment guidance", err)
}
}
func TestEnsureFilesIncludesServerStorageLocations(t *testing.T) {
requireProjectedServerTestHost(t)
root := newProject(t, "server profile")
setNonInteractiveAnswers(t, newExternalSecrets(t, root))
result, err := EnsureFiles(Request{ProjectRoot: root, InstallationID: "server", Profile: "server", NonInteractive: true}, strings.NewReader(""), ioDiscard{})
if err != nil {
t.Fatal(err)
}
contents, err := os.ReadFile(result.EnvironmentPath)
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"THT_DATA_ROOT", "THT_PI_STATE_ROOT", "THT_WORKSPACE_REGISTRY_ROOT", "THT_BACKUP_ROOT"} {
if !strings.Contains(string(contents), name+"=") {
t.Errorf("server configuration is missing %s: %s", name, contents)
}
}
}
func requireProjectedServerTestHost(t *testing.T) {
t.Helper()
if runtime.GOOS != "linux" || os.Geteuid() != 0 {
t.Skip("projected server filesystem integration requires Linux root")
}
}
func TestEnsureFilesProjectedServerRefusesBeforeAnyWriteWhenNotRoot(t *testing.T) {
root := newProject(t, "projected server non-root")
setNonInteractiveAnswers(t, newExternalSecrets(t, root))
previous := effectiveUID
effectiveUID = func() int { return 1000 }
t.Cleanup(func() { effectiveUID = previous })
_, err := EnsureFiles(Request{
ProjectRoot: root, InstallationID: "server", Profile: "server", NonInteractive: true,
}, strings.NewReader(""), ioDiscard{})
if err == nil || !strings.Contains(err.Error(), "root") {
t.Fatalf("EnsureFiles() error = %v, want root refusal", err)
}
if _, statErr := os.Lstat(filepath.Join(root, "deploy", "server")); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("projected server path was created before root refusal: %v", statErr)
}
}
func TestEnsureFilesRejectsUnsafeServiceEndpointsBeforeWritingConfiguration(t *testing.T) {
for _, test := range []struct {
name, environment, value string
}{
{"DWH user info", "THT_SETUP_DWH_REST_URL", "https://operator@dwh.example.invalid/api"},
{"DWH password", "THT_SETUP_DWH_REST_URL", "https://operator:password@dwh.example.invalid/api"},
{"DWH query", "THT_SETUP_DWH_REST_URL", "https://dwh.example.invalid/api?token=secret"},
{"LLM fragment", "THT_SETUP_LLM_URL", "https://llm.example.invalid/api#secret"},
} {
t.Run(test.name, func(t *testing.T) {
root := newProject(t, "unsafe endpoint")
setNonInteractiveAnswers(t, newExternalSecrets(t, root))
t.Setenv(test.environment, test.value)
id := "rejected"
_, err := EnsureFiles(Request{ProjectRoot: root, InstallationID: id, Profile: "local", NonInteractive: true}, strings.NewReader(""), ioDiscard{})
if err == nil || !strings.Contains(err.Error(), "endpoint") {
t.Fatalf("EnsureFiles() error = %v, want endpoint rejection", err)
}
assertNoConfigurationOutput(t, root, id)
})
}
}
func TestEnsureFilesRejectsUnsafeExistingSecretFiles(t *testing.T) {
for _, test := range []struct {
name string
mutate func(t *testing.T, paths *secretPaths)
}{
{
name: "directory",
mutate: func(t *testing.T, paths *secretPaths) {
paths.piAuth = filepath.Dir(paths.piAuth)
},
},
{
name: "symlink",
mutate: func(t *testing.T, paths *secretPaths) {
link := paths.piAuth + ".link"
testsupport.SymlinkOrSkip(t, paths.piAuth, link)
paths.piAuth = link
},
},
{
name: "path beneath a symlinked directory",
mutate: func(t *testing.T, paths *secretPaths) {
link := filepath.Join(filepath.Dir(paths.piAuth), "parent-link")
testsupport.SymlinkOrSkip(t, filepath.Dir(paths.piAuth), link)
paths.piAuth = filepath.Join(link, filepath.Base(paths.piAuth))
},
},
{
name: "unreadable file",
mutate: func(t *testing.T, paths *secretPaths) {
if runtime.GOOS == "windows" {
t.Skip("POSIX read permissions are not portable to Windows")
}
if err := os.Chmod(paths.piAuth, 0o000); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(paths.piAuth, 0o600) })
file, err := os.Open(paths.piAuth)
if err == nil {
_ = file.Close()
t.Skip("effective user can read mode 000 files")
}
},
},
{
name: "stat error",
mutate: func(t *testing.T, paths *secretPaths) {
paths.knownHosts = filepath.Join(paths.sshKey, "not-a-directory")
},
},
} {
t.Run(test.name, func(t *testing.T) {
root := newProject(t, "unsafe secret")
paths := newExternalSecrets(t, root)
test.mutate(t, &paths)
setNonInteractiveAnswers(t, paths)
_, err := EnsureFiles(Request{ProjectRoot: root, InstallationID: "rejected", Profile: "local", NonInteractive: true}, strings.NewReader(""), ioDiscard{})
if err == nil || (!strings.Contains(err.Error(), "readable regular file") && !strings.Contains(err.Error(), "could not be inspected")) {
t.Fatalf("EnsureFiles() error = %v, want unsafe secret-file rejection", err)
}
assertNoConfigurationOutput(t, root, "rejected")
})
}
}
func TestEnsureFilesRejectsUnwritableDeploymentDirectory(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX directory write permissions are not portable to Windows")
}
root := newProject(t, "unwritable deployment")
setNonInteractiveAnswers(t, newExternalSecrets(t, root))
deploy := filepath.Join(root, "deploy")
if err := os.Chmod(deploy, 0o500); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(deploy, 0o700) })
_, err := EnsureFiles(Request{ProjectRoot: root, InstallationID: "rejected", Profile: "local", NonInteractive: true}, strings.NewReader(""), ioDiscard{})
if err == nil {
t.Skip("effective user can create files in a mode 0500 directory")
}
assertNoConfigurationOutput(t, root, "rejected")
}
func TestEnsureFilesIgnoresGeneratedFilesInActualLinkedGitWorktree(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git is unavailable")
}
worktree := newLinkedGitWorktree(t)
secrets := newExternalSecretsAt(t, filepath.Join(t.TempDir(), "external secrets"))
setNonInteractiveAnswers(t, secrets)
result, err := EnsureFiles(Request{ProjectRoot: worktree, InstallationID: "linked", Profile: "local", NonInteractive: true}, strings.NewReader(""), ioDiscard{})
if err != nil {
t.Fatal(err)
}
for _, path := range []string{result.DescriptorPath, result.EnvironmentPath} {
gitRun(t, worktree, "check-ignore", "--quiet", path)
if err := exec.Command("git", "-C", worktree, "ls-files", "--error-unmatch", "--", path).Run(); err == nil {
t.Fatalf("generated path %s was added to the Git index", path)
}
}
status := gitOutput(t, worktree, "status", "--porcelain", "--untracked-files=all")
if status != "" {
t.Fatalf("generated configuration appears in linked-worktree Git status: %q", status)
}
}
func assertNoConfigurationOutput(t *testing.T, root, id string) {
t.Helper()
directory := filepath.Join(root, "deploy", id)
for _, name := range []string{"thothii-installation.yaml", "operator.env"} {
if _, err := os.Lstat(filepath.Join(directory, name)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("unexpected configuration output %s: %v", name, err)
}
}
}
func newLinkedGitWorktree(t *testing.T) string {
t.Helper()
repository := newProject(t, "source repository")
if err := os.WriteFile(filepath.Join(repository, ".gitignore"), []byte("deploy/*/thothii-installation.yaml\ndeploy/*/operator.env\ndeploy/*/secrets/*\n"), 0o600); err != nil {
t.Fatal(err)
}
gitRun(t, repository, "init")
gitRun(t, repository, "config", "user.email", "tests@example.invalid")
gitRun(t, repository, "config", "user.name", "ThothII tests")
gitRun(t, repository, "add", ".")
gitRun(t, repository, "commit", "-m", "fixture")
worktree := filepath.Join(t.TempDir(), "linked worktree")
gitRun(t, repository, "worktree", "add", "-b", "linked-fixture", worktree)
canonical, err := filepath.EvalSymlinks(worktree)
if err != nil {
t.Fatal(err)
}
return canonical
}
func gitRun(t *testing.T, directory string, args ...string) {
t.Helper()
command := exec.Command("git", append([]string{"-C", directory}, args...)...)
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("git %s: %v: %s", strings.Join(args, " "), err, output)
}
}
func gitOutput(t *testing.T, directory string, args ...string) string {
t.Helper()
command := exec.Command("git", append([]string{"-C", directory}, args...)...)
output, err := command.Output()
if err != nil {
t.Fatalf("git %s: %v", strings.Join(args, " "), err)
}
return string(output)
}
func newProject(t *testing.T, name string) string {
t.Helper()
root := filepath.Join(t.TempDir(), name)
for _, path := range []string{
filepath.Join(root, "deploy", "env"),
filepath.Join(root, "deploy"),
filepath.Join(root, ".git"),
filepath.Join(root, "backend"),
filepath.Join(root, "frontend"),
filepath.Join(root, "harness"),
filepath.Join(root, "tools"),
} {
if err := os.MkdirAll(path, 0o700); err != nil {
t.Fatal(err)
}
}
for path, contents := range map[string]string{
filepath.Join(root, "compose.yaml"): "services: {}\n",
filepath.Join(root, "deploy", "compose.local.yaml"): "services: {}\n",
filepath.Join(root, "deploy", "compose.git-ssh.yaml"): "services: {}\n",
filepath.Join(root, "deploy", "compose.git-https.yaml"): "services: {}\n",
filepath.Join(root, "deploy", "env", "local.env.example"): "tracked example\n",
} {
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}
canonical, err := filepath.EvalSymlinks(root)
if err != nil {
t.Fatal(err)
}
return canonical
}
type secretPaths struct {
secrets, piAuth, sshKey, knownHosts string
}
func newExternalSecrets(t *testing.T, root string) secretPaths {
t.Helper()
return newExternalSecretsAt(t, filepath.Join(root, "external secrets"))
}
func newExternalSecretsAt(t *testing.T, directory string) secretPaths {
t.Helper()
if err := os.Mkdir(directory, 0o700); err != nil {
t.Fatal(err)
}
canonical, err := filepath.EvalSymlinks(directory)
if err != nil {
t.Fatal(err)
}
directory = canonical
paths := secretPaths{
secrets: filepath.Join(directory, "thothii.secrets"), piAuth: filepath.Join(directory, "pi-auth.json"),
sshKey: filepath.Join(directory, "git-key"), knownHosts: filepath.Join(directory, "known-hosts"),
}
for path, contents := range map[string]string{
paths.secrets: "super-secret-value\n", paths.piAuth: "pi-secret-value\n", paths.sshKey: "private-key-value\n", paths.knownHosts: "git.example.invalid ssh-ed25519 AAAA\n",
} {
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}
return paths
}
func setNonInteractiveAnswers(t *testing.T, paths secretPaths) {
t.Helper()
for name, value := range map[string]string{
"THT_SETUP_WORKSPACE_REMOTE": "git@git.example.invalid:team/workspaces.git",
"THT_SETUP_WORKSPACE_BRANCH": "main",
"THT_SETUP_WORKSPACE_ACCESS": "ssh",
"THT_SETUP_SECRETS_FILE": paths.secrets,
"THT_SETUP_PI_AUTH_FILE": paths.piAuth,
"THT_SETUP_GIT_SSH_KEY_FILE": paths.sshKey,
"THT_SETUP_GIT_KNOWN_HOSTS_FILE": paths.knownHosts,
"THT_SETUP_DWH_REST_URL": "https://dwh.example.invalid",
"THT_SETUP_LLM_URL": "https://llm.example.invalid",
} {
t.Setenv(name, value)
}
}
type ioDiscard struct{}
func (ioDiscard) Write(value []byte) (int, error) { return len(value), nil }