package web import ( "context" "net/http" "reflect" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/util" ) // fakeDecomStacks is the stack-manager seam of „Végleges leszerelés — mégis": it records stops and starts and never // reaches docker. type fakeDecomStacks struct { list []stacks.Stack hdd map[string]string stops, starts []string } func (f *fakeDecomStacks) GetStacks() []stacks.Stack { return f.list } func (f *fakeDecomStacks) LoadAppConfigByName(n string) *stacks.AppConfig { return &stacks.AppConfig{Deployed: true, Env: map[string]string{"HDD_PATH": f.hdd[n]}} } func (f *fakeDecomStacks) StopStack(n string) error { f.stops = append(f.stops, n); return nil } func (f *fakeDecomStacks) StartStack(n string) error { f.starts = append(f.starts, n); return nil } func r1144Setup(t *testing.T) (*Server, *fakeDecomStacks) { t.Helper() s := testServer(t) _ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/old", Schedulable: true, IsDefault: true}) _ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/new", Schedulable: true}) st := func(n string, state stacks.ContainerState) stacks.Stack { return stacks.Stack{Name: n, Deployed: true, State: state, Meta: stacks.Metadata{DisplayName: strings.ToUpper(n)}} } f := &fakeDecomStacks{ list: []stacks.Stack{st("immich", stacks.StateRunning), st("jellyfin", stacks.StateUnhealthy), st("komga", stacks.StateStopped), st("other", stacks.StateRunning)}, hdd: map[string]string{"immich": "/mnt/old", "jellyfin": "/mnt/old", "komga": "/mnt/old", "other": "/mnt/new"}, } return s, f } // R-1144: the agent refuses AFTER the apps were stopped (403 here; its disk list gave no verdict) → exactly the apps // that were running before the press run again (immich, jellyfin — not komga, which was stopped, and not other, on // another drive), the drive is not marked, and the household reads both sentences. // RED-PROOF: without the restart leg in decommissionAnyway, starts = [] and this test fails. func TestR1144_RefusalAfterStopStartsTheRunningAppsAgain(t *testing.T) { for _, code := range []int{http.StatusForbidden, http.StatusConflict} { s, f := r1144Setup(t) agent := &mockAgent{decommissionErr: &agentapi.DecommissionRefusedError{Status: code, Reason: "no"}} stopped, all, err := s.decommissionAnyway(context.Background(), agent, f, "/mnt/old") if err == nil || !driveUntouched(err) { t.Fatalf("HTTP %d: err = %v, want a policy refusal", code, err) } if !reflect.DeepEqual(f.stops, []string{"immich", "jellyfin", "komga"}) { t.Fatalf("stops = %v", f.stops) } if !reflect.DeepEqual(f.starts, []string{"immich", "jellyfin"}) || !all { t.Fatalf("HTTP %d: starts = %v (all=%v), want exactly the apps that were running", code, f.starts, all) } if len(stopped) != 3 || s.settings.IsDecommissioned("/mnt/old") { t.Fatalf("stopped=%v marked=%v", stopped, s.settings.IsDecommissioned("/mnt/old")) } for lang, frag := range map[string]string{"hu": "újra futnak", "en": "running again"} { if !strings.Contains(util.Text(lang, "disk.decommission.apps_restarted"), frag) { t.Errorf("[%s] the restart sentence lacks %q", lang, frag) } } } } // When the agent's own disk list already says no (a protected role, or the whole-guest backup target), NOTHING is // stopped: the refusal comes before the first StopStack. func TestR1144_AgentVerdictBeforeAnythingIsStopped(t *testing.T) { for name, d := range map[string]agentapi.DiskInfo{ "protected role": {MountPath: "/mnt/old", Role: "backup"}, "backup target": {MountPath: "/mnt/old", Role: "user-data", BackupTarget: true}, } { s, f := r1144Setup(t) agent := &mockAgent{disks: agentapi.DisksResponse{Disks: []agentapi.DiskInfo{d}}} _, _, err := s.decommissionAnyway(context.Background(), agent, f, "/mnt/old") if !isDecommissionPolicyRefusal(err) || len(f.stops) != 0 || len(agent.decommissionCalls) != 0 { t.Fatalf("%s: err=%v stops=%v agent calls=%v — want a refusal before anything is stopped", name, err, f.stops, agent.decommissionCalls) } } } // The agent says yes (200) → retired as before: marked, apps stay stopped, nothing restarted. func TestR1144_AcceptedRetirementKeepsTheAppsStopped(t *testing.T) { s, f := r1144Setup(t) agent := &mockAgent{disks: agentapi.DisksResponse{Disks: []agentapi.DiskInfo{{MountPath: "/mnt/old", Role: "user-data"}}}} if _, all, err := s.decommissionAnyway(context.Background(), agent, f, "/mnt/old"); err != nil || !all { t.Fatalf("err=%v all=%v", err, all) } if len(f.starts) != 0 || !s.settings.IsDecommissioned("/mnt/old") { t.Fatalf("starts=%v marked=%v — a retired drive's apps must stay stopped", f.starts, s.settings.IsDecommissioned("/mnt/old")) } }