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
75 lines
2.9 KiB
Go
75 lines
2.9 KiB
Go
package web
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
)
|
|
|
|
// Slice 4 — the predicate moved from this package into backup.Tier2UnitRestorePoint. The page must
|
|
// render IDENTICALLY: the row's four unit-restore fields are compared against the coverage computed
|
|
// the old way (the inline expression that used to live in buildAppBackupRows).
|
|
func TestSlice4_BackupRowUnitFieldsAreUnchangedByTheExtraction(t *testing.T) {
|
|
s, sett, m := newOffboxWebServer(t)
|
|
dest := t.TempDir()
|
|
if err := sett.AddStoragePath(settings.StoragePath{Path: dest, Label: "flash"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := sett.SetCrossDriveConfig("app", &settings.CrossDriveBackup{
|
|
Enabled: true, Method: "rsync", DestinationPath: dest,
|
|
LastRun: "2026-09-13T01:30:00Z", LastStatus: "ok", LastSuccess: "2026-09-13T01:30:00Z", SuccessTracked: true,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
unit := filepath.Join(dest, "backups", "secondary", "app", "recovery-unit")
|
|
if err := os.MkdirAll(unit, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(unit, "manifest.json"), []byte(`{"schema_version":2,"app_name":"app","created_at":"2026-09-12T02:15:29Z"}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dest, "backups", "secondary", "app", ".felhom-tier2-layout"), []byte("2"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cov, err := m.Tier2RestoreCoverage("app")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantDate, wantStale := cov.UnitRestoreDate()
|
|
rows := s.buildAppBackupRows(&backup.FullBackupStatus{AppDataInfo: []backup.AppBackupInfo{{StackName: "app", DisplayName: "App"}}}, "hu")
|
|
row := findRow(rows, "app")
|
|
if row == nil {
|
|
t.Fatal("no row")
|
|
}
|
|
if !cov.CanRestoreUnit() {
|
|
t.Fatal("fixture: the copy must hold an openable unit")
|
|
}
|
|
if row.Tier2UnitRestorable != cov.CanRestoreUnit() || row.Tier2CopyDate != wantDate ||
|
|
row.Tier2CopyDateProven != (cov.CopyLastSuccess != "") ||
|
|
row.Tier2UnitConfirm != noteServer(t).tier2UnitConfirmWithStaleness(wantDate, cov.CopyLastSuccess != "", wantStale) {
|
|
t.Errorf("the row changed: restorable=%v date=%q proven=%v confirm=%q", row.Tier2UnitRestorable, row.Tier2CopyDate, row.Tier2CopyDateProven, row.Tier2UnitConfirm)
|
|
}
|
|
}
|
|
|
|
// The drive-return gate starts apps UNATTENDED. A held app must be skipped.
|
|
//
|
|
// COMPANION RED-PROOF (REPORT.md): delete the appHeld check from restartStacks. The server has NO stack
|
|
// manager here on purpose, so the unguarded StartStack call panics and this test fails.
|
|
func TestSlice4_DriveReturnGateSkipsAHeldApp(t *testing.T) {
|
|
s, _, m := newOffboxWebServer(t)
|
|
if err := m.HoldAfterFailedUpdate("held", time.Now(), time.Now(), backup.UpdateTierLocal); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("the drive-return gate tried to START a held app: %v", r)
|
|
}
|
|
}()
|
|
s.restartStacks([]string{"held"})
|
|
}
|