Files
felhom-controller/controller/internal/web/tier2_honest_message_test.go
T
admin 48f3336956
gates / gates (push) Successful in 23s
v0.254.0 — the saved notes follow the language, and the switch becomes a globe (R-557 slice 2 release C; SLICE 2 CLOSED)
The notes a background run SAVES — last night's backup line, the last error, the proof
result, the restore outcome — are written in the BOX's language at the moment they are
written. A household that switches sees the previous run's note in the old language until
the next run rewrites it: the operator's §16 option 1, stated rather than hidden.
EndRestoreOp no longer receives a Hungarian literal from anywhere.

The language switch is a globe. Two text links wrapped in the sidebar footer and asked the
reader to recognise "Magyar"/"English" as links; a globe is the one symbol every web user
already reads as "language", so nobody has to read Hungarian to escape Hungarian. It is
<details>/<summary> — a menu with no script, drawn inline because the icon sprite lives
only in layout.html and the visitor pages have their own shell.

Those visitor pages get the same globe, and a visitor's choice stays theirs: a display-only
felhom_lang cookie that langFor reads ONLY when there is no session. A signed-in household
can never inherit a language a previous visitor picked in the same browser. POST /lang is
CSRF-exempt for a narrow reason written at the exemption — its only achievable effect is the
language of the page the victim's own browser shows them — and safeBackPath refuses
//evil.example as well as https://, because "starts with /" alone is not the test. §16 taken:
a successful claim carries the cookie into the household's setting.

TWO PARITY EXCEPTIONS, MEASURED: 106 fixtures compared with a real diff — exactly two change
shapes (the dashboard footer, the globe in the shells) and 5 byte-identical, which are the
three pages that must not change.

I INTRODUCED A DEADLOCK AND THE SUITE CAUGHT IT BY HANGING. UpdateOffboxStatus holds the
settings write lock while running its callback; boxLang() wants the read lock; sync.RWMutex
is not reentrant. On a real box an off-site run would have hung forever HOLDING the settings
lock. Fixed by resolving the language before the callback, and guarded by a test that names
the file and line in a second instead of hanging for 25 minutes.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 14:19:31 +02:00

178 lines
7.6 KiB
Go

