package serverops import ( "context" "errors" "io" "os" "path/filepath" "reflect" "strconv" "strings" "testing" "github.com/aritmolab/thothii/tools/thothctl/internal/compose" "github.com/aritmolab/thothii/tools/thothctl/internal/config" ) type fakeRunner struct { run func(args []string) (compose.Result, error) all [][]string } func (r *fakeRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) { r.all = append(r.all, append([]string(nil), args...)) return r.run(args) } func TestMigrateSessionsUsesOnlyTheMigrationProfileAndSelectedCoreImage(t *testing.T) { for _, image := range []string{ "thothii-core:local", "registry.example.invalid/thothii/core@sha256:" + strings.Repeat("a", 64), } { t.Run(image, func(t *testing.T) { installation := testInstallation(t) if err := os.MkdirAll(installation.ControlDirectory(), 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(installation.CurrentImageOverridePath(), []byte("services:\n core:\n image: "+image+"\n"), 0o600); err != nil { t.Fatal(err) } var temporaryOverride string configCalls := 0 runner := &fakeRunner{run: func(args []string) (compose.Result, error) { switch { case contains(args, "ps", "--all", "--format", "json", "core", "frontend"): return compose.Result{Stdout: `[{"ID":"core-id","Name":"core-name","Service":"core","State":"exited"},{"ID":"front-id","Name":"front-name","Service":"frontend","State":"exited"}]`}, nil case contains(args, "--profile", "session-migrate", "config", "--format", "json"): configCalls++ if configCalls == 1 { return compose.Result{Stdout: `{"services":{"core":{"image":"` + image + `"},"session-migrate":{"image":"thothii-core:local"}}}`}, nil } temporaryOverride = lastComposeFile(args) contents, err := os.ReadFile(temporaryOverride) if err != nil { t.Fatal(err) } if !strings.Contains(string(contents), "image: "+strconv.Quote(image)) || !strings.Contains(string(contents), "build: !reset null") { t.Fatalf("migration override = %q", contents) } if indexOf(args, installation.CurrentImageOverridePath()) >= indexOf(args, temporaryOverride) { t.Fatalf("temporary override does not follow durable selector: %#v", args) } return compose.Result{Stdout: `{"services":{"core":{"image":"` + image + `"},"session-migrate":{"image":"` + image + `"}}}`}, nil case contains(args, "--profile", "session-migrate", "run", "--rm", "--no-deps", "--no-TTY", "session-migrate"): return compose.Result{Stdout: `{"applied":["0001"],"drifted":[],"pending":[]}` + "\n"}, nil default: t.Fatalf("unexpected Docker invocation: %#v", args) return compose.Result{}, nil } }} status, err := MigrateSessions(context.Background(), installation, runner, true) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(status.Pending, []string{}) || !reflect.DeepEqual(status.Drifted, []string{}) { t.Fatalf("status = %#v", status) } if temporaryOverride == "" { t.Fatal("migration override was not inspected") } if _, err := os.Stat(temporaryOverride); !errors.Is(err, os.ErrNotExist) { t.Fatalf("temporary override remains after migration: %v", err) } }) } } func TestMigrateSessionsFailsClosedBeforeMutation(t *testing.T) { installation := testInstallation(t) for name, spec := range map[string]struct { confirmed bool ps string migrationJSON string }{ "confirmation missing": {false, `[]`, `{"applied":[],"drifted":[],"pending":[]}`}, "service running": {true, `[{"ID":"core-id","Name":"core","Service":"core","State":"running"}]`, `{"applied":[],"drifted":[],"pending":[]}`}, "pending migration": {true, `[]`, `{"applied":[],"drifted":[],"pending":["0002"]}`}, "drifted migration": {true, `[]`, `{"applied":[],"drifted":["0001"],"pending":[]}`}, } { t.Run(name, func(t *testing.T) { runCalled := false configCalls := 0 runner := &fakeRunner{run: func(args []string) (compose.Result, error) { switch { case contains(args, "ps", "--all"): return compose.Result{Stdout: spec.ps}, nil case contains(args, "config", "--format", "json"): configCalls++ return compose.Result{Stdout: `{"services":{"core":{"image":"thothii-core:local"},"session-migrate":{"image":"thothii-core:local"}}}`}, nil case contains(args, "run", "--rm", "--no-deps", "--no-TTY", "session-migrate"): runCalled = true return compose.Result{Stdout: spec.migrationJSON}, nil default: t.Fatalf("unexpected Docker invocation: %#v", args) return compose.Result{}, nil } }} _, err := MigrateSessions(context.Background(), installation, runner, spec.confirmed) if err == nil { t.Fatal("MigrateSessions() error = nil") } if !spec.confirmed && len(runner.all) != 0 { t.Fatalf("Docker invoked without confirmation: %#v", runner.all) } if strings.Contains(name, "service running") && (runCalled || configCalls != 0) { t.Fatalf("migration advanced while app was running: %#v", runner.all) } }) } } func TestMigrateSessionsPreservesCompleteFailureDetailBehindTypedMetadata(t *testing.T) { longSecret := "long-secret-" + strings.Repeat("s", 700) for _, spec := range []struct { name string secret string stderr string }{ {name: "secret longer than display limit", secret: longSecret, stderr: longSecret + " rejected"}, {name: "secret crossing display boundary", secret: "boundary-secret-value", stderr: strings.Repeat("p", 500) + "boundary-secret-value rejected"}, } { t.Run(spec.name, func(t *testing.T) { installation := testInstallation(t) configCalls := 0 runner := &fakeRunner{run: func(args []string) (compose.Result, error) { switch { case contains(args, "ps", "--all"): return compose.Result{Stdout: `[]`}, nil case contains(args, "config", "--format", "json"): configCalls++ return compose.Result{Stdout: `{"services":{"core":{"image":"thothii-core:local"},"session-migrate":{"image":"thothii-core:local"}}}`}, nil case contains(args, "run", "--rm", "--no-deps", "--no-TTY", "session-migrate"): return compose.Result{Stderr: spec.stderr, ExitCode: 23}, errors.New("exit status 23") default: t.Fatalf("unexpected Docker invocation: %#v", args) return compose.Result{}, nil } }} _, err := MigrateSessions(context.Background(), installation, runner, true) var operationErr *OperationError if !errors.As(err, &operationErr) { t.Fatalf("MigrateSessions() error = %T %v, want OperationError", err, err) } if operationErr.Stage() != StageSessionMigration || operationErr.Class() != ExitClassNonzero { t.Fatalf("operation error = %#v", operationErr) } if strings.Contains(operationErr.Error(), spec.secret[:12]) { t.Fatalf("typed metadata exposed secret prefix: %q", operationErr.Error()) } if detail := operationErr.Detail(); detail != spec.stderr || !strings.Contains(detail, spec.secret) { t.Fatalf("detail was truncated before redaction: length=%d", len(detail)) } if configCalls != 2 { t.Fatalf("config calls = %d", configCalls) } }) } } func TestRemovePreservesEveryDeclaredBindSecretAndBackup(t *testing.T) { installation, preserved := removalInstallation(t) psCalls := 0 runner := &fakeRunner{run: func(args []string) (compose.Result, error) { switch { case contains(args, "ps", "--all", "--format", "json", "core", "frontend"): psCalls++ if psCalls == 1 { return compose.Result{Stdout: `[{"ID":"core-id","Name":"project-core-1","Service":"core","State":"exited"},{"ID":"frontend-id","Name":"project-frontend-1","Service":"frontend","State":"exited"}]`}, nil } return compose.Result{Stdout: `[]`}, nil case reflect.DeepEqual(args, []string{"rm", "core-id", "frontend-id"}): return compose.Result{Stdout: "core-id\nfrontend-id\n"}, nil default: t.Fatalf("unexpected Docker invocation: %#v", args) return compose.Result{}, nil } }} result, err := Remove(context.Background(), installation, runner, []string{"core-id", "frontend-id"}) if err != nil { t.Fatal(err) } if result.Preserved != len(preserved) { t.Fatalf("preserved = %d, want %d", result.Preserved, len(preserved)) } if got := result.Targets; len(got) != 2 || got[0].ID != "core-id" || got[1].ID != "frontend-id" { t.Fatalf("targets = %#v", got) } for _, args := range runner.all { joined := strings.Join(args, " ") if strings.Contains(joined, " -v") || strings.Contains(joined, "volume") || strings.Contains(joined, "down") || strings.Contains(joined, "prune") { t.Fatalf("destructive removal invocation: %q", joined) } } for _, path := range preserved { if _, err := os.Stat(path); err != nil { t.Errorf("preserved path %q: %v", path, err) } } } func TestRemoveDisplaysTargetsButDoesNotMutateWithoutConfirmation(t *testing.T) { installation, _ := removalInstallation(t) runner := &fakeRunner{run: func(args []string) (compose.Result, error) { if !contains(args, "ps", "--all") { t.Fatalf("mutation without confirmation: %#v", args) } return compose.Result{Stdout: `[{"ID":"core-id","Name":"project-core-1","Service":"core","State":"exited"}]`}, nil }} result, err := Remove(context.Background(), installation, runner, nil) if !errors.Is(err, ErrConfirmationRequired) { t.Fatalf("Remove() error = %v, want confirmation", err) } if len(result.Targets) != 1 || result.Targets[0].ID != "core-id" { t.Fatalf("targets = %#v", result.Targets) } if len(runner.all) != 1 { t.Fatalf("Docker calls = %#v", runner.all) } } func TestRemoveRejectsRunningOrReplacedContainers(t *testing.T) { for name, spec := range map[string]struct{ first, second string }{ "running": {`[{"ID":"core-id","Name":"core","Service":"core","State":"running"}]`, `[]`}, "replaced": {`[{"ID":"core-id","Name":"core","Service":"core","State":"exited"}]`, `[{"ID":"new-id","Name":"core","Service":"core","State":"exited"}]`}, } { t.Run(name, func(t *testing.T) { installation, _ := removalInstallation(t) psCalls := 0 runner := &fakeRunner{run: func(args []string) (compose.Result, error) { if contains(args, "ps", "--all") { psCalls++ if psCalls == 1 { return compose.Result{Stdout: spec.first}, nil } return compose.Result{Stdout: spec.second}, nil } if reflect.DeepEqual(args, []string{"rm", "core-id"}) { return compose.Result{}, nil } t.Fatalf("unexpected Docker invocation: %#v", args) return compose.Result{}, nil }} _, err := Remove(context.Background(), installation, runner, []string{"core-id"}) if err == nil { t.Fatal("Remove() error = nil") } if name == "running" && len(runner.all) != 1 { t.Fatalf("running container was mutated: %#v", runner.all) } }) } } func TestRemoveRejectsConfirmationForDifferentContainerIDs(t *testing.T) { installation, _ := removalInstallation(t) runner := &fakeRunner{run: func(args []string) (compose.Result, error) { if !contains(args, "ps", "--all") { t.Fatalf("mismatched confirmation caused mutation: %#v", args) } return compose.Result{Stdout: `[{"ID":"replacement-id","Name":"core","Service":"core","State":"exited"}]`}, nil }} result, err := Remove(context.Background(), installation, runner, []string{"previously-displayed-id"}) if !errors.Is(err, ErrUnsafeState) || len(result.Targets) != 1 { t.Fatalf("Remove() = %#v, %v", result, err) } if len(runner.all) != 1 { t.Fatalf("Docker calls = %#v", runner.all) } } func testInstallation(t *testing.T) config.Installation { t.Helper() root, err := filepath.EvalSymlinks(t.TempDir()) if err != nil { t.Fatal(err) } project := filepath.Join(root, "project") if err := os.Mkdir(project, 0o700); err != nil { t.Fatal(err) } return config.Installation{ Path: filepath.Join(root, "thothii-installation.yaml"), Profile: "server", ProjectDirectory: project, EnvFile: filepath.Join(root, "server.env"), } } func removalInstallation(t *testing.T) (config.Installation, []string) { t.Helper() installation := testInstallation(t) paths := make([]string, 0, 5) values := map[string]string{} for _, name := range []string{"data", "pi-state", "workspace-registry", "backups"} { path := filepath.Join(filepath.Dir(installation.Path), name) if err := os.Mkdir(path, 0o700); err != nil { t.Fatal(err) } paths = append(paths, path) values[name] = path } secret := filepath.Join(filepath.Dir(installation.Path), "secret") if err := os.WriteFile(secret, []byte("never-log-this"), 0o600); err != nil { t.Fatal(err) } paths = append(paths, secret) env := "THT_DATA_ROOT=" + values["data"] + "\n" + "THT_PI_STATE_ROOT=" + values["pi-state"] + "\n" + "THT_WORKSPACE_REGISTRY_ROOT=" + values["workspace-registry"] + "\n" + "THT_BACKUP_ROOT=" + values["backups"] + "\n" + "APP_TOKEN_FILE=" + secret + "\n" if err := os.WriteFile(installation.EnvFile, []byte(env), 0o600); err != nil { t.Fatal(err) } return installation, paths } func contains(values []string, sequence ...string) bool { for start := range values { if start+len(sequence) <= len(values) && reflect.DeepEqual(values[start:start+len(sequence)], sequence) { return true } } return false } func indexOf(values []string, value string) int { for index, candidate := range values { if candidate == value { return index } } return -1 } func lastComposeFile(args []string) string { last := "" for index := 0; index+1 < len(args); index++ { if args[index] == "-f" { last = args[index+1] } } return last }