1235 lines
50 KiB
Go
1235 lines
50 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/aritmolab/thothii/tools/thothctl/internal/compose"
|
|
"github.com/aritmolab/thothii/tools/thothctl/internal/config"
|
|
"github.com/aritmolab/thothii/tools/thothctl/internal/pi"
|
|
"github.com/aritmolab/thothii/tools/thothctl/internal/testsupport"
|
|
"github.com/aritmolab/thothii/tools/thothctl/internal/workspaceops"
|
|
)
|
|
|
|
func TestInstallationRunnerMapsProfileToSessionInventoryScope(t *testing.T) {
|
|
for _, test := range []struct {
|
|
profile string
|
|
want string
|
|
}{
|
|
{profile: "local", want: "mine"},
|
|
{profile: "server", want: "all"},
|
|
} {
|
|
runner := 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 TestUsageDocumentsClosedConfigureUpdateSourcesAndMaintenanceRecovery(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 maintenance status",
|
|
"pi maintenance recover --yes",
|
|
"sessions migrate --yes",
|
|
"remove --yes ID...",
|
|
} {
|
|
if !strings.Contains(usage, required) {
|
|
t.Errorf("usage missing %q", required)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunWorkspacePublicDispatchExitMatrix(t *testing.T) {
|
|
cases := []struct {
|
|
name, status, code string
|
|
exit int
|
|
}{
|
|
{"succeeded", "succeeded", "ok", 0},
|
|
{"blocked", "blocked", "manual_review_required", 3},
|
|
{"failed", "failed", "workspace_not_found", 1},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fixture := newCLIFixture(t, "")
|
|
fixture.setEnvironment(t)
|
|
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", fmt.Sprintf(`{"schemaVersion":1,"status":%q,"code":%q,"workspaceId":"psd","workspaceRevision":"%s","descriptorBlob":"%s","operation":"inspect","completedStages":[]}`, tc.status, tc.code, strings.Repeat("0", 40), strings.Repeat("a", 40)))
|
|
t.Setenv("THOTHCTL_FAKE_WORKSPACE_EXIT", strconv.Itoa(tc.exit))
|
|
var stdout, stderr bytes.Buffer
|
|
got := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd", "--json"}, &stdout, &stderr)
|
|
if got != tc.exit || stderr.Len() != 0 {
|
|
t.Fatalf("exit=%d stderr=%q; want exit %d", got, stderr.String(), tc.exit)
|
|
}
|
|
if len(fixture.invocations(t)) != 1 {
|
|
t.Fatalf("workspace dispatch invocations = %#v, want one", fixture.invocations(t))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunWorkspaceBoundsFinalJSONEncoding(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
final int
|
|
wantCode int
|
|
}{
|
|
{name: "exact", final: 1 << 20, wantCode: 0},
|
|
{name: "one-over", final: (1 << 20) + 1, wantCode: 1},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fixture := newCLIFixture(t, "")
|
|
fixture.setEnvironment(t)
|
|
payload, encodedLength := boundedWorkspaceResultPayload(t, tc.final)
|
|
if encodedLength != tc.final {
|
|
t.Fatalf("final encoded length = %d, want %d", encodedLength, tc.final)
|
|
}
|
|
writeWorkspaceResultFile(t, fixture, payload)
|
|
var stdout, stderr bytes.Buffer
|
|
code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd", "--json"}, &stdout, &stderr)
|
|
if code != tc.wantCode || (tc.wantCode == 0 && stdout.Len() != tc.final) || (tc.wantCode != 0 && stdout.Len() != 0) {
|
|
t.Fatalf("bounded output = exit %d stdout %d stderr %q", code, stdout.Len(), stderr.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type failingWorkspaceWriter struct{}
|
|
|
|
func (failingWorkspaceWriter) Write([]byte) (int, error) {
|
|
return 0, errors.New("injected stdout failure")
|
|
}
|
|
|
|
func TestRunWorkspaceReportsCommittedReconcileOnStdoutFailure(t *testing.T) {
|
|
fixture := newCLIFixture(t, "")
|
|
fixture.setEnvironment(t)
|
|
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", `{"schemaVersion":1,"status":"succeeded","code":"ok","workspaceId":"psd","workspaceRevision":"0000000000000000000000000000000000000000","descriptorBlob":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","operation":"inspect","completedStages":[]}`)
|
|
var stderr bytes.Buffer
|
|
code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd"}, failingWorkspaceWriter{}, &stderr)
|
|
if code != 1 || !strings.Contains(stderr.String(), "reconcile") {
|
|
t.Fatalf("exit=%d stderr=%q, want committed reconcile guidance", code, stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunWorkspaceRejectsDeclaredSecretInFinalJSONKeyBeforeCandidateCommit(t *testing.T) {
|
|
fixture := newCLIFixture(t, "UNLABELLED_SECRET_FILE=%s\n")
|
|
secretPath := filepath.Join(fixture.root, "secret")
|
|
if err := os.WriteFile(secretPath, []byte("schemaVersion"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fixture.setEnvironment(t, secretPath)
|
|
candidate := []byte("candidates: []\n")
|
|
digest := fmt.Sprintf("%x", sha256.Sum256(candidate))
|
|
result := fmt.Sprintf(`{"schemaVersion":1,"status":"succeeded","code":"ok","workspaceId":"psd","workspaceRevision":"%s","descriptorBlob":"%s","operation":"suggest-fks","runId":"0123456789abcdef0123456789abcdef","completedStages":[],"artifactIdentities":[{"kind":"fk-candidates","digest":"%s"}],"hostExport":{"mediaType":"application/yaml","sha256":"%s","contentBase64":"%s"}}`, strings.Repeat("0", 40), strings.Repeat("a", 40), digest, digest, base64.StdEncoding.EncodeToString(candidate))
|
|
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", result)
|
|
output := filepath.Join(fixture.root, "candidate.yaml")
|
|
var stdout, stderr bytes.Buffer
|
|
code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "schema", "suggest-fks", "--workspace", "psd", "--output", output, "--json"}, &stdout, &stderr)
|
|
if code != 1 || stdout.Len() != 0 {
|
|
t.Fatalf("exit=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
|
}
|
|
if _, err := os.Stat(output); !os.IsNotExist(err) {
|
|
t.Fatalf("candidate exists after final-byte secret rejection: %v", err)
|
|
}
|
|
if strings.Contains(stderr.String(), "schemaVersion") {
|
|
t.Fatalf("stderr leaked declared secret: %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunWorkspaceRejectsDeclaredSecretInHumanChrome(t *testing.T) {
|
|
fixture := newCLIFixture(t, "UNLABELLED_SECRET_FILE=%s\n")
|
|
secretPath := filepath.Join(fixture.root, "secret")
|
|
if err := os.WriteFile(secretPath, []byte("Workspace"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fixture.setEnvironment(t, secretPath)
|
|
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", `{"schemaVersion":1,"status":"succeeded","code":"ok","workspaceId":"psd","workspaceRevision":"0000000000000000000000000000000000000000","descriptorBlob":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","operation":"inspect","completedStages":[]}`)
|
|
var stdout, stderr bytes.Buffer
|
|
code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd"}, &stdout, &stderr)
|
|
if code != 1 || stdout.Len() != 0 || strings.Contains(stderr.String(), "Workspace") {
|
|
t.Fatalf("exit=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunWorkspacePartialSecretLoadEmitsNoOutput(t *testing.T) {
|
|
fixture := newCLIFixture(t, "")
|
|
firstSecret := filepath.Join(fixture.root, "first-secret")
|
|
missingSecret := filepath.Join(fixture.root, "missing-secret")
|
|
if err := os.WriteFile(firstSecret, []byte("workspace operation failed"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fixture.setEnvContents(t, "FIRST_SECRET_FILE="+firstSecret+"\nSECOND_SECRET_FILE="+missingSecret+"\n")
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd"}, &stdout, &stderr)
|
|
if code != 2 || stdout.Len() != 0 || stderr.Len() != 0 {
|
|
t.Fatalf("exit=%d stdout=%q stderr=%q, want exit 2 and no output", code, stdout.String(), stderr.String())
|
|
}
|
|
assertDockerNotInvoked(t, fixture)
|
|
}
|
|
|
|
func TestRunWorkspaceErrorPrefixNeverLeaksDeclaredSecret(t *testing.T) {
|
|
fixture := newCLIFixture(t, "UNLABELLED_SECRET_FILE=%s\n")
|
|
secretPath := filepath.Join(fixture.root, "secret")
|
|
if err := os.WriteFile(secretPath, []byte("thothctl:"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fixture.setEnvironment(t, secretPath)
|
|
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", "not-json")
|
|
var stdout, stderr bytes.Buffer
|
|
code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd"}, &stdout, &stderr)
|
|
if code != 1 || stdout.Len() != 0 || strings.Contains(stderr.String(), "thothctl:") {
|
|
t.Fatalf("exit=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunWorkspaceBoundsFinalHumanEncoding(t *testing.T) {
|
|
fixture := newCLIFixture(t, "")
|
|
fixture.setEnvironment(t)
|
|
payload := encodeWorkspaceResultForTest(workspaceops.Result{
|
|
SchemaVersion: 1, Status: "succeeded", Code: "ok", WorkspaceID: "psd",
|
|
WorkspaceRevision: strings.Repeat("0", 40), DescriptorBlob: strings.Repeat("a", 40),
|
|
Operation: "inspect", CompletedStages: []string{},
|
|
})
|
|
writeWorkspaceResultFile(t, fixture, payload)
|
|
var stdout, stderr bytes.Buffer
|
|
code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd"}, &stdout, &stderr)
|
|
if code != 0 || stderr.Len() != 0 {
|
|
t.Fatalf("successful child result was not rendered: exit=%d stderr=%q", code, stderr.String())
|
|
}
|
|
if !strings.HasPrefix(stdout.String(), "Workspace psd: succeeded (ok)\n") {
|
|
t.Fatalf("human output = %q, want successful workspace rendering", stdout.String())
|
|
}
|
|
|
|
// A human rendering can only be larger than the child JSON when it is supplied directly;
|
|
// exercise the same bounded public writer without making the child hit its 1 MiB cap.
|
|
large := workspaceops.Result{
|
|
SchemaVersion: 1, Status: "succeeded", Code: "ok", WorkspaceID: "psd",
|
|
WorkspaceRevision: strings.Repeat("0", 40), DescriptorBlob: strings.Repeat("a", 40),
|
|
Operation: "inspect", CompletedStages: []string{strings.Repeat("x", 1<<20)},
|
|
}
|
|
var bounded bytes.Buffer
|
|
renderWorkspaceHuman(&boundedWriter{dst: &bounded, maximum: 1 << 20}, large)
|
|
if bounded.Len() != 1<<20 {
|
|
t.Fatalf("bounded human output length = %d, want exactly 1 MiB", bounded.Len())
|
|
}
|
|
}
|
|
|
|
func TestRunBoundsParseErrorStderr(t *testing.T) {
|
|
fixture := newCLIFixture(t, "")
|
|
fixture.setEnvironment(t)
|
|
unknownAction := strings.Repeat("x", 70<<10)
|
|
var stdout, stderr bytes.Buffer
|
|
code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", unknownAction, "--workspace", "psd"}, &stdout, &stderr)
|
|
if code != 2 {
|
|
t.Fatalf("exit = %d, want usage exit 2", code)
|
|
}
|
|
if stdout.Len() != 0 || stderr.Len() != 64<<10 {
|
|
t.Fatalf("bounded parse error = stdout %d stderr %d, want 0 and exactly 64 KiB", stdout.Len(), stderr.Len())
|
|
}
|
|
if !strings.HasPrefix(stderr.String(), "thothctl: unknown workspace command: workspace ") || !strings.HasSuffix(stderr.String(), "x") {
|
|
t.Fatalf("parse error was not the truncated unknown action: prefix/suffix mismatch")
|
|
}
|
|
}
|
|
|
|
func TestProjectWorkspaceResultProtectsEveryPublicStringBoundary(t *testing.T) {
|
|
base := workspaceops.Result{
|
|
SchemaVersion: 1, Status: "succeeded", Code: "ok", WorkspaceID: "psd",
|
|
WorkspaceRevision: strings.Repeat("0", 40), DescriptorBlob: strings.Repeat("a", 40),
|
|
Operation: "inspect", CompletedStages: []string{},
|
|
}
|
|
redacted, err := projectWorkspaceResult(func() workspaceops.Result {
|
|
r := base
|
|
r.Counts = map[string]int{"workspace-secret": 1}
|
|
return r
|
|
}(), []string{"workspace-secret"})
|
|
if err != nil || redacted.Counts["[REDACTED]"] != 1 {
|
|
t.Fatalf("counts projection = %#v, err=%v", redacted.Counts, err)
|
|
}
|
|
if _, leaked := redacted.Counts["workspace-secret"]; leaked {
|
|
t.Fatal("secret count key survived projection")
|
|
}
|
|
colliding := base
|
|
colliding.Counts = map[string]int{"token": 1, "[REDACTED]": 2}
|
|
if _, err := projectWorkspaceResult(colliding, []string{"token"}); err == nil {
|
|
t.Fatal("accepted a redacted count-key collision")
|
|
}
|
|
identity := base
|
|
identity.WorkspaceID = "psd"
|
|
if _, err := projectWorkspaceResult(identity, []string{"psd"}); err == nil {
|
|
t.Fatal("rewrote a closed workspace identity")
|
|
}
|
|
spoof := base
|
|
spoof.Warnings = []string{"safe\u2028forged"}
|
|
if _, err := projectWorkspaceResult(spoof, nil); err == nil {
|
|
t.Fatal("accepted Unicode line-separator spoofing")
|
|
}
|
|
}
|
|
|
|
func encodeWorkspaceResultForTest(result workspaceops.Result) []byte {
|
|
var encoded bytes.Buffer
|
|
encoder := json.NewEncoder(&encoded)
|
|
encoder.SetEscapeHTML(false)
|
|
if err := encoder.Encode(result); err != nil {
|
|
panic(err)
|
|
}
|
|
return encoded.Bytes()
|
|
}
|
|
|
|
func boundedWorkspaceResultPayload(t *testing.T, finalLength int) ([]byte, int) {
|
|
t.Helper()
|
|
result := workspaceops.Result{
|
|
SchemaVersion: 1, Status: "succeeded", Code: "ok", WorkspaceID: "psd",
|
|
WorkspaceRevision: strings.Repeat("0", 40), DescriptorBlob: strings.Repeat("a", 40),
|
|
Operation: "inspect", CompletedStages: []string{},
|
|
// A multibyte UTF-8 warning exercises the final output bound without
|
|
// introducing a Unicode separator that the public projection rejects.
|
|
Warnings: []string{strings.Repeat("é", 1000)},
|
|
}
|
|
encoded := encodeWorkspaceResultForTest(result)
|
|
if len(encoded) >= finalLength {
|
|
t.Fatalf("base encoded result length = %d, cannot reach target %d", len(encoded), finalLength)
|
|
}
|
|
result.Warnings[0] += strings.Repeat("x", finalLength-len(encoded))
|
|
encoded = encodeWorkspaceResultForTest(result)
|
|
if len(encoded) != finalLength {
|
|
t.Fatalf("final encoded result length = %d, want %d", len(encoded), finalLength)
|
|
}
|
|
child := bytes.ReplaceAll(encoded, []byte(`\u2028`), []byte("\u2028"))
|
|
return child, len(encoded)
|
|
}
|
|
|
|
func writeWorkspaceResultFile(t *testing.T, fixture cliFixture, payload []byte) {
|
|
t.Helper()
|
|
path := filepath.Join(fixture.root, "workspace-result.json")
|
|
if err := os.WriteFile(path, payload, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT_FILE", path)
|
|
}
|
|
|
|
func TestRunWorkspaceOperationalFailureExitsOne(t *testing.T) {
|
|
fixture := newCLIFixture(t, "")
|
|
fixture.setEnvironment(t)
|
|
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", "not-json")
|
|
var stdout, stderr bytes.Buffer
|
|
got := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd"}, &stdout, &stderr)
|
|
if got != 1 || !strings.Contains(stderr.String(), "invalid workspace result") {
|
|
t.Fatalf("exit=%d stderr=%q; want operational exit 1", got, stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunWorkspaceUnsafeOutputDoesNotInvokeCompose(t *testing.T) {
|
|
fixture := newCLIFixture(t, "")
|
|
fixture.setEnvironment(t)
|
|
output := filepath.Join(fixture.root, "existing.yaml")
|
|
if err := os.WriteFile(output, []byte("existing"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
got := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "schema", "suggest-fks", "--workspace", "psd", "--output", output}, &stdout, &stderr)
|
|
if got != 2 || !strings.Contains(stderr.String(), "unsafe output file") {
|
|
t.Fatalf("exit=%d stderr=%q; want unsafe host failure", got, stderr.String())
|
|
}
|
|
assertDockerNotInvoked(t, fixture)
|
|
}
|
|
|
|
func TestRunWorkspaceRejectsInvalidCommandBeforeDocker(t *testing.T) {
|
|
fixture := newCLIFixture(t, "")
|
|
fixture.setEnvironment(t)
|
|
var stdout, stderr bytes.Buffer
|
|
code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "PSd"}, &stdout, &stderr)
|
|
if code != 2 || !strings.Contains(stderr.String(), "workspace") {
|
|
t.Fatalf("exit=%d stderr=%q", code, stderr.String())
|
|
}
|
|
assertDockerNotInvoked(t, fixture)
|
|
}
|
|
|
|
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("THOTHCTL_FAKE_CONFIG", `{"services":{"core":{"image":"thothii-core:local"},"session-migrate":{"image":"thothii-core:local"}}}`)
|
|
t.Setenv("THOTHCTL_FAKE_MIGRATION_FAILURE", spec.failure)
|
|
t.Setenv("THOTHCTL_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("THOTHCTL_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("THOTHCTL_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 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("THOTHCTL_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("THOTHCTL_FAKE_LOG", "stdout source-secret")
|
|
t.Setenv("THOTHCTL_FAKE_FAILURE", "stderr source-secret")
|
|
t.Setenv("THOTHCTL_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("THOTHCTL_FAKE_FAIL_ON", "version")
|
|
t.Setenv("THOTHCTL_FAKE_FAILURE", "doctor saw doctor-secret")
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "doctor"}, &stdout, &stderr)
|
|
|
|
if exitCode != 41 {
|
|
t.Errorf("run() exit code = %d, want 41", 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)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "doctor"}, &stdout, &stderr)
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("run() exit code = %d, stderr = %s", exitCode, stderr.String())
|
|
}
|
|
if stdout.String() != "Doctor checks passed.\n" {
|
|
t.Errorf("stdout = %q, want doctor success", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunPreservesChildExitCodes(t *testing.T) {
|
|
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
|
|
fixture.setEnvironment(t)
|
|
t.Setenv("THOTHCTL_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 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 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 TestRunPiUpdateRequiresExplicitSourceWithoutInvokingDocker(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", "--yes",
|
|
}, &stdout, &stderr)
|
|
|
|
if exitCode != 2 {
|
|
t.Errorf("run() exit code = %d, want 2", exitCode)
|
|
}
|
|
if !strings.Contains(stderr.String(), "requires explicit --source build or pull") {
|
|
t.Errorf("stderr = %q, want source guidance", stderr.String())
|
|
}
|
|
assertDockerNotInvoked(t, fixture)
|
|
}
|
|
|
|
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 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("THOTHCTL_FAKE_FAIL_ON", "version")
|
|
t.Setenv("THOTHCTL_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())
|
|
}
|
|
}
|
|
|
|
func TestRunWorkspacePublicProjectionRedactsSecrets(t *testing.T) {
|
|
fixture := newCLIFixture(t, "TOKEN_FILE=%s\n")
|
|
secretPath := filepath.Join(fixture.root, "token")
|
|
if err := os.WriteFile(secretPath, []byte("workspace-secret"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fixture.setEnvironment(t, secretPath)
|
|
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", fmt.Sprintf(`{"schemaVersion":1,"status":"succeeded","code":"ok","workspaceId":"psd","workspaceRevision":"%s","descriptorBlob":"%s","operation":"inspect","completedStages":["stage workspace-secret"],"warnings":["warning workspace-secret"]}`, strings.Repeat("0", 40), strings.Repeat("a", 40)))
|
|
var stdout, stderr bytes.Buffer
|
|
if code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd", "--json"}, &stdout, &stderr); code != 0 {
|
|
t.Fatalf("run() exit=%d stderr=%q", code, stderr.String())
|
|
}
|
|
if strings.Contains(stdout.String(), "workspace-secret") || !strings.Contains(stdout.String(), "[REDACTED]") {
|
|
t.Fatalf("public JSON = %q, want redacted secret", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunWorkspacePublicProjectionRejectsHumanControlCharacters(t *testing.T) {
|
|
fixture := newCLIFixture(t, "")
|
|
fixture.setEnvironment(t)
|
|
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", fmt.Sprintf(`{"schemaVersion":1,"status":"succeeded","code":"ok","workspaceId":"psd","workspaceRevision":"%s","descriptorBlob":"%s","operation":"inspect","completedStages":["safe\nforged"]}`, strings.Repeat("0", 40), strings.Repeat("a", 40)))
|
|
var stdout, stderr bytes.Buffer
|
|
if code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd"}, &stdout, &stderr); code != 1 || stdout.Len() != 0 {
|
|
t.Fatalf("run() exit=%d stdout=%q stderr=%q, want rejected output", code, stdout.String(), stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunNonWorkspaceOutputIsNotGloballyCapped(t *testing.T) {
|
|
fixture := newCLIFixture(t, "")
|
|
fixture.setEnvironment(t)
|
|
logPath := filepath.Join(fixture.root, "large.log")
|
|
if err := os.WriteFile(logPath, []byte(strings.Repeat("log-line\n", 200000)), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("THOTHCTL_FAKE_LOG_FILE", logPath)
|
|
var stdout, stderr bytes.Buffer
|
|
if code := run(context.Background(), []string{"--installation", fixture.installationPath, "logs"}, &stdout, &stderr); code != 0 {
|
|
t.Fatalf("run() exit=%d stderr=%q invocations=%#v", code, stderr.String(), fixture.invocations(t))
|
|
}
|
|
if stdout.Len() <= maxPublicStdoutBytes {
|
|
t.Fatalf("non-workspace output length=%d, want greater than cap %d", stdout.Len(), maxPublicStdoutBytes)
|
|
}
|
|
}
|
|
|
|
type cliFixture struct {
|
|
root string
|
|
installationPath string
|
|
projectDirectory string
|
|
envFile string
|
|
argsFile string
|
|
pathDirectory string
|
|
envTemplate string
|
|
}
|
|
|
|
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, "thothctl-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)
|
|
}
|
|
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")
|
|
installationPath := filepath.Join(root, "thothii-installation.yaml")
|
|
contents := "profile: local\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\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' "$@" >> "$THOTHCTL_FAKE_ARGS"
|
|
printf '%s\n' -- >> "$THOTHCTL_FAKE_ARGS"
|
|
case " $* " in
|
|
*" ps --all --format json core frontend "*) printf '%s\n' "${THOTHCTL_FAKE_STOPPED_PS:-[]}" ;;
|
|
*" config --format json "*)
|
|
if [ -n "${THOTHCTL_FAKE_CONFIG:-}" ]; then
|
|
printf '%s\n' "$THOTHCTL_FAKE_CONFIG"
|
|
else
|
|
printf '%s\n' '{"volumes":{"settings":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}'
|
|
fi ;;
|
|
*" run --rm --no-deps --no-TTY workspace-maintenance "*)
|
|
workspace_result="${THOTHCTL_FAKE_WORKSPACE_RESULT:-}"
|
|
if [ -n "${THOTHCTL_FAKE_WORKSPACE_RESULT_FILE:-}" ]; then
|
|
workspace_result=$(/bin/cat "$THOTHCTL_FAKE_WORKSPACE_RESULT_FILE")
|
|
fi
|
|
printf '%s\n' "$workspace_result"
|
|
exit "${THOTHCTL_FAKE_WORKSPACE_EXIT:-0}" ;;
|
|
*" run --rm --no-deps --no-TTY session-migrate "*)
|
|
if [ "${THOTHCTL_FAKE_MIGRATION_EXIT:-0}" -ne 0 ]; then
|
|
printf '%s\n' "$THOTHCTL_FAKE_MIGRATION_FAILURE" >&2
|
|
exit "$THOTHCTL_FAKE_MIGRATION_EXIT"
|
|
fi
|
|
printf '%s\n' '{"applied":[],"drifted":[],"pending":[]}' ;;
|
|
*" ps --format json "*) printf '%s\n' '[{"Service":"core","State":"running","Health":"healthy"},{"Service":"frontend","State":"running","Health":"healthy"}]' ;;
|
|
*"io.thothii.pi.version"*) printf '%s\n' '0.80.3' ;;
|
|
*"PI_VERSION"*) printf '%s\n' '0.80.3' ;;
|
|
*" pi --version "*) printf '%s\n' '0.80.3' ;;
|
|
*"/pi-management/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":""}' ;;
|
|
*"/settings "*) printf '%s\n' '{"provider":"provider","model":"model","thinking":"medium"}' ;;
|
|
*"/internal/maintenance/status "*) printf '%s\n' '{"active":true,"admissions":0}' ;;
|
|
*" logs "*)
|
|
if [ -n "${THOTHCTL_FAKE_LOG_FILE:-}" ]; then /bin/cat "$THOTHCTL_FAKE_LOG_FILE"; else printf '%s\n' "$THOTHCTL_FAKE_LOG"; fi ;;
|
|
esac
|
|
if [ "${THOTHCTL_FAKE_FAIL_ON:-}" = "version" ]; then
|
|
printf '%s\n' "${THOTHCTL_FAKE_FAILURE:-fake Docker failure}" >&2
|
|
exit 41
|
|
fi
|
|
if [ "${THOTHCTL_FAKE_EXIT:-0}" -ne 0 ]; then
|
|
printf '%s\n' "${THOTHCTL_FAKE_FAILURE:-fake Docker failure}" >&2
|
|
fi
|
|
exit "${THOTHCTL_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()
|
|
if err := os.WriteFile(f.envFile, []byte(env), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("PATH", f.pathDirectory)
|
|
t.Setenv("THOTHCTL_FAKE_ARGS", f.argsFile)
|
|
t.Setenv("THOTHCTL_FAKE_EXIT", "0")
|
|
t.Setenv("THOTHCTL_FAKE_LOG", "")
|
|
t.Setenv("THOTHCTL_FAKE_LOG_FILE", "")
|
|
t.Setenv("THOTHCTL_FAKE_FAILURE", "")
|
|
t.Setenv("THOTHCTL_FAKE_FAIL_ON", "")
|
|
t.Setenv("THOTHCTL_FAKE_STOPPED_PS", "[]")
|
|
t.Setenv("THOTHCTL_FAKE_CONFIG", "")
|
|
t.Setenv("THOTHCTL_FAKE_MIGRATION_FAILURE", "")
|
|
t.Setenv("THOTHCTL_FAKE_MIGRATION_EXIT", "0")
|
|
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", "")
|
|
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT_FILE", "")
|
|
t.Setenv("THOTHCTL_FAKE_WORKSPACE_EXIT", "0")
|
|
}
|
|
|
|
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 + "\n"
|
|
if err := os.WriteFile(f.installationPath, []byte(contents), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
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 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)
|
|
}
|
|
|
|
func TestRenderWorkspaceHumanHidesRecoveryIdentity(t *testing.T) {
|
|
var out bytes.Buffer
|
|
renderWorkspaceHuman(&out, workspaceops.Result{WorkspaceID: "psd", Status: "blocked", Code: workspaceops.CodeRegistryBootstrapRecoveryConflict, RunID: "0123456789abcdef0123456789abcdef"})
|
|
if out.String() != "Bootstrap recovery is ambiguous or corrupt; inspect the installation registry jobs.\n" {
|
|
t.Fatalf("human output = %q", out.String())
|
|
}
|
|
}
|
|
|
|
func TestWorkspaceOperationalEnvelopeFailuresAreExitOne(t *testing.T) {
|
|
for _, err := range []error{compose.ErrOutputLimit, errors.New("invalid workspace result"), errors.New("invalid workspace request"), errors.New("invalid host export")} {
|
|
if workspaceUsageError(err) {
|
|
t.Errorf("classified operational error %q as usage", err)
|
|
}
|
|
}
|
|
for _, err := range []error{errors.New("unsafe output file"), errors.New("request exceeds limit"), errors.New("unsafe SQL input")} {
|
|
if !workspaceUsageError(err) {
|
|
t.Errorf("classified host error %q as operational", err)
|
|
}
|
|
}
|
|
}
|