package web
import (
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// C9-F1 at the customer surface. Two messages had to change, and they fail differently:
//
// - the LOUD lie: for the 43 apps whose data the restore cannot read, the customer got an outage
// and „Nincs hiányzó fájl — minden fájl megvan a helyén." — pressed precisely BECAUSE files were
// missing;
// - the QUIET one: for the 9 apps it does cover, it covers only the file legs, never the database
// or named volumes, so the same blanket sentence was a clean bill of health over data the
// operation never opened (immich's 1.3 GB Postgres unit is the dangerous case).
// honestProvider is a minimal provider: the restore must never reach StopStack in the no-coverage
// test, and `stops` is the assertion that proves it.
type honestProvider struct {
hdd string
stops int32
}
func (p *honestProvider) GetStackComposePath(string) (string, bool) { return "", false }
func (p *honestProvider) ListDeployedStacks() []backup.StackSummary { return nil }
func (p *honestProvider) GetStackHDDMounts(string) []string { return nil }
func (p *honestProvider) GetStackHDDPath(string) string { return p.hdd }
func (p *honestProvider) GetImportRoot() string { return "" }
func (p *honestProvider) GetDockerVolumes(string) []string { return nil }
func (p *honestProvider) StopStack(string) error { atomic.AddInt32(&p.stops, 1); return nil }
func (p *honestProvider) StartStack(string) error { return nil }
func (p *honestProvider) RefreshAndIsRunning(string) bool { return true }
func (p *honestProvider) GetStackRecoveryInfo(string) (backup.RecoveryInfo, bool) {
return backup.RecoveryInfo{}, false
}
func (p *honestProvider) GetStackClassifiedBinds(string) ([]backup.ClassifiedBind, bool) {
return nil, false
}
func (p *honestProvider) RecoverStackSecrets(string, []string) map[string]string { return nil }
func (p *honestProvider) RecreateStackDefinitionFromUnit(string, string, map[string]string) error {
return nil
}
func (p *honestProvider) StartStackServices(string, []string) error { return nil }
// newHonestServer builds a server whose recorded Tier-2 copy has `legs` (each created as a dir) and,
// optionally, a recovery unit — so one harness expresses both the class-A and class-B shapes.
func newHonestServer(t *testing.T, legs []string, withUnit bool) (*Server, *honestProvider) {
t.Helper()
tmp := t.TempDir()
live := filepath.Join(tmp, "usb")
dest := filepath.Join(tmp, "flash")
lg := log.New(io.Discard, "", 0)
sett, err := settings.Load(filepath.Join(tmp, "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
for _, p := range []string{live, dest} {
if err := sett.AddStoragePath(settings.StoragePath{Path: p, Label: filepath.Base(p)}); err != nil {
t.Fatal(err)
}
}
if err := sett.SetCrossDriveConfig("app", &settings.CrossDriveBackup{
Enabled: true, Method: "rsync", DestinationPath: dest,
LastRun: "2026-07-28T03:30:00Z", LastStatus: "ok",
}); err != nil {
t.Fatal(err)
}
destBase := filepath.Join(dest, "backups", "secondary", "app")
for _, leg := range legs {
if err := os.MkdirAll(filepath.Join(destBase, leg, "appdata"), 0o755); err != nil {
t.Fatal(err)
}
}
if withUnit {
if err := os.MkdirAll(filepath.Join(destBase, "recovery-unit", "volume-dumps"), 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(destBase, ".felhom-tier2-layout"), []byte("2"), 0o644); err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.DataDir = tmp
m := backup.NewManager(cfg, sett, lg)
prov := &honestProvider{hdd: live}
m.SetStackProvider(prov)
return &Server{cfg: cfg, backupMgr: m, logger: lg}, prov
}
// SCENARIO D at the surface — the customer is told plainly, up front, and the app is NOT stopped.
// The message must NAME the action that works; a dead end for 81% of the catalog is not honesty.
//
// RED-PROOF (observed): remove the pre-flight `!cov.CanRestore()` block from the handler →
//
// tier2_honest_message_test.go:118: no flash_error — the customer was told the restore STARTED
// tier2_honest_message_test.go:129: THE APP WAS STOPPED (stops=1) for a restore that can never restore anything
func TestTier2RestoreHandler_NoCoverage_RefusesUpFrontAndNamesTheAction(t *testing.T) {
s, prov := newHonestServer(t, nil, true) // bookstack shape: unit only, no legs
req := httptest.NewRequest(http.MethodPost, "/backup/tier2/restore", strings.NewReader("stack_name=app"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.backupTier2RestoreHandler(w, req)
loc := w.Header().Get("Location")
if !strings.Contains(loc, "flash_error=") {
t.Fatalf("no flash_error — the customer was told the restore STARTED: %s", loc)
}
// The refusal must not have started an operation at all.
if st := s.backupMgr.RestoreStatus(); st.Running {
t.Error("an async op was begun for a restore that cannot do anything")
}
if got := atomic.LoadInt32(&prov.stops); got != 0 {
t.Errorf("THE APP WAS STOPPED (stops=%d) for a restore that can never restore anything", got)
}
// The message names the working action rather than dead-ending.
for _, want := range []string{"nem ebből a másolatból", "nem állt le", "Visszaállítás indítása"} {
if !strings.Contains(noteHU(t, tier2NoCoverageKey), want) {
t.Errorf("the refusal message is missing %q:\n%s", want, noteHU(t, tier2NoCoverageKey))
}
}
}
// SCENARIO F — „nothing missing" must claim only what was EXAMINED, and must disclose what this
// restore does not cover at all.
//
// RED-PROOF (observed): restore the old blanket string
// (`msg := "Nincs hiányzó fájl — minden fájl megvan a helyén."` with no disclosure) →
//
// tier2_honest_message_test.go:154: the success message still claims ALL files: "Nincs hiányzó fájl — minden fájl megvan a helyén."
// tier2_honest_message_test.go:161: the message does not disclose that the database and volumes were not covered
func TestTier2RestoreHandler_CoveredApp_ClaimsOnlyWhatWasExamined(t *testing.T) {
s, _ := newHonestServer(t, []string{"hdd"}, true) // paperless shape: a leg AND a unit
req := httptest.NewRequest(http.MethodPost, "/backup/tier2/restore", strings.NewReader("stack_name=app"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.backupTier2RestoreHandler(w, req)
if !strings.Contains(w.Header().Get("Location"), "flash=") {
t.Fatalf("a COVERED app was refused — this is the regression guard on Campaign 9's A1 result: %s", w.Header().Get("Location"))
}
var last backup.RestoreOpResult
waitFor(t, func() bool {
st := s.backupMgr.RestoreStatus()
if st.Running || st.Last == nil {
return false
}
last = *st.Last
return true
}, "the restore to finish")
if !last.OK {
t.Fatalf("a covered app's restore failed: %s", last.Message)
}
if strings.Contains(last.Message, "minden fájl megvan a helyén") {
t.Errorf("the success message still claims ALL files: %q", last.Message)
}
if !strings.Contains(last.Message, "vizsgált") {
t.Errorf("the message does not limit its claim to what was EXAMINED: %q", last.Message)
}
if !strings.Contains(last.Message, noteHU(t, tier2UnitNotCoveredKey)) {
t.Errorf("the message does not disclose that the database and volumes were not covered: %q", last.Message)
}
}