Files
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

226 lines
9.2 KiB
Go

package web
import (
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"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"
)
// ── R-358 / R-360 at the HANDLERS ────────────────────────────────────────────────────────────────
//
// Both defects are server-side. R-358's part-copy was hidden by a template flag, and a hidden button
// is not a guard — these POST directly, which is what a curious customer, a stale tab or a double
// submit does anyway. R-360's delete refused only while a BACKUP ran, so it went through during a
// restore; that one asserts the CONSEQUENCE (the directory still exists), never the branch.
func newR358Server(t *testing.T) (*Server, *backup.Manager, string) {
t.Helper()
tmp := t.TempDir()
lg := log.New(io.Discard, "", 0)
drive := filepath.Join(tmp, "drive")
if err := os.MkdirAll(drive, 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: drive, Label: "drive", Schedulable: true}); err != nil {
t.Fatal(err)
}
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "nas.local", Port: 22, User: "u", RepoPath: "/srv/repo",
Schedule: "daily", EscrowState: "escrowed",
}); err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.DataDir = tmp
m := backup.NewManager(cfg, sett, lg)
if err := m.WriteOffboxSecrets("KEY", "nas.local ssh-ed25519 AAAA"); err != nil {
t.Fatal(err)
}
m.SetStackProvider(&r353Provider{hdd: drive})
s := &Server{cfg: cfg, backupMgr: m, settings: sett, logger: lg}
return s, m, drive
}
// plantIncompleteScratch writes the exact shape a failed restic download leaves: files, no marker.
func plantIncompleteScratch(t *testing.T, m *backup.Manager, app string) string {
t.Helper()
scratch := m.OffsiteRestoreScratchPath(app)
if scratch == "" {
t.Fatal("could not resolve the scratch path")
}
if err := os.MkdirAll(filepath.Join(scratch, "backups", "primary", app), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(scratch, "backups", "primary", app, "half.tar"), []byte("partial"), 0o644); err != nil {
t.Fatal(err)
}
return scratch
}
func TestR358_PlaceHandlerRefusesIncompleteScratch(t *testing.T) {
s, m, _ := newR358Server(t)
plantIncompleteScratch(t, m, "kimai")
req := httptest.NewRequest(http.MethodPost, "/backup/offbox/place", strings.NewReader("app=kimai"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.offboxPlaceHandler(w, req)
loc := w.Header().Get("Location")
// The flash carries a KEY since v0.252.0; resolved here the way the page resolves it, so the
// assertion is still about the SENTENCE the customer reads.
if !strings.Contains(flashSentence(t, loc), "nem teljes") {
t.Fatalf("a direct POST over a part-copy was NOT refused server-side; redirect was %q -> %q", loc, flashSentence(t, loc))
}
if m.RestoreStatus().Running {
t.Fatal("the place operation actually STARTED over an incomplete scratch")
}
}
func TestR358_ReconstituteHandlerRefusesIncompleteScratch(t *testing.T) {
// The destructive one. On 2026-08-21 „Teljes visszaállítás indítása" was offered over exactly this
// state and reported ok=true.
s, m, _ := newR358Server(t)
plantIncompleteScratch(t, m, "kimai")
req := httptest.NewRequest(http.MethodPost, "/backup/offbox/reconstitute",
strings.NewReader("app=kimai&confirm=1"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.offboxReconstituteHandler(w, req)
loc := w.Header().Get("Location")
if !strings.Contains(flashSentence(t, loc), "nem teljes") {
t.Fatalf("the DESTRUCTIVE restore was not refused over a part-copy; redirect was %q -> %q", loc, flashSentence(t, loc))
}
if m.RestoreStatus().Running {
t.Fatal("the destructive restore actually STARTED over an incomplete scratch")
}
}
// TestR360_VerifyCopyDeleteRefusedDuringRestore — Scenario G, asserting the CONSEQUENCE.
//
// The state is the one observed live on 2026-08-21 22:35 and it is the whole reason the bug existed:
// RestoreStatus().Running is TRUE while IsRunning() is FALSE. The old guard read only the second.
func TestR360_VerifyCopyDeleteRefusedDuringRestore(t *testing.T) {
s, m, drive := newR358Server(t)
// A real verification copy on disk, at the path DeleteOffsiteRestoreCopy resolves.
copyDir := filepath.Join(drive, "backups", "offsite-restore", "kimai")
if err := os.MkdirAll(copyDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(copyDir, "payload.txt"), []byte("the copy a restore is writing into"), 0o644); err != nil {
t.Fatal(err)
}
// The live state: a restore op in flight, no BACKUP running.
m.BeginRestoreOp("offbox-restore", "kimai")
if !m.RestoreStatus().Running {
t.Fatal("fixture wrong: no restore op is in flight")
}
if m.IsRunning() {
t.Fatal("fixture wrong: IsRunning() must be FALSE — that divergence IS the defect")
}
req := httptest.NewRequest(http.MethodPost, "/backup/offbox/verify-copy/delete",
strings.NewReader("stack=kimai&confirm=1"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.offboxVerifyCopyDeleteHandler(w, req)
// THE ASSERTION THAT MATTERS: the copy the restore is writing into is still there.
if _, err := os.Stat(copyDir); os.IsNotExist(err) {
t.Fatal("THE VERIFICATION COPY WAS DELETED while a restore was writing into it — this is " +
"the 2026-08-21 behaviour, and the customer can do it from the UI")
}
if _, err := os.Stat(filepath.Join(copyDir, "payload.txt")); err != nil {
t.Fatalf("the copy's contents did not survive the delete attempt: %v", err)
}
if loc := w.Header().Get("Location"); !strings.Contains(loc, "flash_error") {
t.Errorf("the refusal must reach the customer as an error flash; redirect was %q", loc)
}
}
func TestR360_VerifyCopyDeleteStillWorksWhenIdle(t *testing.T) {
// Scenario H for this handler: with nothing in flight the delete must still work. A guard that
// refuses always is not a fix, it is a removed feature.
s, _, drive := newR358Server(t)
copyDir := filepath.Join(drive, "backups", "offsite-restore", "kimai")
if err := os.MkdirAll(copyDir, 0o755); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/backup/offbox/verify-copy/delete",
strings.NewReader("stack=kimai&confirm=1"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.offboxVerifyCopyDeleteHandler(w, req)
if _, err := os.Stat(copyDir); !os.IsNotExist(err) {
t.Fatalf("an idle delete no longer removes the copy (redirect %q)", w.Header().Get("Location"))
}
}
// TestR358_UnitOnlyScratchClosesTheFullRestoreCard — Scenario F at the FLOW level.
//
// THE ANSWER TO THE SPEC'S OPEN QUESTION, and it is worse than the question assumed. The task asked
// whether the real UI flow can reach a state where a unit-only scratch makes the full-restore action
// appear. It can, and by the MOST ORDINARY route available:
//
// - „Ellenőrző visszaállítás" (`mode=unit`, the default, advertised as non-destructive) calls
// RestoreOffboxScratch(ctx, app, full=false);
// - both modes write the SAME directory — offboxRestoreScratchDir ignores `full`, and `--include`
// limits WHAT restic extracts, never WHERE;
// - the wizard sets ScratchReady from OffboxFullScratchReady, which pre-fix answered
// "directory exists and is non-empty";
// - deriveWizardStep then sets PlaceEnabled AND RestoreEnabled from that one flag.
//
// So a customer who ran the SAFE verification restore was then offered „Teljes visszaállítás
// indítása" over a unit-only copy. Filed as a register row; the fix closes it because the marker
// records full=false.
func TestR358_UnitOnlyScratchClosesTheFullRestoreCard(t *testing.T) {
s, m, _ := newR358Server(t)
scratch := m.OffsiteRestoreScratchPath("kimai")
if err := os.MkdirAll(scratch, 0o755); err != nil {
t.Fatal(err)
}
// What a completed `mode=unit` verification restore leaves behind.
if err := os.MkdirAll(filepath.Join(scratch, "mnt", "old", "backups", "primary", "kimai"), 0o755); err != nil {
t.Fatal(err)
}
if err := m.WriteScratchMarkerForTest(scratch, "snap-1", false); err != nil {
t.Fatal(err)
}
ready := s.backupMgr.OffboxFullScratchReady("kimai")
if ready {
t.Fatal("a unit-only verification restore still unlocks the full-restore card — the customer " +
"is offered a destructive restore over a copy that holds only the recovery unit")
}
view := deriveWizardStep(restoreWizardInput{App: "kimai", ScratchReady: ready})
if view.RestoreEnabled || view.PlaceEnabled {
t.Fatalf("the wizard still offers place/restore over a unit-only scratch: %+v", view)
}
if !view.PrepareEnabled {
t.Fatal("the customer is left with no way forward — PrepareEnabled must be true so they can " +
"run the real full download")
}
if !view.VerifyEnabled {
t.Fatal("the verification restore must stay available")
}
}