Files
felhom-controller/controller/internal/web/r353_unit_outcome_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

219 lines
9.9 KiB
Go

package web
import (
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"io"
"log"
"os"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// ── R-353 — a local restore that gave back nothing still said it worked ──────────────────────────
//
// Observed on demo-hp 2026-08-21: an `opengist` restore reported „opengist visszaállítva (<snapshot>)."
// over a recovery unit holding manifest.json and compose/ and nothing else. The customer reads that as
// "my data is back". It was not, and no screen in the product could have said so — the count of what
// came back was discarded one line below the function that produced it.
//
// The three cases below are three DIFFERENT facts and collapsing any two is the whole defect. The
// wording of the middle one is constrained by R-355 and by 07-backup-architecture §6.3: it is a claim
// about THE BACKUP, never about the app, because an absent dump has causes that say nothing about
// whether the app has data (R-361 destroyed apps' canonical .sql files for four months).
func TestUnitRestoreOutcome_VolumesAndDatabaseNamed(t *testing.T) {
msg := noteServer(t).unitRestoreOutcomeMsg("kimai", backup.UnitRestoreResult{
VolumesReplayed: 2, DBsReplayed: 1, ManifestVolumes: 2, ManifestDBs: 1,
})
for _, want := range []string{"2 adatkötet", "az adatbázis"} {
if !strings.Contains(msg, want) {
t.Errorf("a restore that returned data must NAME it; missing %q in %q", want, msg)
}
}
if strings.Contains(msg, "FIGYELEM") {
t.Errorf("a fully successful restore must not carry a warning; got %q", msg)
}
if strings.Contains(msg, "visszaállítva (") {
t.Errorf("the snapshot-id sentence is the pre-fix shape and says nothing about what came back; got %q", msg)
}
}
func TestUnitRestoreOutcome_BackupHeldOnlySettings(t *testing.T) {
msg := noteServer(t).unitRestoreOutcomeMsg("opengist", backup.UnitRestoreResult{})
for _, want := range []string{"csak a beállításokat tartalmazta", "NEM álltak vissza"} {
if !strings.Contains(msg, want) {
t.Errorf("a restore that returned no data must say so plainly; missing %q in %q", want, msg)
}
}
// R-355: the forbidden inference. The manifest cannot support a claim about the APP, and on the
// off-site path the equivalent sentence was printed over a live 72-table PostgreSQL.
for _, forbidden := range []string{"nincs adata", "nincs adatbázisa", "alkalmazásnak nincs"} {
if strings.Contains(msg, forbidden) {
t.Fatalf("FALSE CLAIM about the app inferred from a counter (%q) in %q", forbidden, msg)
}
}
}
func TestUnitRestoreOutcome_ManifestListedDataThatDidNotReturn(t *testing.T) {
msg := noteServer(t).unitRestoreOutcomeMsg("paperless-ngx", backup.UnitRestoreResult{
VolumesReplayed: 0, DBsReplayed: 0, ManifestVolumes: 2, ManifestDBs: 1,
})
for _, want := range []string{"2 adatkötetet", "1 adatbázis-mentést", "változatlanok maradtak"} {
if !strings.Contains(msg, want) {
t.Errorf("the unit listed data that did not come back — the message must say so; missing %q in %q", want, msg)
}
}
// The Scenario B sentence would say the backup held only settings, which the manifest contradicts.
if strings.Contains(msg, "csak a beállításokat tartalmazta") {
t.Fatalf("wrong case: said the backup held only settings while its manifest lists 3 dumps; got %q", msg)
}
}
func TestUnitRestoreOutcome_DatabaseOnly(t *testing.T) {
msg := noteServer(t).unitRestoreOutcomeMsg("bookstack", backup.UnitRestoreResult{
VolumesReplayed: 0, DBsReplayed: 1, ManifestVolumes: 0, ManifestDBs: 1,
})
if !strings.Contains(msg, "az adatbázis visszaállítva") {
t.Errorf("a database-only restore must read naturally; got %q", msg)
}
if strings.Contains(msg, "adatkötet") {
t.Fatalf("named a volume count for a restore that replayed none; got %q", msg)
}
if strings.Contains(msg, "FIGYELEM") {
t.Errorf("data came back — this is not a warning case; got %q", msg)
}
}
// --- A5: THE SEAM TEST (§10) --------------------------------------------------------------------
//
// The one that matters. It drives the REAL backupRestoreHandler and reads the sentence off the
// op-status surface the customer's banner polls — not unitRestoreOutcomeMsg directly. Three shipped
// defects in this project came from testing a component whose caller never invoked it, and R-353 is
// itself an instance: restoreDockerVolumesFrom returned the count correctly the whole time.
type r353Provider struct {
hdd string
starts int32
}
func (p *r353Provider) GetStackComposePath(string) (string, bool) { return "", false }
func (p *r353Provider) ListDeployedStacks() []backup.StackSummary { return nil }
func (p *r353Provider) GetStackHDDMounts(string) []string { return nil }
func (p *r353Provider) GetStackHDDPath(string) string { return p.hdd }
func (p *r353Provider) GetImportRoot() string { return "" }
func (p *r353Provider) GetDockerVolumes(string) []string { return nil }
func (p *r353Provider) StopStack(string) error { return nil }
func (p *r353Provider) StartStack(string) error { atomic.AddInt32(&p.starts, 1); return nil }
func (p *r353Provider) RefreshAndIsRunning(string) bool { return true }
func (p *r353Provider) GetStackRecoveryInfo(string) (backup.RecoveryInfo, bool) {
return backup.RecoveryInfo{}, false
}
func (p *r353Provider) RecoverStackSecrets(string, []string) map[string]string { return nil }
func (p *r353Provider) RecreateStackDefinitionFromUnit(string, string, map[string]string) error {
return nil
}
func (p *r353Provider) StartStackServices(string, []string) error { return nil }
func (p *r353Provider) GetStackClassifiedBinds(string) ([]backup.ClassifiedBind, bool) {
return nil, false
}
func TestR353_HandlerPublishesTheOutcome(t *testing.T) {
tmp := t.TempDir()
lg := log.New(io.Discard, "", 0)
live := filepath.Join(tmp, "live")
if err := os.MkdirAll(live, 0o755); err != nil {
t.Fatal(err)
}
sett, err := settings.Load(filepath.Join(tmp, "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
if err := sett.AddStoragePath(settings.StoragePath{Path: live, Label: "live"}); err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.DataDir = tmp
m := backup.NewManager(cfg, sett, lg)
prov := &r353Provider{hdd: live}
m.SetStackProvider(prov)
s := &Server{cfg: cfg, backupMgr: m, logger: lg}
req := httptest.NewRequest(http.MethodPost, "/backup/restore",
strings.NewReader("stack_name=opengist&snapshot_id=snap-123"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.backupRestoreHandler(w, req)
if w.Code != http.StatusFound {
t.Fatalf("want 302, got %d", w.Code)
}
// The restore runs in a background goroutine; poll the surface the banner polls.
var last string
for i := 0; i < 900; i++ {
st := m.RestoreStatus()
if !st.Running && st.Last.Message != "" {
last = st.Last.Message
break
}
time.Sleep(10 * time.Millisecond)
}
if last == "" {
t.Fatal("the restore never reached a terminal status")
}
// THE ASSERTION THAT CARRIES THE SEAM: whatever branch fires, the sentence the customer is shown
// must be `unitRestoreOutcomeMsg`'s output and not the pre-fix string.
if strings.Contains(last, "visszaállítva (snap-123)") {
t.Fatalf("THE PRE-FIX SENTENCE REACHED THE CUSTOMER: %q — it is true of a restore that "+
"returned an entire dataset and of one that returned nothing", last)
}
// This fixture has no recovery unit, so the production path takes the RestoreApp fallback — where
// the counts are genuinely unknown. The honest sentence for that is the unknown one, and asserting
// it here is what pins the fallback to it: the zero-value shape would otherwise print
// „csak a beállításokat tartalmazta" over a restore that may have returned everything.
if strings.Contains(last, "csak a beállításokat tartalmazta") {
t.Fatalf("the no-unit fallback claimed the backup held no data, from counts it never "+
"established: %q", last)
}
if !strings.Contains(last, "nem tudjuk megmondani") {
t.Fatalf("the published outcome does not state the unknown as unknown; got %q", last)
}
}
// TestUnitRestoreOutcome_NoUnitFallbackSaysUnknownNotEmpty — the defect the first draft of this fix
// introduced, caught by the observations gate forcing a re-read of my own code.
//
// `RestoreFromRecoveryUnit` falls back to `RestoreApp` when there is no recovery unit, and `RestoreApp`
// returns only an error — its signature is deliberately out of scope. So the result is a ZERO value,
// and a zero `UnitRestoreResult` is Scenario B's shape: „ez a mentés csak a beállításokat tartalmazta,
// adatot nem". That sentence would be printed over a fallback restore that had just replayed the app's
// entire dataset. **An unknown drawn as a zero is the R-88 failure direction**, and it is exactly what
// this whole change exists to remove — so it must not be re-introduced by the fix itself.
func TestUnitRestoreOutcome_NoUnitFallbackSaysUnknownNotEmpty(t *testing.T) {
msg := noteServer(t).unitRestoreOutcomeMsg("legacyapp", backup.UnitRestoreResult{CountsUnknown: true})
if strings.Contains(msg, "csak a beállításokat tartalmazta") {
t.Fatal("FALSE CLAIM: told the customer the backup held no data when the counts were never " +
"established — a fallback restore may have returned everything they own")
}
if strings.Contains(msg, "adatkötet") {
t.Fatal("claimed a volume count that was never measured")
}
if !strings.Contains(msg, "nem tudjuk megmondani") {
t.Errorf("an unknown must be STATED as unknown, not left silent; got %q", msg)
}
// And it must still tell them the restore ran, or the sentence reads as a failure.
if !strings.Contains(msg, "lefutott") {
t.Errorf("the message must say the restore completed; got %q", msg)
}
}