2fe634141f
gates / gates (push) Successful in 45s
R-1142: the HDD_PATH root is appdata/<own folder>, with a compiled-in folder map (paperless-ngx -> paperless). R-981: the allow-list is empty (homepage's socket served no Felhom feature). R-1063: finalizeDecommissionWith asks the agent before it marks the drive or moves the default; the 403/409 come back typed and reach the household as a plain 409. R-1069: the agent's no-durable-id format refusal is ErrNoDurableID and a plain sentence. R-1143: build.sh carries :latest only for x.y.z. Each pinned by a red-proofed test. MinAgent 0.158.1.
179 lines
8.1 KiB
Go
179 lines
8.1 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
)
|
|
|
|
// TestFinalizeDecommission_SoftMarksAndCallsAgent: finalizeDecommissionWith soft-marks the registry
|
|
// (entry retained, MigratedTo set) and calls the agent's Decommission. Used by both the migrate-done
|
|
// hook (MigratedTo=target) and decommission-anyway (MigratedTo="").
|
|
func TestFinalizeDecommission_SoftMarksAndCallsAgent(t *testing.T) {
|
|
s := testServer(t)
|
|
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/old", Schedulable: true, IsDefault: true})
|
|
// The migrate target is a registered schedulable drive (realistic for migrate-then-decommission); M1
|
|
// promotes it to default since /mnt/old was the default.
|
|
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/new", Schedulable: true})
|
|
agent := &mockAgent{}
|
|
|
|
if err := s.finalizeDecommissionWith(context.Background(), agent, "/mnt/old", "/mnt/new"); err != nil {
|
|
t.Fatalf("finalize: %v", err)
|
|
}
|
|
// M1: the default was reassigned to the migrate target (never zero default).
|
|
if s.settings.GetDefaultStoragePath() != "/mnt/new" {
|
|
t.Errorf("default not reassigned to /mnt/new (M1), got %q", s.settings.GetDefaultStoragePath())
|
|
}
|
|
// soft-marked, entry retained
|
|
if !s.settings.IsDecommissioned("/mnt/old") {
|
|
t.Errorf("path not soft-marked decommissioned")
|
|
}
|
|
found := false
|
|
for _, sp := range s.settings.GetStoragePaths() {
|
|
if sp.Path == "/mnt/old" {
|
|
found = true
|
|
if sp.MigratedTo != "/mnt/new" {
|
|
t.Errorf("MigratedTo = %q, want /mnt/new", sp.MigratedTo)
|
|
}
|
|
if sp.Schedulable || sp.IsDefault {
|
|
t.Errorf("decommissioned entry should not be schedulable/default")
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("entry was REMOVED — soft marker must retain it (blocks A1 resurrection)")
|
|
}
|
|
// agent told to decommission
|
|
if len(agent.decommissionCalls) != 1 || agent.decommissionCalls[0] != "/mnt/old" {
|
|
t.Errorf("agent.Decommission calls = %v, want [/mnt/old]", agent.decommissionCalls)
|
|
}
|
|
}
|
|
|
|
// TestOnMigrationDone_UnflaggedIsNoop: a plain migrate-all completion (DecommissionOnDone=false) must
|
|
// NOT decommission the source. (The flagged path's effects are covered by the finalize test; it would
|
|
// require a live agent client here.) Companion: if the early-return guard were removed, this would try
|
|
// to reach agentClient() and the path would change — so the no-op assertion pins the guard.
|
|
func TestOnMigrationDone_UnflaggedIsNoop(t *testing.T) {
|
|
s := testServer(t)
|
|
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/old", Schedulable: true})
|
|
s.OnMigrationDone(&stacks.MigrationJob{Source: "/mnt/old", Target: "/mnt/new", DecommissionOnDone: false})
|
|
if s.settings.IsDecommissioned("/mnt/old") {
|
|
t.Errorf("unflagged migration must not decommission the source")
|
|
}
|
|
}
|
|
|
|
// TestReEnrollClearMarker_Change4: re-enrolling a decommissioned path clears the marker + restores
|
|
// Schedulable so its apps' missing-storage indicator clears. (Companion: a non-decommissioned path is
|
|
// untouched — returns false.)
|
|
func TestReEnrollClearMarker_Change4(t *testing.T) {
|
|
s := testServer(t)
|
|
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/usb", Schedulable: true})
|
|
_ = s.settings.SetDecommissioned("/mnt/usb", "")
|
|
if !s.settings.IsDecommissioned("/mnt/usb") {
|
|
t.Fatal("precondition: should be decommissioned")
|
|
}
|
|
|
|
cleared, err := s.reEnrollClearMarker("/mnt/usb")
|
|
if err != nil {
|
|
t.Fatalf("reEnrollClearMarker: %v", err)
|
|
}
|
|
if !cleared {
|
|
t.Errorf("expected cleared=true for a decommissioned path")
|
|
}
|
|
if s.settings.IsDecommissioned("/mnt/usb") {
|
|
t.Errorf("marker not cleared")
|
|
}
|
|
if !s.settings.IsStoragePathSchedulable("/mnt/usb") {
|
|
t.Errorf("Schedulable not restored")
|
|
}
|
|
// companion: a non-decommissioned path is a no-op
|
|
if c, _ := s.reEnrollClearMarker("/mnt/usb"); c {
|
|
t.Errorf("non-decommissioned path should return cleared=false")
|
|
}
|
|
}
|
|
|
|
// TestMissingStorageLabel: an app's HDD_PATH on a decommissioned/disconnected/absent registry path is
|
|
// "missing"; on a present+schedulable path it is not; an empty HDD_PATH (SSD) is never missing.
|
|
func TestMissingStorageLabel(t *testing.T) {
|
|
s := testServer(t)
|
|
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/live", Label: "Élő", Schedulable: true})
|
|
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/dead", Label: "Holt", Schedulable: true})
|
|
_ = s.settings.SetDecommissioned("/mnt/dead", "")
|
|
|
|
if _, missing := s.missingStorageLabel(""); missing {
|
|
t.Errorf("empty HDD_PATH (SSD) must not be missing")
|
|
}
|
|
if _, missing := s.missingStorageLabel("/mnt/live"); missing {
|
|
t.Errorf("present+schedulable path must not be missing")
|
|
}
|
|
if label, missing := s.missingStorageLabel("/mnt/dead"); !missing || label != "Holt" {
|
|
t.Errorf("decommissioned path: missing=%v label=%q, want true/Holt", missing, label)
|
|
}
|
|
if label, missing := s.missingStorageLabel("/mnt/gone"); !missing || label == "" {
|
|
t.Errorf("unregistered path: missing=%v label=%q, want true/non-empty", missing, label)
|
|
}
|
|
}
|
|
|
|
// R-1063: the agent is asked FIRST. When it refuses — 403 (a protected role) or 409 (the whole-guest backup target) —
|
|
// nothing changes on the dashboard: the drive is not marked decommissioned, the default does not move, and the
|
|
// household reads a plain reason in its language (a 409 to the browser, like the M1 refusal).
|
|
// RED-PROOF: with SetDecommissioned + the default move back BEFORE agent.Decommission (the v0.328.0 order), the
|
|
// drive reads decommissioned and the default moved, and this test fails.
|
|
func TestFinalizeDecommission_AgentRefusalChangesNothing(t *testing.T) {
|
|
for _, c := range []struct {
|
|
status int
|
|
want error
|
|
huFrag, enFrag string
|
|
}{
|
|
{http.StatusForbidden, errDecommissionProtected, "rendszer v", "system protects this drive"},
|
|
{http.StatusConflict, errDecommissionBackupTarget, "rendszermentés helyéül", "place for the system backup"},
|
|
} {
|
|
t.Run(strconv.Itoa(c.status), func(t *testing.T) {
|
|
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})
|
|
agent := &mockAgent{decommissionErr: &agentapi.DecommissionRefusedError{Status: c.status, Reason: "refused"}}
|
|
|
|
err := s.finalizeDecommissionWith(context.Background(), agent, "/mnt/old", "")
|
|
if !errors.Is(err, c.want) {
|
|
t.Fatalf("err = %v, want the plain refusal %v", err, c.want)
|
|
}
|
|
if len(agent.decommissionCalls) != 1 {
|
|
t.Fatalf("agent asked %d times, want 1", len(agent.decommissionCalls))
|
|
}
|
|
if s.settings.IsDecommissioned("/mnt/old") {
|
|
t.Error("the drive reads decommissioned although the agent refused")
|
|
}
|
|
if got := s.settings.GetDefaultStoragePath(); got != "/mnt/old" {
|
|
t.Errorf("default moved to %q although the agent refused", got)
|
|
}
|
|
if !isDecommissionPolicyRefusal(err) {
|
|
t.Error("the refusal is not mapped to 409 (the CF edge would swallow a 502's body)")
|
|
}
|
|
if hu := util.ErrText("hu", err); !strings.Contains(hu, c.huFrag) || !strings.Contains(hu, "Semmi nem v") {
|
|
t.Errorf("HU text = %q", hu)
|
|
}
|
|
if en := util.ErrText("en", err); !strings.Contains(en, c.enFrag) || !strings.Contains(en, "Nothing was changed") {
|
|
t.Errorf("EN text = %q", en)
|
|
}
|
|
})
|
|
}
|
|
// Any other agent failure is still the old „failed" message, and changes nothing either.
|
|
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})
|
|
err := s.finalizeDecommissionWith(context.Background(), &mockAgent{decommissionErr: errors.New("agentapi: dial: refused")}, "/mnt/old", "")
|
|
if err == nil || isDecommissionPolicyRefusal(err) || s.settings.IsDecommissioned("/mnt/old") {
|
|
t.Fatalf("a transport failure: err=%v policy=%v marked=%v", err, isDecommissionPolicyRefusal(err), s.settings.IsDecommissioned("/mnt/old"))
|
|
}
|
|
}
|