1701 lines
65 KiB
Go
1701 lines
65 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/aritmolab/thothii/tools/tht/internal/backup"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/compose"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/doctor"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/pi"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/setup"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/testsupport"
|
|
)
|
|
|
|
func TestBackupCommandParsesSafeTransactionalOptions(t *testing.T) {
|
|
request, err := parseBackupArgs([]string{"--output", "backup.zip", "--include-secrets", "--yes", "--drain"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if request.Output != "backup.zip" || !request.IncludeSecrets || !request.Confirm || !request.Drain {
|
|
t.Fatalf("backup request = %#v", request)
|
|
}
|
|
|
|
for _, args := range [][]string{
|
|
{"--include-secrets"},
|
|
{"--yes"},
|
|
{"--output"},
|
|
{"--drain", "--drain"},
|
|
{"--unknown"},
|
|
} {
|
|
if _, err := parseBackupArgs(args); err == nil {
|
|
t.Errorf("parseBackupArgs(%v) succeeded", args)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSetupArgumentsRequireCompleteNonInteractiveAuthenticationInputs(t *testing.T) {
|
|
request, err := parseSetupArgs([]string{
|
|
"--non-interactive", "--auth-mode", "local", "--auth-public-url", "http://127.0.0.1:8080",
|
|
"--auth-admin-user", "admin", "--auth-admin-display-name", "Initial Admin", "--auth-password-file", "/protected/password",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if request.Answers.AuthMode != "local" || request.Answers.AuthPasswordFile != "/protected/password" {
|
|
t.Fatalf("setup request = %#v", request)
|
|
}
|
|
if _, err := parseSetupArgs([]string{"--auth-mode", "local", "--auth-mode", "oidc"}); err == nil {
|
|
t.Fatal("duplicate --auth-mode was accepted")
|
|
}
|
|
}
|
|
|
|
func TestBackupCommandDispatchesWithoutDockerAndPrintsCustodyWarning(t *testing.T) {
|
|
installation := config.Installation{Path: "/tmp/thothii-installation.yaml"}
|
|
var received backup.CreateRequest
|
|
executor := func(_ context.Context, got config.Installation, request backup.CreateRequest) (backup.Result, error) {
|
|
if got.Path != installation.Path {
|
|
t.Fatalf("installation = %#v", got)
|
|
}
|
|
received = request
|
|
return backup.Result{Path: "/tmp/backup.zip", Warning: "archive contains external secret files; protect its custody"}, nil
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
code := backupCommandWith(context.Background(), installation, []string{"--include-secrets", "--yes"}, executor, &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("backup exit = %d, stderr = %s", code, stderr.String())
|
|
}
|
|
if !received.IncludeSecrets || !received.Confirm || !strings.Contains(stdout.String(), "/tmp/backup.zip") || !strings.Contains(stderr.String(), "protect its custody") {
|
|
t.Fatalf("request=%#v stdout=%q stderr=%q", received, stdout.String(), stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestBackupCommandMapsSafetyRefusalsToUsageExit(t *testing.T) {
|
|
executor := func(context.Context, config.Installation, backup.CreateRequest) (backup.Result, error) {
|
|
return backup.Result{}, backup.ErrActiveSessions
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
if code := backupCommandWith(context.Background(), config.Installation{}, nil, executor, &stdout, &stderr); code != 2 {
|
|
t.Fatalf("backup safety refusal exit = %d, stderr = %q", code, stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRestoreCommandParsesArchiveConfirmationAndDrain(t *testing.T) {
|
|
request, err := parseRestoreArgs([]string{"restore.zip", "--yes", "--drain"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if request.Archive != "restore.zip" || !request.Confirm || !request.Drain {
|
|
t.Fatalf("restore request = %#v", request)
|
|
}
|
|
|
|
for _, args := range [][]string{
|
|
{},
|
|
{"restore.zip"},
|
|
{"--yes"},
|
|
{"restore.zip", "--unknown", "--yes"},
|
|
{"restore.zip", "--yes", "extra"},
|
|
} {
|
|
if _, err := parseRestoreArgs(args); err == nil {
|
|
t.Errorf("parseRestoreArgs(%v) succeeded", args)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRestoreCommandDispatchesAndPrintsCheckpointOnlyWhenPresent(t *testing.T) {
|
|
installation := config.Installation{Path: "/tmp/thothii-installation.yaml"}
|
|
var received backup.RestoreRequest
|
|
executor := func(_ context.Context, got config.Installation, request backup.RestoreRequest) (backup.RestoreResult, error) {
|
|
if got.Path != installation.Path {
|
|
t.Fatalf("installation = %#v", got)
|
|
}
|
|
received = request
|
|
return backup.RestoreResult{Checkpoint: "/tmp/recovery.zip", Restarted: true, Verified: true}, nil
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
code := restoreCommandWith(context.Background(), installation, []string{"restore.zip", "--yes", "--drain"}, executor, &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("restore exit = %d, stderr = %s", code, stderr.String())
|
|
}
|
|
if received.Archive != "restore.zip" || !received.Confirm || !received.Drain {
|
|
t.Fatalf("request = %#v", received)
|
|
}
|
|
if !strings.Contains(stdout.String(), "Recovery checkpoint: /tmp/recovery.zip") || stderr.Len() != 0 {
|
|
t.Fatalf("stdout=%q stderr=%q", stdout.String(), stderr.String())
|
|
}
|
|
|
|
stdout.Reset()
|
|
code = restoreCommandWith(context.Background(), installation, []string{"restore.zip", "--yes"}, func(context.Context, config.Installation, backup.RestoreRequest) (backup.RestoreResult, error) {
|
|
return backup.RestoreResult{}, nil
|
|
}, &stdout, &stderr)
|
|
if code != 0 || strings.Contains(stdout.String(), "Recovery checkpoint:") {
|
|
t.Fatalf("empty checkpoint output: code=%d stdout=%q", code, stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRestoreCommandMapsSafetyRefusalsAndRetainsRecoveryText(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
err error
|
|
}{
|
|
{name: "confirmation", err: backup.ErrRestoreConfirmationRequired},
|
|
{name: "active sessions", err: backup.ErrActiveSessions},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
code := restoreCommandWith(context.Background(), config.Installation{}, []string{"restore.zip", "--yes"}, func(context.Context, config.Installation, backup.RestoreRequest) (backup.RestoreResult, error) {
|
|
return backup.RestoreResult{Checkpoint: "/tmp/recovery.zip"}, test.err
|
|
}, &stdout, &stderr)
|
|
if code != 2 {
|
|
t.Fatalf("restore exit = %d, want usage exit 2", code)
|
|
}
|
|
if !strings.Contains(stderr.String(), "Recovery checkpoint: /tmp/recovery.zip") {
|
|
t.Fatalf("stderr=%q, want checkpoint", stderr.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPiMutationsUseTheSharedInstallationLifecycleLock(t *testing.T) {
|
|
for _, test := range []struct {
|
|
args []string
|
|
want bool
|
|
}{
|
|
{args: []string{"status"}, want: false},
|
|
{args: []string{"doctor"}, want: false},
|
|
{args: []string{"test"}, want: false},
|
|
{args: []string{"logs"}, want: false},
|
|
{args: []string{"configure"}, want: true},
|
|
{args: []string{"restart"}, want: true},
|
|
{args: []string{"update"}, want: true},
|
|
{args: []string{"rollback"}, want: true},
|
|
{args: []string{"maintenance", "recover"}, want: true},
|
|
{args: []string{"maintenance", "status"}, want: false},
|
|
} {
|
|
if got := piMutationRequiresLifecycleLock(test.args); got != test.want {
|
|
t.Errorf("piMutationRequiresLifecycleLock(%v) = %v, want %v", test.args, got, test.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInstallationRunnerMapsProfileToSessionInventoryScope(t *testing.T) {
|
|
for _, test := range []struct {
|
|
profile string
|
|
want string
|
|
}{
|
|
{profile: "local", want: "mine"},
|
|
{profile: "server", want: "all"},
|
|
} {
|
|
runner := compose.InstallationRunner{Installation: config.Installation{Profile: test.profile}}
|
|
if got := runner.SessionInventoryScope(); got != test.want {
|
|
t.Fatalf("profile %q maps to session scope %q, want %q", test.profile, got, test.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRootCommandIdentity(t *testing.T) {
|
|
fixture := newCLIFixture(t, "")
|
|
fixture.setEnvironment(t)
|
|
retiredCommand := "thoth" + "ctl"
|
|
|
|
for _, test := range []struct {
|
|
name string
|
|
args []string
|
|
wantCode int
|
|
wantText string
|
|
}{
|
|
{
|
|
name: "help banner",
|
|
args: []string{"--help"},
|
|
wantCode: 0,
|
|
wantText: " auth configure",
|
|
},
|
|
{
|
|
name: "version path",
|
|
args: []string{"--installation", fixture.installationPath, "version"},
|
|
wantCode: 0,
|
|
wantText: "tht 0.0.0-dev",
|
|
},
|
|
{
|
|
name: "retired command is not an alias",
|
|
args: []string{"--installation", fixture.installationPath, retiredCommand},
|
|
wantCode: 2,
|
|
wantText: "tht: unknown command \"" + retiredCommand + "\"",
|
|
},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
if got := run(context.Background(), test.args, &stdout, &stderr); got != test.wantCode {
|
|
t.Fatalf("run(%v) exit code = %d, want %d", test.args, got, test.wantCode)
|
|
}
|
|
if got := stdout.String() + stderr.String(); !strings.Contains(got, test.wantText) {
|
|
t.Fatalf("run(%v) output = %q, want %q", test.args, got, test.wantText)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBootstrapCommandsDoNotRequireAnInstallationDescriptor(t *testing.T) {
|
|
projectRoot := newProjectWithoutInstallation(t)
|
|
previousDirectory, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chdir(filepath.Join(projectRoot, "frontend")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if restoreErr := os.Chdir(previousDirectory); restoreErr != nil {
|
|
t.Errorf("restore working directory: %v", restoreErr)
|
|
}
|
|
})
|
|
t.Setenv("THOTHII_INSTALLATION", "")
|
|
|
|
for _, args := range [][]string{
|
|
nil,
|
|
{"help"},
|
|
{"help", "pi"},
|
|
{"version"},
|
|
{"version", "--json"},
|
|
{"setup"},
|
|
{"setup", "--configure-only"},
|
|
} {
|
|
var stdout, stderr bytes.Buffer
|
|
_ = run(context.Background(), args, &stdout, &stderr)
|
|
if output := stdout.String() + stderr.String(); strings.Contains(output, "installation descriptor") {
|
|
t.Fatalf("run(%v) required a descriptor: %q", args, output)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSetupCommandBuildsAndStartsUnlessConfigureOnlyIsRequested(t *testing.T) {
|
|
projectRoot := newProjectWithoutInstallation(t)
|
|
previousDirectory, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chdir(projectRoot); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.Chdir(previousDirectory) })
|
|
|
|
for _, test := range []struct {
|
|
name string
|
|
args []string
|
|
want bool
|
|
}{
|
|
{name: "default", args: []string{"--non-interactive", "--installation-id", "cli", "--profile", "local"}, want: false},
|
|
{name: "configuration only", args: []string{"--configure-only", "--non-interactive", "--installation-id", "cli", "--profile", "local"}, want: true},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var received setup.Request
|
|
executor := func(_ context.Context, _ compose.Runner, request setup.Request, _ io.Reader, _ io.Writer) (setup.Result, error) {
|
|
received = request
|
|
return setup.Result{DescriptorPath: filepath.Join(projectRoot, "deploy", "cli", "thothii-installation.yaml"), Configured: true, Built: !request.ConfigureOnly, Started: !request.ConfigureOnly, Healthy: !request.ConfigureOnly}, nil
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
code := setupCommandWith(context.Background(), "", test.args, strings.NewReader(""), compose.NewRunner("docker"), executor, &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("setupCommandWith() = %d, stderr = %q", code, stderr.String())
|
|
}
|
|
if received.ConfigureOnly != test.want {
|
|
t.Fatalf("ConfigureOnly = %t, want %t", received.ConfigureOnly, test.want)
|
|
}
|
|
canonicalRoot, pathErr := filepath.EvalSymlinks(projectRoot)
|
|
if pathErr != nil {
|
|
t.Fatal(pathErr)
|
|
}
|
|
if received.ProjectRoot != canonicalRoot {
|
|
t.Fatalf("ProjectRoot = %q, want %q", received.ProjectRoot, canonicalRoot)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Catches interactive configuration prompts that use retired model-only data instead of the
|
|
// provider, model, and reasoning choices supplied by the dedicated Pi Management API.
|
|
func TestResolvePiConfigureUsesNumberedClosedChoicesOnlyForTTY(t *testing.T) {
|
|
runner := &wizardRunner{}
|
|
var prompt bytes.Buffer
|
|
defaults, err := resolvePiConfigure(
|
|
context.Background(), runner, nil, strings.NewReader("2\n1\n3\n"), &prompt, true,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := pi.Defaults{Provider: "zai", Model: "glm-5.2", Thinking: "high"}
|
|
if defaults != want {
|
|
t.Fatalf("defaults = %#v", defaults)
|
|
}
|
|
for _, expected := range []string{"1) deepseek", "2) zai", "1) glm-5.2", "3) high"} {
|
|
if !strings.Contains(prompt.String(), expected) {
|
|
t.Errorf("prompt %q missing %q", prompt.String(), expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolvePiConfigureRequiresExplicitFlagsWithoutTTY(t *testing.T) {
|
|
runner := &wizardRunner{}
|
|
_, err := resolvePiConfigure(context.Background(), runner, nil, strings.NewReader("1\n1\n1\n"), io.Discard, false)
|
|
if err == nil || !strings.Contains(err.Error(), "non-interactive") {
|
|
t.Fatalf("resolvePiConfigure() error = %v, want explicit non-interactive guidance", err)
|
|
}
|
|
if len(runner.calls) != 0 {
|
|
t.Fatalf("Docker calls = %v, want none", runner.calls)
|
|
}
|
|
}
|
|
|
|
func TestPiLifecycleContractErrorsExitTwo(t *testing.T) {
|
|
for _, lifecycleErr := range []error{pi.ErrActiveSessions, pi.ErrInterruptedUpdate} {
|
|
var stderr bytes.Buffer
|
|
wrapped := fmt.Errorf("automatic rollback succeeded: %w", lifecycleErr)
|
|
if code := piFailure(&stderr, wrapped, nil); code != 2 {
|
|
t.Errorf("piFailure(%v) = %d, want 2", lifecycleErr, code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLogsRejectsFollowAndOtherArguments(t *testing.T) {
|
|
if _, err := logsArgs([]string{"--follow"}); err == nil {
|
|
t.Fatal("logsArgs(--follow) error = nil, want bounded-log rejection")
|
|
}
|
|
if got, err := logsArgs(nil); err != nil || strings.Join(got, " ") != "logs --tail 200" {
|
|
t.Fatalf("logsArgs(nil) = %v, %v", got, err)
|
|
}
|
|
}
|
|
|
|
func TestParseArgsMakesInstallationOptionalAndAcceptsOverrideAfterCommand(t *testing.T) {
|
|
if installation, command, commandArgs, err := parseArgs([]string{"pi", "status"}); err != nil || installation != "" || command != "pi" || strings.Join(commandArgs, " ") != "status" {
|
|
t.Fatalf("implicit parse = %q, %q, %v, %v", installation, command, commandArgs, err)
|
|
}
|
|
|
|
want := "/tmp/thothii-installation.yaml"
|
|
installation, command, commandArgs, err := parseArgs([]string{"pi", "update", "--installation", want})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if installation != want || command != "pi" || strings.Join(commandArgs, " ") != "update" {
|
|
t.Fatalf("trailing override parse = %q, %q, %v", installation, command, commandArgs)
|
|
}
|
|
}
|
|
|
|
func TestParseSetupArgsAcceptsExplicitNonInteractiveAnswers(t *testing.T) {
|
|
request, err := parseSetupArgs([]string{
|
|
"--configure-only", "--non-interactive", "--installation-id", "ci", "--profile", "server",
|
|
"--workspace-remote", "https://git.example.invalid/workspaces.git", "--workspace-branch", "release",
|
|
"--workspace-access", "https", "--secrets-file", "/tmp/thothii.secrets", "--pi-auth-file", "/tmp/pi-auth.json",
|
|
"--git-credentials-file", "/tmp/git-credentials", "--git-ca-file", "/tmp/git-ca.pem",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !request.ConfigureOnly || !request.NonInteractive || request.InstallationID != "ci" || request.Profile != "server" {
|
|
t.Fatalf("setup request = %#v", request)
|
|
}
|
|
if request.Answers.WorkspaceBranch != "release" || request.Answers.GitCAFile != "/tmp/git-ca.pem" {
|
|
t.Fatalf("setup answers = %#v", request.Answers)
|
|
}
|
|
}
|
|
|
|
func TestRunPiStatusUsesInstallationEnvironmentWithoutFlag(t *testing.T) {
|
|
fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
|
|
fixture.setEnvironment(t)
|
|
t.Setenv("THOTHII_INSTALLATION", fixture.installationPath)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
if code := run(context.Background(), []string{"pi", "status"}, &stdout, &stderr); code != 0 {
|
|
t.Fatalf("run() exit code = %d, stderr = %s", code, stderr.String())
|
|
}
|
|
if stdout.String() != "Pi version: 0.80.3\n" {
|
|
t.Fatalf("stdout = %q, want image-bundled Pi version", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestUsageDocumentsClosedConfigureUpdateSourcesRestartAndMaintenanceRecovery(t *testing.T) {
|
|
if strings.Contains(usage, "--follow") {
|
|
t.Fatal("usage still advertises unbounded log following")
|
|
}
|
|
for _, required := range []string{
|
|
"--provider P --model M --thinking low|medium|high",
|
|
"--source build",
|
|
"--source pull --image IMAGE@sha256:DIGEST",
|
|
"pi restart --yes [--drain]",
|
|
"Recreate only core with the currently selected Pi image",
|
|
"pi maintenance status",
|
|
"pi maintenance recover --yes",
|
|
"sessions migrate --yes",
|
|
"remove --yes ID...",
|
|
} {
|
|
if !strings.Contains(usage, required) {
|
|
t.Errorf("usage missing %q", required)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParsePiRestartArgs(t *testing.T) {
|
|
restartPath := "/var/lib/tht/restart-state.json"
|
|
updatePath := "/var/lib/tht/update-state.json"
|
|
for _, test := range []struct {
|
|
name string
|
|
args []string
|
|
want pi.RestartRequest
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "confirmed",
|
|
args: []string{"--yes"},
|
|
want: pi.RestartRequest{StatePath: restartPath, UpdateStatePath: updatePath, Confirm: true},
|
|
},
|
|
{
|
|
name: "drain",
|
|
args: []string{"--yes", "--drain"},
|
|
want: pi.RestartRequest{StatePath: restartPath, UpdateStatePath: updatePath, Confirm: true, Drain: true},
|
|
},
|
|
{name: "duplicate yes", args: []string{"--yes", "--yes"}, wantErr: "--yes may be supplied once"},
|
|
{name: "duplicate drain", args: []string{"--drain", "--drain"}, wantErr: "--drain may be supplied once"},
|
|
{name: "unknown", args: []string{"--force"}, wantErr: "unknown pi restart option"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
got, err := parsePiRestartArgs(test.args, restartPath, updatePath)
|
|
if test.wantErr != "" {
|
|
if err == nil || err.Error() != test.wantErr {
|
|
t.Fatalf("parsePiRestartArgs(%v) error = %v, want %q", test.args, err, test.wantErr)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("parsePiRestartArgs(%v) error = %v", test.args, err)
|
|
}
|
|
if got != test.want {
|
|
t.Fatalf("parsePiRestartArgs(%v) = %#v, want %#v", test.args, got, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParsePiUpdateArgsDefaultsToLatestStableBuildForTheShortCommand(t *testing.T) {
|
|
statePath := "/var/lib/tht/update-state.json"
|
|
restartPath := "/var/lib/tht/restart-state.json"
|
|
got, err := parsePiUpdateArgs(nil, statePath, restartPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := pi.Request{
|
|
StatePath: statePath,
|
|
RestartStatePath: restartPath,
|
|
Source: pi.BuildSource,
|
|
Confirm: true,
|
|
Drain: true,
|
|
}
|
|
if got != want {
|
|
t.Fatalf("parsePiUpdateArgs(nil) = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestParsePiUpdateArgsAllowsExplicitVersionWithoutAdvancedFlags(t *testing.T) {
|
|
got, err := parsePiUpdateArgs([]string{"--version", "0.81.0"}, "update.json", "restart.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Version != "0.81.0" || got.Source != pi.BuildSource || !got.Confirm || !got.Drain {
|
|
t.Fatalf("parsePiUpdateArgs() = %#v, want explicit version with safe build defaults", got)
|
|
}
|
|
}
|
|
|
|
func TestRunSessionsMigrateRequiresExplicitConfirmationBeforeDocker(t *testing.T) {
|
|
fixture := newCLIFixture(t, "")
|
|
fixture.setProfile(t, "server")
|
|
fixture.setEnvironment(t)
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
code := run(context.Background(), []string{
|
|
"--installation", fixture.installationPath, "sessions", "migrate",
|
|
}, &stdout, &stderr)
|
|
|
|
if code != 2 || !strings.Contains(stderr.String(), "sessions migrate requires --yes") {
|
|
t.Fatalf("exit = %d, stderr = %q", code, stderr.String())
|
|
}
|
|
assertDockerNotInvoked(t, fixture)
|
|
}
|
|
|
|
func TestRunSessionsMigrateRedactsCompleteDetailBeforeDisplayBound(t *testing.T) {
|
|
longSecret := "long-secret-" + strings.Repeat("s", 700)
|
|
for _, spec := range []struct {
|
|
name string
|
|
secret string
|
|
failure string
|
|
leakedProbe string
|
|
}{
|
|
{
|
|
name: "secret longer than display limit",
|
|
secret: longSecret,
|
|
failure: longSecret + " rejected by TLS",
|
|
leakedProbe: longSecret[:64],
|
|
},
|
|
{
|
|
name: "secret crossing display boundary",
|
|
secret: "boundary-secret-value",
|
|
failure: strings.Repeat("p", 500) + "boundary-secret-value rejected",
|
|
leakedProbe: "boundary-sec",
|
|
},
|
|
} {
|
|
t.Run(spec.name, func(t *testing.T) {
|
|
fixture := newCLIFixture(t, "MIGRATION_PASSWORD_FILE=%s\n")
|
|
fixture.setProfile(t, "server")
|
|
secretPath := filepath.Join(fixture.root, "migration-password")
|
|
if err := os.WriteFile(secretPath, []byte(spec.secret), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fixture.setEnvironment(t, secretPath)
|
|
t.Setenv("THT_FAKE_CONFIG", `{"services":{"core":{"image":"thothii-core:local"},"session-migrate":{"image":"thothii-core:local"}}}`)
|
|
t.Setenv("THT_FAKE_MIGRATION_FAILURE", spec.failure)
|
|
t.Setenv("THT_FAKE_MIGRATION_EXIT", "23")
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
code := run(context.Background(), []string{
|
|
"--installation", fixture.installationPath, "sessions", "migrate", "--yes",
|
|
}, &stdout, &stderr)
|
|
|
|
if code != 1 {
|
|
t.Fatalf("exit = %d, stderr = %q", code, stderr.String())
|
|
}
|
|
for _, required := range []string{"stage=session-migration", "class=nonzero-exit", "[REDACTED]"} {
|
|
if !strings.Contains(stderr.String(), required) {
|
|
t.Errorf("stderr = %q, missing %q", stderr.String(), required)
|
|
}
|
|
}
|
|
if strings.Contains(stderr.String(), spec.secret) || strings.Contains(stderr.String(), spec.leakedProbe) {
|
|
t.Fatalf("stderr exposed secret or prefix: %q", stderr.String())
|
|
}
|
|
if stderr.Len() > 640 {
|
|
t.Fatalf("stderr exceeded bounded display: %d bytes", stderr.Len())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunRemoveDisplaysExactInstallationTargetsBeforeConfirmation(t *testing.T) {
|
|
fixture := newCLIFixture(t, "")
|
|
fixture.setProfile(t, "server")
|
|
fixture.setEnvironment(t)
|
|
t.Setenv("THT_FAKE_STOPPED_PS", `[{"ID":"core-id","Name":"exact-core","Service":"core","State":"exited"},{"ID":"front-id","Name":"exact-frontend","Service":"frontend","State":"exited"}]`)
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
code := run(context.Background(), []string{
|
|
"--installation", fixture.installationPath, "remove",
|
|
}, &stdout, &stderr)
|
|
|
|
if code != 2 || !strings.Contains(stderr.String(), "re-run with remove --yes core-id front-id") {
|
|
t.Fatalf("exit = %d, stderr = %q", code, stderr.String())
|
|
}
|
|
for _, value := range []string{"exact-core", "core-id", "exact-frontend", "front-id", "exited"} {
|
|
if !strings.Contains(stdout.String(), value) {
|
|
t.Errorf("target display %q missing %q", stdout.String(), value)
|
|
}
|
|
}
|
|
calls := fixture.invocations(t)
|
|
if len(calls) != 1 {
|
|
t.Fatalf("Docker calls = %#v", calls)
|
|
}
|
|
assertInvocationContains(t, calls, "ps", "--all", "--format", "json", "core", "frontend")
|
|
}
|
|
|
|
type wizardRunner struct{ calls []string }
|
|
|
|
func (r *wizardRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) {
|
|
r.calls = append(r.calls, strings.Join(args, " "))
|
|
return compose.Result{Stdout: `{"providers":["deepseek","zai"],"models":[{"provider":"deepseek","id":"deepseek-v4"},{"provider":"zai","id":"glm-5.2"}],"reasoning":["low","medium","high"]}`}, nil
|
|
}
|
|
|
|
func TestRunLogsRedactsAnUnlabelledDeclaredSecret(t *testing.T) {
|
|
fixture := newCLIFixture(t, "UNLABELLED_SECRET_FILE=%s\n")
|
|
secretPath := filepath.Join(fixture.root, "operator-secret")
|
|
if err := os.WriteFile(secretPath, []byte("unlabelled-secret\r\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fixture.setEnvironment(t, secretPath)
|
|
t.Setenv("THT_FAKE_LOG", "fake Docker log: unlabelled-secret")
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "logs"}, &stdout, &stderr)
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("run() exit code = %d, stderr = %s", exitCode, stderr.String())
|
|
}
|
|
if strings.Contains(stdout.String(), "unlabelled-secret") {
|
|
t.Fatalf("logs exposed an unlabelled secret: %q", stdout.String())
|
|
}
|
|
if stdout.String() != "fake Docker log: [REDACTED]\n" {
|
|
t.Errorf("logs = %q, want redacted output", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunPiLogsRedactsNestedAuthAndBundleScalars(t *testing.T) {
|
|
fixture := newCLIFixture(t, "")
|
|
authFile := filepath.Join(fixture.root, "pi-auth.json")
|
|
bundleFile := filepath.Join(fixture.root, "thothii.secrets")
|
|
if err := os.WriteFile(authFile, []byte(`{
|
|
"providers": {
|
|
"dummy-provider": {
|
|
"auth": {
|
|
"key": "dummy-canary-pi-log-json",
|
|
"nested": {"access": "dummy-canary-pi-log-nested"}
|
|
}
|
|
}
|
|
}
|
|
}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(bundleFile, []byte(
|
|
"MODEL_API_KEY=dummy-canary-pi-log-model\n"+
|
|
"DWH_PASSWORD=dummy-canary-pi-log-dwh\n",
|
|
), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fixture.setEnvContents(t, "PI_AUTH_FILE="+authFile+"\nTHT_SECRETS_FILE="+bundleFile+"\n")
|
|
t.Setenv(
|
|
"THT_FAKE_LOG",
|
|
"dummy-canary-pi-log-json dummy-canary-pi-log-nested "+
|
|
"dummy-canary-pi-log-model dummy-canary-pi-log-dwh",
|
|
)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{
|
|
"--installation", fixture.installationPath, "pi", "logs",
|
|
}, &stdout, &stderr)
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("run() exit code = %d, stderr = %q", exitCode, stderr.String())
|
|
}
|
|
for _, canary := range []string{
|
|
"dummy-canary-pi-log-json",
|
|
"dummy-canary-pi-log-nested",
|
|
"dummy-canary-pi-log-model",
|
|
"dummy-canary-pi-log-dwh",
|
|
} {
|
|
if strings.Contains(stdout.String()+stderr.String(), canary) {
|
|
t.Fatalf("pi logs exposed scalar %q: stdout=%q stderr=%q", canary, stdout.String(), stderr.String())
|
|
}
|
|
}
|
|
if strings.Count(stdout.String(), "[REDACTED]") != 4 {
|
|
t.Fatalf("pi logs = %q, want four scalar redactions", stdout.String())
|
|
}
|
|
assertInvocationContains(t, fixture.invocations(t), "logs", "--tail", "200", "core")
|
|
}
|
|
|
|
func TestRunResolvesComposeDotenvCommentsQuotesAndInterpolationForSecretFiles(t *testing.T) {
|
|
fixture := newCLIFixture(t, "")
|
|
secretDirectory := filepath.Join(fixture.root, "secret directory")
|
|
if err := os.Mkdir(secretDirectory, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
inlineSecret := filepath.Join(secretDirectory, "inline")
|
|
doubleQuotedSecret := filepath.Join(secretDirectory, "double quoted")
|
|
singleQuotedSecret := filepath.Join(secretDirectory, "single quoted")
|
|
interpolatedSecret := filepath.Join(secretDirectory, "interpolated")
|
|
for path, value := range map[string]string{
|
|
inlineSecret: "inline-secret",
|
|
doubleQuotedSecret: "double-quoted-secret",
|
|
singleQuotedSecret: "single-quoted-secret",
|
|
interpolatedSecret: "interpolated-secret",
|
|
} {
|
|
if err := os.WriteFile(path, []byte(value), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
fixture.setEnvContents(t, "SECRET_ROOT="+secretDirectory+"\n"+
|
|
"INLINE_TOKEN_FILE="+inlineSecret+" # Compose comment\n"+
|
|
"DOUBLE_TOKEN_FILE=\""+doubleQuotedSecret+"\" # Compose comment\n"+
|
|
"SINGLE_TOKEN_FILE='"+singleQuotedSecret+"' # Compose comment\n"+
|
|
"INTERPOLATED_TOKEN_SOURCE=\"${SECRET_ROOT}/interpolated\"\n")
|
|
t.Setenv("THT_FAKE_LOG", "inline-secret double-quoted-secret single-quoted-secret interpolated-secret")
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "logs"}, &stdout, &stderr)
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("run() exit code = %d, stderr = %s", exitCode, stderr.String())
|
|
}
|
|
for _, secret := range []string{"inline-secret", "double-quoted-secret", "single-quoted-secret", "interpolated-secret"} {
|
|
if strings.Contains(stdout.String(), secret) {
|
|
t.Errorf("logs exposed %q: %q", secret, stdout.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunRedactsSecretSourceInBothStreams(t *testing.T) {
|
|
fixture := newCLIFixture(t, "")
|
|
secretPath := filepath.Join(fixture.root, "source-secret")
|
|
if err := os.WriteFile(secretPath, []byte("source-secret"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fixture.setEnvContents(t, "UNLABELLED_SECRET_SOURCE="+secretPath+"\n")
|
|
t.Setenv("THT_FAKE_LOG", "stdout source-secret")
|
|
t.Setenv("THT_FAKE_FAILURE", "stderr source-secret")
|
|
t.Setenv("THT_FAKE_EXIT", "17")
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "logs"}, &stdout, &stderr)
|
|
|
|
if exitCode != 17 {
|
|
t.Errorf("run() exit code = %d, want 17", exitCode)
|
|
}
|
|
if strings.Contains(stdout.String()+stderr.String(), "source-secret") {
|
|
t.Errorf("output exposed source secret: stdout=%q stderr=%q", stdout.String(), stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunRedactsSecretWhenDoctorFails(t *testing.T) {
|
|
fixture := newCLIFixture(t, "")
|
|
secretPath := filepath.Join(fixture.root, "doctor-secret")
|
|
if err := os.WriteFile(secretPath, []byte("doctor-secret"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fixture.setEnvContents(t, "DOCTOR_SECRET_FILE="+secretPath+"\n")
|
|
t.Setenv("THT_FAKE_FAIL_ON", "version")
|
|
t.Setenv("THT_FAKE_FAILURE", "doctor saw doctor-secret")
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "doctor"}, &stdout, &stderr)
|
|
|
|
if exitCode != 1 {
|
|
t.Errorf("run() exit code = %d, want normalized doctor failure exit 1", exitCode)
|
|
}
|
|
if strings.Contains(stdout.String()+stderr.String(), "doctor-secret") {
|
|
t.Errorf("doctor failure exposed secret: stdout=%q stderr=%q", stdout.String(), stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunFailsClosedForUnresolvedSecretSourceInterpolation(t *testing.T) {
|
|
fixture := newCLIFixture(t, "MISSING_TOKEN_SOURCE=${MISSING_SECRET_ROOT}/token\n")
|
|
fixture.setEnvironment(t)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "logs"}, &stdout, &stderr)
|
|
|
|
if exitCode != 2 {
|
|
t.Errorf("run() exit code = %d, want 2", exitCode)
|
|
}
|
|
if !strings.Contains(stderr.String(), "installation secret declarations could not be read") {
|
|
t.Errorf("stderr = %q, want fail-closed declaration error", stderr.String())
|
|
}
|
|
if _, err := os.Stat(fixture.argsFile); !os.IsNotExist(err) {
|
|
t.Errorf("Docker was invoked after unresolved interpolation: stat error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunFailsClosedForTraversalAndParentSymlinkSecretSources(t *testing.T) {
|
|
for name, source := range map[string]func(*testing.T, cliFixture) string{
|
|
"traversal": func(t *testing.T, fixture cliFixture) string {
|
|
secret := filepath.Join(fixture.root, "secret")
|
|
if err := os.WriteFile(secret, []byte("traversal-secret"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return filepath.Join(fixture.root, "subdirectory") + string(filepath.Separator) + ".." + string(filepath.Separator) + "secret"
|
|
},
|
|
"parent symlink": func(t *testing.T, fixture cliFixture) string {
|
|
realDirectory := filepath.Join(fixture.root, "real")
|
|
if err := os.Mkdir(realDirectory, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(realDirectory, "secret"), []byte("symlink-secret"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
linkDirectory := filepath.Join(fixture.root, "linked")
|
|
testsupport.SymlinkOrSkip(t, realDirectory, linkDirectory)
|
|
return filepath.Join(linkDirectory, "secret")
|
|
},
|
|
"final symlink": func(t *testing.T, fixture cliFixture) string {
|
|
realSecret := filepath.Join(fixture.root, "real-secret")
|
|
if err := os.WriteFile(realSecret, []byte("final-symlink-secret"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
linkSecret := filepath.Join(fixture.root, "linked-secret")
|
|
testsupport.SymlinkOrSkip(t, realSecret, linkSecret)
|
|
return linkSecret
|
|
},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
fixture := newCLIFixture(t, "")
|
|
unsafeSource := source(t, fixture)
|
|
fixture.setEnvContents(t, "UNSAFE_SECRET_SOURCE="+unsafeSource+"\n")
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "logs"}, &stdout, &stderr)
|
|
|
|
if exitCode != 2 {
|
|
t.Errorf("run() exit code = %d, want 2", exitCode)
|
|
}
|
|
if !strings.Contains(stderr.String(), "could not be read") {
|
|
t.Errorf("stderr = %q, want sanitized unsafe-file error", stderr.String())
|
|
}
|
|
if strings.Contains(stderr.String(), unsafeSource) {
|
|
t.Errorf("stderr revealed unsafe source path: %q", stderr.String())
|
|
}
|
|
assertDockerNotInvoked(t, fixture)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunFailsClosedForOversizedEnvAndSecretFiles(t *testing.T) {
|
|
t.Run("environment", func(t *testing.T) {
|
|
fixture := newCLIFixture(t, "")
|
|
fixture.setEnvContents(t, strings.Repeat("A", 1<<20+1))
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "logs"}, &stdout, &stderr)
|
|
if exitCode != 2 || !strings.Contains(stderr.String(), "installation secret declarations could not be read") {
|
|
t.Errorf("exit=%d stderr=%q, want sanitized oversized-env failure", exitCode, stderr.String())
|
|
}
|
|
assertDockerNotInvoked(t, fixture)
|
|
})
|
|
t.Run("secret", func(t *testing.T) {
|
|
fixture := newCLIFixture(t, "")
|
|
secretPath := filepath.Join(fixture.root, "large-secret")
|
|
if err := os.WriteFile(secretPath, make([]byte, 64*1024+1), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fixture.setEnvContents(t, "LARGE_SECRET_FILE="+secretPath+"\n")
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "logs"}, &stdout, &stderr)
|
|
if exitCode != 2 || !strings.Contains(stderr.String(), "declared secret file could not be read") {
|
|
t.Errorf("exit=%d stderr=%q, want sanitized oversized-secret failure", exitCode, stderr.String())
|
|
}
|
|
assertDockerNotInvoked(t, fixture)
|
|
})
|
|
}
|
|
|
|
func TestRunFailsClosedForTooManyOrTooLargeSecretSources(t *testing.T) {
|
|
t.Run("too many sources", func(t *testing.T) {
|
|
fixture := newCLIFixture(t, "")
|
|
var declarations strings.Builder
|
|
for index := range 33 {
|
|
secretPath := filepath.Join(fixture.root, "secret-count-"+strconv.Itoa(index))
|
|
if err := os.WriteFile(secretPath, []byte("secret"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fmt.Fprintf(&declarations, "SECRET_%d_FILE=%s\n", index, secretPath)
|
|
}
|
|
fixture.setEnvContents(t, declarations.String())
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "logs"}, &stdout, &stderr)
|
|
if exitCode != 2 || !strings.Contains(stderr.String(), "installation secret declarations could not be read") {
|
|
t.Errorf("exit=%d stderr=%q, want sanitized source-count failure", exitCode, stderr.String())
|
|
}
|
|
assertDockerNotInvoked(t, fixture)
|
|
})
|
|
|
|
t.Run("total source bytes", func(t *testing.T) {
|
|
fixture := newCLIFixture(t, "")
|
|
var declarations strings.Builder
|
|
for index := range 5 {
|
|
secretPath := filepath.Join(fixture.root, "secret-total-"+strconv.Itoa(index))
|
|
if err := os.WriteFile(secretPath, bytes.Repeat([]byte("x"), 60*1024), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fmt.Fprintf(&declarations, "SECRET_%d_SOURCE=%s\n", index, secretPath)
|
|
}
|
|
fixture.setEnvContents(t, declarations.String())
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "logs"}, &stdout, &stderr)
|
|
if exitCode != 2 || !strings.Contains(stderr.String(), "declared secret file could not be read") {
|
|
t.Errorf("exit=%d stderr=%q, want sanitized total-size failure", exitCode, stderr.String())
|
|
}
|
|
assertDockerNotInvoked(t, fixture)
|
|
})
|
|
}
|
|
|
|
func TestRunStatusUsesStableComposeArguments(t *testing.T) {
|
|
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
|
|
fixture.setEnvironment(t)
|
|
|
|
for range 2 {
|
|
var stdout, stderr bytes.Buffer
|
|
if exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "status"}, &stdout, &stderr); exitCode != 0 {
|
|
t.Fatalf("run() exit code = %d, stderr = %s", exitCode, stderr.String())
|
|
}
|
|
}
|
|
|
|
invocations := fixture.invocations(t)
|
|
if len(invocations) != 2 {
|
|
t.Fatalf("docker invocations = %d, want 2", len(invocations))
|
|
}
|
|
if strings.Join(invocations[0], "\x00") != strings.Join(invocations[1], "\x00") {
|
|
t.Errorf("Compose arguments changed between identical status calls: %#v then %#v", invocations[0], invocations[1])
|
|
}
|
|
wantSuffix := []string{
|
|
"--project-directory", fixture.projectDirectory,
|
|
"--env-file", fixture.envFile,
|
|
"-f", filepath.Join(fixture.projectDirectory, "compose.yaml"),
|
|
"-f", filepath.Join(fixture.projectDirectory, "deploy", "compose.local.yaml"),
|
|
"ps", "--format", "json",
|
|
}
|
|
got := invocations[0]
|
|
if len(got) != len(wantSuffix)+3 || got[0] != "compose" || got[1] != "--project-name" || !strings.HasPrefix(got[2], "thothii-") {
|
|
t.Fatalf("unexpected Compose prefix: %#v", got)
|
|
}
|
|
for index, want := range wantSuffix {
|
|
if got[index+3] != want {
|
|
t.Errorf("argument %d = %q, want %q", index+3, got[index+3], want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunStartAutomaticallyUsesTheDurableCurrentImageOverride(t *testing.T) {
|
|
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
|
|
fixture.setEnvironment(t)
|
|
installation, err := config.Load(fixture.installationPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
currentImage := installation.CurrentImageOverridePath()
|
|
if err := os.MkdirAll(filepath.Dir(currentImage), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(currentImage, []byte("services:\n core:\n image: thothii-core:verified\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
if code := run(context.Background(), []string{"--installation", fixture.installationPath, "start"}, &stdout, &stderr); code != 0 {
|
|
t.Fatalf("start exit = %d, stderr = %s", code, stderr.String())
|
|
}
|
|
assertInvocationContains(t, fixture.invocations(t), "-f", currentImage, "up", "--detach", "--remove-orphans")
|
|
}
|
|
|
|
func TestRunExplainsWhenDockerIsNotAvailable(t *testing.T) {
|
|
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
|
|
fixture.setEnvironment(t)
|
|
t.Setenv("PATH", t.TempDir())
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "status"}, &stdout, &stderr)
|
|
|
|
if exitCode != 127 {
|
|
t.Errorf("run() exit code = %d, want 127", exitCode)
|
|
}
|
|
if !strings.Contains(stderr.String(), "Docker is not installed or is not on PATH") {
|
|
t.Errorf("stderr = %q, want Docker-not-found guidance", stderr.String())
|
|
}
|
|
if strings.Contains(stderr.String(), "executable file") {
|
|
t.Errorf("stderr leaked a process implementation detail: %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunDoctorValidatesTheRenderedInstallation(t *testing.T) {
|
|
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
|
|
fixture.setEnvironment(t)
|
|
|
|
report := runDoctorJSON(t, fixture)
|
|
assertDoctorCheck(t, report, "configuration", doctor.StatusPassed)
|
|
assertDoctorCheck(t, report, "services", doctor.StatusPassed)
|
|
}
|
|
|
|
func TestRunDoctorDoesNotDereferenceSymlinksDuringLineEndingCheck(t *testing.T) {
|
|
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
|
|
fixture.setEnvironment(t)
|
|
testsupport.SymlinkOrSkip(
|
|
t,
|
|
filepath.Join(fixture.projectDirectory, "missing-workspace.yaml"),
|
|
filepath.Join(fixture.projectDirectory, "legacy-workspace.yaml"),
|
|
)
|
|
|
|
report := runDoctorJSON(t, fixture)
|
|
assertDoctorCheck(t, report, "files", doctor.StatusPassed)
|
|
assertDoctorCheck(t, report, "configuration", doctor.StatusPassed)
|
|
}
|
|
|
|
func TestRunDoctorAcceptsComposeJSONLinesServiceStatus(t *testing.T) {
|
|
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
|
|
fixture.setEnvironment(t)
|
|
t.Setenv(
|
|
"THT_FAKE_PS",
|
|
"{\"Service\":\"core\",\"State\":\"running\",\"Health\":\"healthy\"}\n"+
|
|
"{\"Service\":\"frontend\",\"State\":\"running\",\"Health\":\"healthy\"}\n"+
|
|
"{\"Service\":\"qdrant\",\"State\":\"running\",\"Health\":\"healthy\"}\n"+
|
|
"{\"Service\":\"embedding\",\"State\":\"running\",\"Health\":\"healthy\"}\n"+
|
|
"{\"Service\":\"embedding-model-init\",\"State\":\"exited\",\"ExitCode\":0}",
|
|
)
|
|
|
|
report := runDoctorJSON(t, fixture)
|
|
assertDoctorCheck(t, report, "services", doctor.StatusPassed)
|
|
}
|
|
|
|
func TestRunPreservesChildExitCodes(t *testing.T) {
|
|
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
|
|
fixture.setEnvironment(t)
|
|
t.Setenv("THT_FAKE_EXIT", "42")
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "update", "--check-only"}, &stdout, &stderr)
|
|
|
|
if exitCode != 42 {
|
|
t.Errorf("run() exit code = %d, want 42", exitCode)
|
|
}
|
|
if stderr.String() != "fake Docker failure\n" {
|
|
t.Errorf("stderr = %q, want child stderr", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunUpdateCheckOnlyRefusesProjectedAuthenticationBeforeCompose(t *testing.T) {
|
|
for _, state := range []string{"missing", "blocked", "divergent"} {
|
|
t.Run(state, func(t *testing.T) {
|
|
fixture := projectedUpdateFixture(t)
|
|
previous := requireRuntimeAuthProjectionReady
|
|
requireRuntimeAuthProjectionReady = func(installation config.Installation) error {
|
|
if !installation.HasRuntimeAuthProjection() {
|
|
t.Fatal("update readiness gate received an unprojected installation")
|
|
}
|
|
return errors.New("synthetic " + state + " projection")
|
|
}
|
|
t.Cleanup(func() { requireRuntimeAuthProjectionReady = previous })
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
if code := run(context.Background(), []string{"--installation", fixture.installationPath, "update", "--check-only"}, &stdout, &stderr); code == 0 {
|
|
t.Fatalf("update --check-only accepted %s projection", state)
|
|
}
|
|
assertDockerNotInvoked(t, fixture)
|
|
if strings.Contains(stdout.String()+stderr.String(), "synthetic "+state+" projection") {
|
|
t.Fatalf("update leaked readiness detail: stdout=%q stderr=%q", stdout.String(), stderr.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunPiStatusUsesImageBundledPi(t *testing.T) {
|
|
fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
|
|
fixture.setEnvironment(t)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "pi", "status"}, &stdout, &stderr)
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("run() exit code = %d, stderr = %s", exitCode, stderr.String())
|
|
}
|
|
if stdout.String() != "Pi version: 0.80.3\n" {
|
|
t.Errorf("stdout = %q, want image-bundled Pi version", stdout.String())
|
|
}
|
|
assertInvocationContains(t, fixture.invocations(t), "exec", "-T", "core", "pi", "--version")
|
|
}
|
|
|
|
func TestRunPiUpdateResolvesLatestStableVersionWhenVersionIsOmitted(t *testing.T) {
|
|
fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
|
|
fixture.setEnvironment(t)
|
|
if err := os.WriteFile(filepath.Join(fixture.projectDirectory, "docker", "core.Dockerfile"), []byte("ARG PI_VERSION=0.79.0\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
previousRegistry := newPiRegistryClient
|
|
newPiRegistryClient = func() pi.RegistryClient {
|
|
return testRegistryClient{version: "0.80.3"}
|
|
}
|
|
t.Cleanup(func() { newPiRegistryClient = previousRegistry })
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
if code := run(context.Background(), []string{"--installation", fixture.installationPath, "pi", "update"}, &stdout, &stderr); code != 0 {
|
|
t.Fatalf("run() exit code = %d, stderr = %s", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "Pi already runs requested version 0.80.3") {
|
|
t.Fatalf("stdout = %q, want registry-selected version", stdout.String())
|
|
}
|
|
assertInvocationNotContains(t, fixture.invocations(t), "build --pull")
|
|
}
|
|
|
|
func TestRunPiUpdateRequiresExplicitConfirmationWithoutInvokingDocker(t *testing.T) {
|
|
fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
|
|
fixture.setEnvironment(t)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "pi", "update", "--version", "0.81.0", "--source", "build"}, &stdout, &stderr)
|
|
|
|
if exitCode != 2 {
|
|
t.Errorf("run() exit code = %d, want 2", exitCode)
|
|
}
|
|
if !strings.Contains(stderr.String(), "requires --yes") {
|
|
t.Errorf("stderr = %q, want explicit confirmation guidance", stderr.String())
|
|
}
|
|
assertDockerNotInvoked(t, fixture)
|
|
}
|
|
|
|
func TestRunPiRestartRequiresExplicitConfirmationWithoutInvokingDocker(t *testing.T) {
|
|
fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
|
|
fixture.setEnvironment(t)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{
|
|
"--installation", fixture.installationPath, "pi", "restart",
|
|
}, &stdout, &stderr)
|
|
|
|
if exitCode != 2 {
|
|
t.Errorf("run() exit code = %d, want 2", exitCode)
|
|
}
|
|
if !strings.Contains(stderr.String(), "pi restart requires --yes") {
|
|
t.Errorf("stderr = %q, want explicit confirmation guidance", stderr.String())
|
|
}
|
|
assertDockerNotInvoked(t, fixture)
|
|
}
|
|
|
|
func TestRunPiRestartSanitizesSuccessOutput(t *testing.T) {
|
|
fixture := newCLIFixture(t, "PI_TOKEN_FILE=%s\nTHT_LLM_URL=https://llm.example.invalid\n")
|
|
secretPath := filepath.Join(fixture.root, "pi-restart-secret")
|
|
if err := os.WriteFile(secretPath, []byte("pi-restart-secret"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fixture.setEnvironment(t, secretPath)
|
|
t.Setenv("THT_FAKE_PI_VERSION", "pi-restart-secret")
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{
|
|
"--installation", fixture.installationPath, "pi", "restart", "--yes",
|
|
}, &stdout, &stderr)
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("run() exit code = %d, stderr = %s", exitCode, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "Pi core restarted with the existing image;") {
|
|
t.Fatalf("stdout = %q, want restart success", stdout.String())
|
|
}
|
|
if strings.Contains(stdout.String()+stderr.String(), "pi-restart-secret") {
|
|
t.Fatalf("Pi restart exposed a secret: stdout=%q stderr=%q", stdout.String(), stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunPiUpdateAcceptsExplicitVersionWithoutAdvancedFlags(t *testing.T) {
|
|
fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
|
|
fixture.setEnvironment(t)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{
|
|
"--installation", fixture.installationPath, "pi", "update", "--version", "0.80.3",
|
|
}, &stdout, &stderr)
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("run() exit code = %d, stderr = %q", exitCode, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "Pi already runs requested version 0.80.3") {
|
|
t.Fatalf("stdout = %q, want explicit version result", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunPiConfigureReportsTheActualHostAuthFile(t *testing.T) {
|
|
fixture := newCLIFixture(t, "")
|
|
authFile := filepath.Join(fixture.root, "pi-auth.json")
|
|
if err := os.WriteFile(authFile, []byte(`{"provider":"credential"}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fixture.setEnvContents(t, "THT_LLM_URL=https://llm.example.invalid\nPI_AUTH_FILE="+authFile+"\n")
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{
|
|
"--installation", fixture.installationPath, "pi", "configure",
|
|
"--provider", "provider", "--model", "model", "--thinking", "medium",
|
|
}, &stdout, &stderr)
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("run() exit = %d, stderr=%s", exitCode, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), authFile) {
|
|
t.Fatalf("stdout = %q, want host auth path", stdout.String())
|
|
}
|
|
if strings.Contains(stdout.String(), "/home/thoth/.pi") {
|
|
t.Fatalf("stdout exposed container-only auth path: %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunPiMaintenanceStatusAndRecoverConfirmationContract(t *testing.T) {
|
|
fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
|
|
fixture.setEnvironment(t)
|
|
var stdout, stderr bytes.Buffer
|
|
if code := run(context.Background(), []string{
|
|
"--installation", fixture.installationPath, "pi", "maintenance", "status",
|
|
}, &stdout, &stderr); code != 0 {
|
|
t.Fatalf("maintenance status exit = %d, stderr = %s", code, stderr.String())
|
|
}
|
|
if stdout.String() != "Pi maintenance active: true (admissions: 0)\n" {
|
|
t.Fatalf("maintenance status output = %q", stdout.String())
|
|
}
|
|
|
|
second := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
|
|
second.setEnvironment(t)
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
if code := run(context.Background(), []string{
|
|
"--installation", second.installationPath, "pi", "maintenance", "recover",
|
|
}, &stdout, &stderr); code != 2 {
|
|
t.Fatalf("maintenance recover without --yes exit = %d, want 2", code)
|
|
}
|
|
assertDockerNotInvoked(t, second)
|
|
}
|
|
|
|
func TestRunPiMaintenanceRecoverClearsRestartLifecycleState(t *testing.T) {
|
|
fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
|
|
fixture.setEnvironment(t)
|
|
installation, err := config.Load(fixture.installationPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const restartState = `{
|
|
"version": 4,
|
|
"transaction": "restart-recovery",
|
|
"phase": "preflight",
|
|
"target": {"version": "0.80.3", "source": "restart"},
|
|
"previous": {
|
|
"id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
"reference": "thothii-core:local",
|
|
"mounts": [],
|
|
"mount_fingerprint": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
"configuration_sha256": "config"
|
|
}
|
|
}`
|
|
if err := os.MkdirAll(filepath.Dir(installation.RestartStatePath()), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(installation.RestartStatePath(), []byte(restartState), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{
|
|
"--installation", fixture.installationPath, "pi", "maintenance", "recover", "--yes",
|
|
}, &stdout, &stderr)
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("run() exit code = %d, stderr = %s", exitCode, stderr.String())
|
|
}
|
|
if _, err := os.Stat(installation.RestartStatePath()); !os.IsNotExist(err) {
|
|
t.Fatalf("restart state still exists: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunWorkspaceInspectDispatchesThroughTheMaintenanceService(t *testing.T) {
|
|
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
|
|
fixture.setEnvironment(t)
|
|
t.Setenv("THT_FAKE_WORKSPACE_RESULT", `{"schemaVersion":1,"status":"succeeded","code":"ok","workspaceId":"abc","workspaceRevision":"1234567890abcdef1234567890abcdef12345678","descriptorBlob":"sha256:`+strings.Repeat("a", 64)+`","operation":"inspect","completedStages":[]}`)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "abc", "--json"}, &stdout, &stderr)
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("run() exit code = %d, stderr = %s", exitCode, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), `"workspaceId":"abc"`) || !strings.Contains(stdout.String(), `"operation":"inspect"`) {
|
|
t.Fatalf("stdout = %q", stdout.String())
|
|
}
|
|
assertInvocationContains(t, fixture.invocations(t), "run", "--rm", "--no-deps", "--no-TTY", "--name")
|
|
assertInvocationContains(t, fixture.invocations(t), "workspace-maintenance", "inspect")
|
|
}
|
|
|
|
func TestRunWorkspaceBlockedResultsExitThreeAndRenderHumanOutput(t *testing.T) {
|
|
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
|
|
fixture.setEnvironment(t)
|
|
t.Setenv("THT_FAKE_WORKSPACE_RESULT", `{"schemaVersion":1,"status":"blocked","code":"manual_review_required","workspaceId":"abc","workspaceRevision":"1234567890abcdef1234567890abcdef12345678","descriptorBlob":"sha256:`+strings.Repeat("b", 64)+`","operation":"schema-suggest-fks","completedStages":["dwh"],"warnings":["review required"]}`)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "schema", "suggest-fks", "--workspace", "abc"}, &stdout, &stderr)
|
|
|
|
if exitCode != 3 {
|
|
t.Fatalf("run() exit code = %d, want 3", exitCode)
|
|
}
|
|
for _, expected := range []string{"workspace: abc", "status: blocked", "warning: review required"} {
|
|
if !strings.Contains(stdout.String(), expected) {
|
|
t.Fatalf("stdout = %q, missing %q", stdout.String(), expected)
|
|
}
|
|
}
|
|
if strings.Contains(stdout.String(), "descriptorBlob") || strings.Contains(stdout.String(), strings.Repeat("b", 64)) {
|
|
t.Fatalf("stdout leaked non-allowlisted fields: %q", stdout.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("stderr = %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunWorkspaceRequiresWorkspaceFlagBeforeDocker(t *testing.T) {
|
|
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
|
|
fixture.setEnvironment(t)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{
|
|
"--installation", fixture.installationPath,
|
|
"workspace", "inspect",
|
|
}, &stdout, &stderr)
|
|
|
|
if exitCode != 2 {
|
|
t.Fatalf("run() exit code = %d, want 2", exitCode)
|
|
}
|
|
if !strings.Contains(stderr.String(), "--workspace") || !strings.Contains(stderr.String(), "required") {
|
|
t.Fatalf("stderr = %q", stderr.String())
|
|
}
|
|
assertDockerNotInvoked(t, fixture)
|
|
}
|
|
|
|
func TestRunWorkspaceRejectsDuplicateWorkspaceFlagsBeforeDocker(t *testing.T) {
|
|
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
|
|
fixture.setEnvironment(t)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{
|
|
"--installation", fixture.installationPath,
|
|
"workspace", "inspect", "--workspace", "abc", "--workspace", "def",
|
|
}, &stdout, &stderr)
|
|
|
|
if exitCode != 2 {
|
|
t.Fatalf("run() exit code = %d, want 2", exitCode)
|
|
}
|
|
if !strings.Contains(stderr.String(), "--workspace") || !strings.Contains(stderr.String(), "exactly once") {
|
|
t.Fatalf("stderr = %q", stderr.String())
|
|
}
|
|
assertDockerNotInvoked(t, fixture)
|
|
}
|
|
|
|
func TestRunWorkspaceRejectsInvalidResumeRunIDsBeforeDocker(t *testing.T) {
|
|
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
|
|
fixture.setEnvironment(t)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{
|
|
"--installation", fixture.installationPath,
|
|
"workspace", "preprocess", "dwh", "--workspace", "abc", "--resume", "not-a-run-id",
|
|
}, &stdout, &stderr)
|
|
|
|
if exitCode != 2 {
|
|
t.Fatalf("run() exit code = %d, want 2", exitCode)
|
|
}
|
|
if !strings.Contains(stderr.String(), "--resume") || !strings.Contains(stderr.String(), "32 lowercase hex") {
|
|
t.Fatalf("stderr = %q", stderr.String())
|
|
}
|
|
assertDockerNotInvoked(t, fixture)
|
|
}
|
|
|
|
func TestRunWorkspaceSchemaCheckRequiresReviewedCandidatesWithAnnotations(t *testing.T) {
|
|
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
|
|
fixture.setEnvironment(t)
|
|
annotations := filepath.Join(fixture.root, "annotations.yaml")
|
|
if err := os.WriteFile(annotations, []byte("reviewed: []\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{
|
|
"--installation", fixture.installationPath,
|
|
"workspace", "schema", "check", "--workspace", "abc", "--annotations", annotations,
|
|
}, &stdout, &stderr)
|
|
|
|
if exitCode != 2 {
|
|
t.Fatalf("run() exit code = %d, want 2", exitCode)
|
|
}
|
|
if !strings.Contains(stderr.String(), "--reviewed-candidates") {
|
|
t.Fatalf("stderr = %q", stderr.String())
|
|
}
|
|
assertDockerNotInvoked(t, fixture)
|
|
}
|
|
|
|
func TestRunPiStatusPreservesDockerExitCodeAndRedactsDiagnostics(t *testing.T) {
|
|
fixture := newCLIFixture(t, "PI_TOKEN_FILE=%s\n")
|
|
secretPath := filepath.Join(fixture.root, "pi-secret")
|
|
if err := os.WriteFile(secretPath, []byte("pi-status-secret"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fixture.setEnvironment(t, secretPath)
|
|
t.Setenv("THT_FAKE_FAIL_ON", "version")
|
|
t.Setenv("THT_FAKE_FAILURE", "pi-status-secret")
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "pi", "status"}, &stdout, &stderr)
|
|
|
|
if exitCode != 41 {
|
|
t.Errorf("run() exit code = %d, want 41", exitCode)
|
|
}
|
|
if strings.Contains(stdout.String()+stderr.String(), "pi-status-secret") {
|
|
t.Errorf("Pi status exposed a secret: stdout=%q stderr=%q", stdout.String(), stderr.String())
|
|
}
|
|
}
|
|
|
|
type cliFixture struct {
|
|
root string
|
|
installationPath string
|
|
projectDirectory string
|
|
envFile string
|
|
argsFile string
|
|
pathDirectory string
|
|
envTemplate string
|
|
}
|
|
|
|
type testRegistryClient struct {
|
|
version string
|
|
err error
|
|
}
|
|
|
|
func (client testRegistryClient) LatestStable(context.Context, string) (string, error) {
|
|
return client.version, client.err
|
|
}
|
|
|
|
func newProjectWithoutInstallation(t *testing.T) string {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
for _, directory := range []string{"backend", "frontend", "harness", "tools", "deploy", ".git"} {
|
|
if err := os.MkdirAll(filepath.Join(root, directory), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "compose.yaml"), []byte("services: {}\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return root
|
|
}
|
|
|
|
func newCLIFixture(t *testing.T, envTemplate string) cliFixture {
|
|
t.Helper()
|
|
temporaryRoot, err := filepath.EvalSymlinks(os.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
root, err := os.MkdirTemp(temporaryRoot, "tht-test-")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.RemoveAll(root) })
|
|
projectDirectory := filepath.Join(root, "project")
|
|
if err := os.MkdirAll(filepath.Join(projectDirectory, "deploy"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(projectDirectory, "docker"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(projectDirectory, "docker", "core.Dockerfile"), []byte("ARG PI_VERSION=0.80.3\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(projectDirectory, "docker", "pi-runtime"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(projectDirectory, "docker", "pi-runtime", "package.json"), []byte(`{"dependencies":{"@earendil-works/pi-coding-agent":"0.80.3"}}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, path := range []string{filepath.Join(projectDirectory, "compose.yaml"), filepath.Join(projectDirectory, "deploy", "compose.local.yaml")} {
|
|
if err := os.WriteFile(path, []byte("services: {}\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
envFile := filepath.Join(root, "installation.env")
|
|
authDirectory := filepath.Join(root, "auth")
|
|
if err := os.Mkdir(authDirectory, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
installationPath := filepath.Join(root, "thothii-installation.yaml")
|
|
contents := "profile: local\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + authDirectory + "\n"
|
|
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pathDirectory := filepath.Join(root, "bin")
|
|
if err := os.Mkdir(pathDirectory, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
argsFile := filepath.Join(root, "docker-args")
|
|
fakeDocker := `#!/bin/sh
|
|
printf '%s\n' "$@" >> "$THT_FAKE_ARGS"
|
|
printf '%s\n' -- >> "$THT_FAKE_ARGS"
|
|
case " $* " in
|
|
*" ps --all --format json core frontend "*) printf '%s\n' "${THT_FAKE_STOPPED_PS:-[]}" ;;
|
|
*" config --format json "*)
|
|
if [ -n "${THT_FAKE_CONFIG:-}" ]; then
|
|
printf '%s\n' "$THT_FAKE_CONFIG"
|
|
else
|
|
printf '%s\n' '{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{},"auth-state":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}'
|
|
fi ;;
|
|
*" run --rm --no-deps --no-TTY session-migrate "*)
|
|
if [ "${THT_FAKE_MIGRATION_EXIT:-0}" -ne 0 ]; then
|
|
printf '%s\n' "$THT_FAKE_MIGRATION_FAILURE" >&2
|
|
exit "$THT_FAKE_MIGRATION_EXIT"
|
|
fi
|
|
printf '%s\n' '{"applied":[],"drifted":[],"pending":[]}' ;;
|
|
*" ps --all --format json "*) printf '%s\n' "$THT_FAKE_PS" ;;
|
|
*" ps --format json "*) printf '%s\n' "$THT_FAKE_PS" ;;
|
|
*" ps -q core "*) printf '%s\n' 'core-id' ;;
|
|
*" version --format {{.Client.Version}}"*) printf '%s\n' '27.0.0' ;;
|
|
*"compose version --short"*) printf '%s\n' 'v2.30.0' ;;
|
|
*" image inspect --format {{.Id}} "*) printf '%s\n' "${THT_FAKE_IMAGE_ID:-sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb}" ;;
|
|
*"inspect --format {{.Image}} core-id"*) printf '%s\n' "${THT_FAKE_IMAGE_ID:-sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb}" ;;
|
|
*"inspect --format {{json .Mounts}} core-id"*) printf '%s\n' '[{"Type":"volume","Name":"settings","Source":"settings","Destination":"/home/thoth/.pi","RW":true}]' ;;
|
|
*"io.thothii.pi.version"*) printf '%s\n' "${THT_FAKE_PI_VERSION:-0.80.3}" ;;
|
|
*"PI_VERSION"*) printf '%s\n' "${THT_FAKE_PI_VERSION:-0.80.3}" ;;
|
|
*" pi --version "*) printf '%s\n' "${THT_FAKE_PI_VERSION:-0.80.3}" ;;
|
|
*"operator-command.js pi-options "*) printf '%s\n' '{"providers":["provider"],"models":[{"provider":"provider","id":"model"}],"reasoning":["low","medium","high"]}' ;;
|
|
*"settings-cli.js --snapshot"*) printf '%s\n' '{"exists":false,"rawBase64":""}' ;;
|
|
*"operator-command.js effective-settings "*) printf '%s\n' '{"provider":"provider","model":"model","thinking":"medium"}' ;;
|
|
*"operator-command.js pi-test "*) printf '%s\n' '{"ready":true}' ;;
|
|
*"operator-command.js workflow-doctor "*) printf '%s\n' '{"ready":true,"workspaces":1}' ;;
|
|
*"dist/auth/diagnostic-command.js --json"*) printf '%s\n' '{"ready":true,"mode":"oidc","checks":[{"level":"info","code":"auth_ready","message":"Authentication is ready."}]}' ;;
|
|
*"operator-command.js session-inventory "*) printf '%s\n' '[]' ;;
|
|
*"operator-command.js maintenance-activate "*) printf '%s\n' '{"active":true,"admissions":0,"recoveryRequired":false}' ;;
|
|
*"operator-command.js maintenance-deactivate "*) printf '%s\n' '{"active":false,"admissions":0,"recoveryRequired":false}' ;;
|
|
*"operator-command.js maintenance-status "*) printf '%s\n' '{"active":true,"admissions":0,"recoveryRequired":false}' ;;
|
|
*" logs "*) printf '%s\n' "$THT_FAKE_LOG" ;;
|
|
*" run --rm --no-deps --no-TTY "*" workspace-maintenance "*) printf '%s\n' "$THT_FAKE_WORKSPACE_RESULT" ;;
|
|
esac
|
|
if [ "${THT_FAKE_FAIL_ON:-}" = "version" ]; then
|
|
printf '%s\n' "${THT_FAKE_FAILURE:-fake Docker failure}" >&2
|
|
exit 41
|
|
fi
|
|
if [ "${THT_FAKE_EXIT:-0}" -ne 0 ]; then
|
|
printf '%s\n' "${THT_FAKE_FAILURE:-fake Docker failure}" >&2
|
|
fi
|
|
exit "${THT_FAKE_EXIT:-0}"
|
|
`
|
|
if err := os.WriteFile(filepath.Join(pathDirectory, "docker"), []byte(fakeDocker), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return cliFixture{root: root, installationPath: installationPath, projectDirectory: projectDirectory, envFile: envFile, argsFile: argsFile, pathDirectory: pathDirectory, envTemplate: envTemplate}
|
|
}
|
|
|
|
func (f cliFixture) setEnvironment(t *testing.T, values ...string) {
|
|
t.Helper()
|
|
env := f.envTemplate
|
|
if len(values) > 0 {
|
|
env = strings.Replace(env, "%s", values[0], 1)
|
|
}
|
|
f.setEnvContents(t, env)
|
|
}
|
|
|
|
func (f cliFixture) setEnvContents(t *testing.T, env string) {
|
|
t.Helper()
|
|
env += "THT_AUTH_CONFIG_ROOT=" + strconv.Quote(filepath.Join(f.root, "auth")) + "\n"
|
|
if err := os.WriteFile(f.envFile, []byte(env), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("PATH", f.pathDirectory)
|
|
t.Setenv("THT_FAKE_ARGS", f.argsFile)
|
|
t.Setenv("THT_FAKE_EXIT", "0")
|
|
t.Setenv("THT_FAKE_LOG", "")
|
|
t.Setenv("THT_FAKE_FAILURE", "")
|
|
t.Setenv("THT_FAKE_FAIL_ON", "")
|
|
t.Setenv("THT_FAKE_STOPPED_PS", "[]")
|
|
t.Setenv("THT_FAKE_PS", `[{"Service":"core","State":"running","Health":"healthy"},{"Service":"frontend","State":"running","Health":"healthy"},{"Service":"qdrant","State":"running","Health":"healthy"},{"Service":"embedding","State":"running","Health":"healthy"},{"Service":"embedding-model-init","State":"exited","ExitCode":0}]`)
|
|
t.Setenv("THT_FAKE_CONFIG", "")
|
|
t.Setenv("THT_FAKE_MIGRATION_FAILURE", "")
|
|
t.Setenv("THT_FAKE_MIGRATION_EXIT", "0")
|
|
t.Setenv("THT_FAKE_WORKSPACE_RESULT", "")
|
|
t.Setenv("THT_FAKE_IMAGE_ID", "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
|
|
t.Setenv("THT_FAKE_PI_VERSION", "0.80.3")
|
|
}
|
|
|
|
func (f cliFixture) setProfile(t *testing.T, profile string) {
|
|
t.Helper()
|
|
composePath := filepath.Join(f.projectDirectory, "deploy", "compose."+profile+".yaml")
|
|
if err := os.WriteFile(composePath, []byte("services: {}\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
contents := "profile: " + profile + "\nprojectDirectory: " + f.projectDirectory + "\nenvFile: " + f.envFile + "\nauthentication:\n configDirectory: " + filepath.Join(f.root, "auth") + "\n"
|
|
if err := os.WriteFile(f.installationPath, []byte(contents), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func projectedUpdateFixture(t *testing.T) cliFixture {
|
|
t.Helper()
|
|
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
|
|
fixture.setProfile(t, "server")
|
|
runtimeRoot := filepath.Join(fixture.root, "runtime-auth")
|
|
if err := os.Mkdir(runtimeRoot, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(fixture.projectDirectory, "deploy", "compose.auth-runtime-projection.yaml"), []byte("services: {}\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
descriptor := "profile: server\nprojectDirectory: " + fixture.projectDirectory + "\nenvFile: " + fixture.envFile + "\nauthentication:\n configDirectory: " + filepath.Join(fixture.root, "auth") + "\n runtimeProjection:\n directory: " + runtimeRoot + "\n uid: 10001\n gid: 10001\n"
|
|
if err := os.WriteFile(fixture.installationPath, []byte(descriptor), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fixture.setEnvContents(t, "SAFE_VALUE=1\nTHT_AUTH_RUNTIME_ROOT="+strconv.Quote(runtimeRoot)+"\n")
|
|
return fixture
|
|
}
|
|
|
|
func (f cliFixture) invocations(t *testing.T) [][]string {
|
|
t.Helper()
|
|
contents, err := os.ReadFile(f.argsFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var invocations [][]string
|
|
var invocation []string
|
|
for _, line := range strings.Split(strings.TrimSuffix(string(contents), "\n"), "\n") {
|
|
if line == "--" {
|
|
invocations = append(invocations, invocation)
|
|
invocation = nil
|
|
continue
|
|
}
|
|
invocation = append(invocation, line)
|
|
}
|
|
return invocations
|
|
}
|
|
|
|
func assertDockerNotInvoked(t *testing.T, fixture cliFixture) {
|
|
t.Helper()
|
|
if _, err := os.Stat(fixture.argsFile); !os.IsNotExist(err) {
|
|
t.Errorf("Docker was invoked: stat error = %v", err)
|
|
}
|
|
}
|
|
|
|
func runDoctorJSON(t *testing.T, fixture cliFixture) doctor.Report {
|
|
t.Helper()
|
|
var stdout, stderr bytes.Buffer
|
|
if code := run(context.Background(), []string{"--installation", fixture.installationPath, "doctor", "--json"}, &stdout, &stderr); code != 0 {
|
|
t.Fatalf("doctor exit = %d, stderr = %s, stdout = %s", code, stderr.String(), stdout.String())
|
|
}
|
|
var report doctor.Report
|
|
if err := json.Unmarshal(stdout.Bytes(), &report); err != nil {
|
|
t.Fatalf("doctor JSON = %q: %v", stdout.String(), err)
|
|
}
|
|
if !report.OK {
|
|
t.Fatalf("doctor report = %#v, want all checks passed", report)
|
|
}
|
|
return report
|
|
}
|
|
|
|
func assertDoctorCheck(t *testing.T, report doctor.Report, name, wantStatus string) {
|
|
t.Helper()
|
|
for _, check := range report.Checks {
|
|
if check.Name == name {
|
|
if check.Status != wantStatus {
|
|
t.Fatalf("doctor check %q = %q, want %q", name, check.Status, wantStatus)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("doctor report does not contain check %q", name)
|
|
}
|
|
|
|
func assertInvocationNotContains(t *testing.T, invocations [][]string, fragment string) {
|
|
t.Helper()
|
|
for _, invocation := range invocations {
|
|
if strings.Contains(strings.Join(invocation, " "), fragment) {
|
|
t.Errorf("Docker invocation %q unexpectedly contains %q", invocation, fragment)
|
|
}
|
|
}
|
|
}
|
|
|
|
func assertInvocationContains(t *testing.T, invocations [][]string, want ...string) {
|
|
t.Helper()
|
|
for _, invocation := range invocations {
|
|
for start := range invocation {
|
|
if len(invocation)-start < len(want) {
|
|
continue
|
|
}
|
|
if strings.Join(invocation[start:start+len(want)], "\x00") == strings.Join(want, "\x00") {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
t.Fatalf("invocations = %#v, want %#v", invocations, want)
|
|
}
|