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) { 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 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 }