48f3336956
gates / gates (push) Successful in 23s
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
219 lines
9.9 KiB
Go
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)
|
|
}
|
|
}
|