261 lines
11 KiB
Go
261 lines
11 KiB
Go
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, "/models"):
|
|
return compose.Result{Stdout: `{"models":[{"provider":"old","id":"old-model"},{"provider":"new","id":"new-model"}]}`}, 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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConfigureValidatesBackendModelOptionsWritesRealCoreSettingsAndUsesUpstreamIdentity(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, "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")
|
|
}
|
|
}
|
|
|
|
func TestTestUsesOnlySanitizedPiAndCoreProbes(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", "/health", "/models", "/settings"} {
|
|
assertCalled(t, fake.calls, command)
|
|
}
|
|
if got := strings.Join(fake.calls, "\n"); strings.Contains(got, "secret") {
|
|
t.Fatalf("probe commands expose secret: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestTestRequiresConfiguredProviderAndModelToMatchOneAvailableEntry(t *testing.T) {
|
|
fake := newFakeRunner()
|
|
fake.modelsWire = `{"models":[{"id":"different-model","provider":"provider"}]}`
|
|
if err := Test(context.Background(), fake); err == nil || !strings.Contains(err.Error(), "configured provider/model") {
|
|
t.Fatalf("Test() error = %v, want exact settings/model mismatch", err)
|
|
}
|
|
fake.modelsWire = `{"models":[{"id":"model","provider":"provider"}]}`
|
|
if err := Test(context.Background(), fake); err != nil {
|
|
t.Fatalf("Test() exact match error = %v", err)
|
|
}
|
|
assertCalled(t, fake.calls, "pi --version")
|
|
}
|