package pi import ( "context" "encoding/base64" "encoding/json" "errors" "io" "strings" "testing" "github.com/aritmolab/thothii/tools/thothctl/internal/compose" ) func TestDoctorRequiresExternalEndpointAuthPiStateAndHealth(t *testing.T) { fake := newFakeRunner() if err := Doctor(context.Background(), fake); err != nil { t.Fatalf("Doctor() error = %v", err) } for _, command := range []string{"pi --version", "PI_VERSION", "io.thothii.pi.version", "test -w /home/thoth/.pi", "test -r /home/thoth/.pi/agent/auth.json", "/health"} { assertCalled(t, fake.calls, command) } } func TestDoctorRejectsActualEnvironmentAndImageLabelVersionMismatches(t *testing.T) { for _, mismatch := range []string{"actual", "environment", "label"} { t.Run(mismatch, func(t *testing.T) { fake := newFakeRunner() switch mismatch { case "actual": fake.version = "0.80.2" case "environment": fake.expectedVersion = "0.80.2" case "label": fake.labelVersion = "0.80.2" } if err := Doctor(context.Background(), fake); err == nil || !strings.Contains(err.Error(), "version") { t.Fatalf("Doctor() error = %v, want expected-version mismatch", err) } }) } } func TestConfigureRestoresAndVerifiesOldSettingsAfterEveryPostSnapshotFailure(t *testing.T) { for _, failure := range []string{"helper", "readback", "digest"} { t.Run(failure, func(t *testing.T) { old := Defaults{Provider: "old", Model: "old-model", Thinking: "low"} raw, _ := json.Marshal(old) fake := &configureRunner{failure: failure, settings: old, settingsExist: true, settingsRaw: raw} err := Configure(context.Background(), fake, Defaults{Provider: "new", Model: "new-model", Thinking: "high"}) if err == nil { t.Fatal("Configure() error = nil, want injected failure") } if fake.settings != (Defaults{Provider: "old", Model: "old-model", Thinking: "low"}) { t.Fatalf("settings after failure = %#v, want old snapshot", fake.settings) } if !fake.settingsExist || string(fake.settingsRaw) != string(raw) { t.Fatalf("settings raw snapshot after failure = exists:%t raw:%q, want %q", fake.settingsExist, fake.settingsRaw, raw) } }) } } func TestSettingsRestoreDoesNotMaskExplicitDurabilityFailureWithMatchingReadback(t *testing.T) { old := Defaults{Provider: "old", Model: "old-model", Thinking: "low"} raw, _ := json.Marshal(old) fake := &configureRunner{ failure: "restore-durability", settings: Defaults{Provider: "new", Model: "new-model", Thinking: "high"}, settingsExist: true, settingsRaw: []byte(`{"provider":"new","model":"new-model","thinking":"high"}`), } snapshot := settingsFileSnapshot{Exists: true, RawBase64: base64.StdEncoding.EncodeToString(raw)} err := restoreSettingsFile(context.Background(), fake, snapshot) var recovery interface{ RecoveryRequired() bool } if err == nil || !errors.As(err, &recovery) || !recovery.RecoveryRequired() { t.Fatalf("restore error = %v; want typed recovery-required result", err) } if !fake.settingsExist || string(fake.settingsRaw) != string(raw) || fake.settings != old { t.Fatalf("restored state = exists:%t raw:%q value:%#v; want exact old bytes", fake.settingsExist, fake.settingsRaw, fake.settings) } } func TestConfigurePreservesTypedRecoveryRequiredErrorFromSettingsRestore(t *testing.T) { old := Defaults{Provider: "old", Model: "old-model", Thinking: "low"} raw, _ := json.Marshal(old) fake := &configureRunner{ failure: "helper", restoreDurabilityFailure: true, settings: old, settingsExist: true, settingsRaw: raw, } err := Configure(context.Background(), fake, Defaults{Provider: "new", Model: "new-model", Thinking: "high"}) var recovery interface{ RecoveryRequired() bool } if err == nil || !errors.As(err, &recovery) || !recovery.RecoveryRequired() { t.Fatalf("Configure() error = %v; want typed recovery-required result", err) } } type configureRunner struct { failure string restoreDurabilityFailure bool settings Defaults settingsExist bool settingsRaw []byte settingsReads int configReads int writes int } func (f *configureRunner) Run(_ context.Context, args []string, stdin io.Reader) (compose.Result, error) { call := strings.Join(args, " ") switch { case strings.Contains(call, "config --format json"): f.configReads++ endpoint := "https://llm.example.invalid" if f.failure == "digest" && f.configReads > 1 { endpoint = "https://drift.example.invalid" } return compose.Result{Stdout: `{"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"` + endpoint + `"}}}}`}, nil case strings.Contains(call, "/pi-management/options"): return compose.Result{Stdout: `{"providers":["old","new"],"models":[{"provider":"old","id":"old-model"},{"provider":"new","id":"new-model"}],"reasoning":["low","medium","high"]}`}, nil case strings.Contains(call, "settings-cli.js --snapshot"): raw := f.settingsRaw payload := map[string]any{"exists": f.settingsExist, "rawBase64": base64.StdEncoding.EncodeToString(raw)} contents, _ := json.Marshal(payload) return compose.Result{Stdout: string(contents)}, nil case strings.Contains(call, "settings-cli.js --restore"): var payload struct { Exists bool `json:"exists"` RawBase64 string `json:"rawBase64"` } contents, _ := io.ReadAll(stdin) if json.Unmarshal(contents, &payload) != nil { return compose.Result{ExitCode: 2}, errors.New("invalid restore payload") } f.settingsExist = payload.Exists f.settingsRaw, _ = base64.StdEncoding.DecodeString(payload.RawBase64) f.settings = Defaults{Provider: "old", Model: "old-model", Thinking: "low"} if payload.Exists { _ = json.Unmarshal(f.settingsRaw, &f.settings) } if f.failure == "restore-durability" || f.restoreDurabilityFailure { return compose.Result{ExitCode: 2}, errors.New("injected post-rename directory fsync failure") } return compose.Result{}, nil case strings.Contains(call, "settings-cli.js"): if strings.Contains(call, "--provider new") { f.settings = Defaults{Provider: "new", Model: "new-model", Thinking: "high"} f.settingsExist = true f.settingsRaw, _ = json.MarshalIndent(f.settings, "", " ") f.writes++ if f.failure == "helper" { return compose.Result{ExitCode: 17}, errors.New("injected helper failure") } } else { f.settings = Defaults{Provider: "old", Model: "old-model", Thinking: "low"} f.settingsExist = true f.settingsRaw, _ = json.Marshal(f.settings) } return compose.Result{}, nil case strings.Contains(call, "/settings"): f.settingsReads++ if f.failure == "readback" && f.settings.Provider == "new" { return compose.Result{Stdout: `{}`}, nil } if !f.settingsExist { return compose.Result{Stdout: `{"provider":"old","model":"old-model","thinking":"low"}`}, nil } contents, _ := json.Marshal(f.settings) return compose.Result{Stdout: string(contents)}, nil default: return compose.Result{}, nil } } func TestConfigureAllowsAFirstRunWithoutAnExistingSettingsFile(t *testing.T) { fake := &configureRunner{} if err := Configure(context.Background(), fake, Defaults{Provider: "new", Model: "new-model", Thinking: "high"}); err != nil { t.Fatalf("Configure() clean install error = %v", err) } if !fake.settingsExist || fake.writes != 1 || fake.settings.Provider != "new" { t.Fatalf("clean settings = exists:%t writes:%d value:%#v", fake.settingsExist, fake.writes, fake.settings) } } func TestConfigureCompensationRestoresAbsentAndExactEmptyPriorFiles(t *testing.T) { for _, prior := range []struct { name string exists bool raw []byte }{ {name: "absent"}, {name: "empty", exists: true, raw: []byte{}}, {name: "exact raw", exists: true, raw: []byte("{\n \"workspace\": \"kept\",\n \"provider\": \"old\",\n \"model\": \"old-model\",\n \"thinking\": \"low\"\n}\n")}, } { t.Run(prior.name, func(t *testing.T) { fake := &configureRunner{failure: "digest", settingsExist: prior.exists, settingsRaw: append([]byte{}, prior.raw...), settings: Defaults{Provider: "old", Model: "old-model", Thinking: "low"}} err := Configure(context.Background(), fake, Defaults{Provider: "new", Model: "new-model", Thinking: "high"}) if err == nil { t.Fatal("Configure() error = nil, want compensated digest failure") } if fake.writes != 1 { t.Fatalf("settings writes = %d, want selected values written before compensation", fake.writes) } if fake.settingsExist != prior.exists || string(fake.settingsRaw) != string(prior.raw) { t.Fatalf("restored exists/raw = %t/%q, want %t/%q", fake.settingsExist, fake.settingsRaw, prior.exists, prior.raw) } if fake.settingsReads < 3 { t.Fatalf("settings reads = %d, want prior effective state, requested readback, and restored default verification", fake.settingsReads) } }) } } // Catches thothctl reading the legacy public model route instead of the admin-only closed Pi // Management choices before it writes shared installation defaults. func TestConfigureLoadsDedicatedClosedOptionsWritesRealCoreSettingsAndUsesUpstreamIdentity(t *testing.T) { fake := newFakeRunner() if err := Configure(context.Background(), fake, Defaults{Provider: "provider", Model: "model", Thinking: "medium"}); err != nil { t.Fatal(err) } assertCalled(t, fake.calls, "/pi-management/options") assertCalled(t, fake.calls, "node /app/backend/dist/settings/settings-cli.js --provider provider --model model --thinking medium") assertCalled(t, fake.calls, "x-thoth-principal-subject: thothctl-maintenance") if got := strings.Join(fake.calls, "\n"); strings.Contains(got, "pi-defaults.json") || strings.Contains(got, "secret") { t.Fatalf("commands=%q", got) } if err := Configure(context.Background(), fake, Defaults{Provider: "provider", Model: "unknown", Thinking: "medium"}); err == nil { t.Fatal("expected unknown model rejection") } } // Catches a smoke check that composes health/models/settings itself and drifts from the dedicated // backend contract, rather than retaining only the independent in-container version signal. func TestTestUsesDedicatedSmokeEndpointAndIndependentImageVersionProbe(t *testing.T) { fake := newFakeRunner() if err := Test(context.Background(), fake); err != nil { t.Fatalf("Test() error = %v", err) } for _, command := range []string{"pi --version", "/pi-management/test", "x-thoth-principal-subject: thothctl-maintenance"} { assertCalled(t, fake.calls, command) } for _, legacy := range []string{"/health", "/models", "/settings"} { if strings.Contains(strings.Join(fake.calls, "\n"), legacy) { t.Fatalf("Pi smoke invoked legacy endpoint %q: %s", legacy, strings.Join(fake.calls, "\n")) } } if got := strings.Join(fake.calls, "\n"); strings.Contains(got, "secret") { t.Fatalf("probe commands expose secret: %s", got) } } // Catches an ignored negative ready result from the backend smoke endpoint, which would report a // successfully verified candidate image while its configured Pi runtime is unusable. func TestTestRequiresDedicatedSmokeEndpointToReportReady(t *testing.T) { fake := newFakeRunner() fake.piManagementTestWire = `{"ready":false,"message":"provider unavailable"}` if err := Test(context.Background(), fake); err == nil || !strings.Contains(err.Error(), "Pi smoke response is not ready") { t.Fatalf("Test() error = %v, want negative dedicated smoke result", err) } fake.piManagementTestWire = `{"ready":true}` if err := Test(context.Background(), fake); err != nil { t.Fatalf("Test() exact match error = %v", err) } assertCalled(t, fake.calls, "pi --version") } // Catches thothctl accepting a reasoning level that the backend did not publish as a closed // installation option, which would bypass the Pi Management validation surface. func TestConfigureRejectsReasoningOutsideDedicatedClosedOptions(t *testing.T) { fake := newFakeRunner() fake.piManagementOptionsWire = `{"providers":["provider"],"models":[{"provider":"provider","id":"model"}],"reasoning":["low"]}` if err := Configure(context.Background(), fake, Defaults{Provider: "provider", Model: "model", Thinking: "high"}); err == nil || !strings.Contains(err.Error(), "Pi options") { t.Fatalf("Configure() error = %v, want closed reasoning rejection", err) } }