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
175 lines
6.1 KiB
Go
175 lines
6.1 KiB
Go
package backup
|
|
|
|
import (
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// R-383 — the double-failure message claimed the undo copy EXISTED without ever asking the disk.
|
|
//
|
|
// THE DEFECT. The branch where BOTH the replay and the rollback failed ended with
|
|
// „a korábbi állapot mentése megvan: <file>". The filename came from the path `writeSafetyDump`
|
|
// returned. One of the two ways `rollbackSafetyDump` fails is that the file is NOT THERE — so the
|
|
// sentence was most likely to be false in precisely the case it was printed. Measured twice live, on
|
|
// v0.220.2 and v0.221.1.
|
|
//
|
|
// THE LAYER. The guard sits on the phrase builder, because that is where the claim is MADE. A test
|
|
// at the reconstitute level would need a whole failed off-site restore to reach one sentence, and the
|
|
// AST test below is what pins that the sentence is still wired to this builder.
|
|
//
|
|
// RED-PROOF (observed, see REPORT.md): replace undoCopyPhrase's body with the pre-fix shape
|
|
//
|
|
// return "a korábbi állapot mentése megvan: " + filepath.Base(set.First())
|
|
//
|
|
// and TestR383_AbsentUndoCopyIsNotClaimedToExist fails with the message asserting the file exists.
|
|
func TestR383_AbsentUndoCopyIsNotClaimedToExist(t *testing.T) {
|
|
dir := t.TempDir()
|
|
real := filepath.Join(dir, "pre-restore-20260823T120000Z-app-postgres.sql")
|
|
if err := os.WriteFile(real, []byte("-- a real dump\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gone := filepath.Join(dir, "pre-restore-20260823T120000Z-app-mariadb.sql")
|
|
empty := filepath.Join(dir, "pre-restore-20260823T120000Z-app-empty.sql")
|
|
if err := os.WriteFile(empty, nil, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
set := func(paths ...string) safetyDumpSet {
|
|
s := safetyDumpSet{Stamp: "20260823T120000Z"}
|
|
for _, p := range paths {
|
|
s.Files = append(s.Files, safetyDumpFile{Path: p})
|
|
}
|
|
return s
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
set safetyDumpSet
|
|
mustContain []string
|
|
mustNotHave []string
|
|
}{
|
|
{
|
|
name: "present — the operator still gets the filename",
|
|
set: set(real),
|
|
mustContain: []string{"megvan", filepath.Base(real)},
|
|
},
|
|
{
|
|
// THE DEFECT ITSELF: the file is gone and the sentence used to say it was there.
|
|
name: "absent — must NOT claim it exists, must still name where it should be",
|
|
set: set(gone),
|
|
mustContain: []string{"NEM találjuk", filepath.Base(gone)},
|
|
mustNotHave: []string{"mentése megvan"},
|
|
},
|
|
{
|
|
// A 0-byte dump restores nothing. Calling it present is the same false reassurance.
|
|
name: "zero-length — counts as missing",
|
|
set: set(empty),
|
|
mustContain: []string{"NEM találjuk", filepath.Base(empty)},
|
|
mustNotHave: []string{"mentése megvan"},
|
|
},
|
|
{
|
|
name: "partial — both halves named, neither hidden",
|
|
set: set(real, gone),
|
|
mustContain: []string{"RÉSZBEN", filepath.Base(real), "HIÁNYZIK", filepath.Base(gone)},
|
|
},
|
|
{
|
|
name: "no undo was ever written — said plainly, not silently",
|
|
set: set(),
|
|
mustContain: []string{"nem készült"},
|
|
mustNotHave: []string{"megvan"},
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := newNoteManager(t).undoCopyPhrase(tc.set)
|
|
for _, want := range tc.mustContain {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("phrase %q does not contain %q", got, want)
|
|
}
|
|
}
|
|
for _, bad := range tc.mustNotHave {
|
|
if strings.Contains(got, bad) {
|
|
t.Errorf("phrase %q contains %q — it asserts a file that is not on disk", got, bad)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The seam, through production wiring (§10). A correct phrase builder nobody calls is R-106's defect
|
|
// — "seam built but never wired", four instances in this project. This walks the AST rather than
|
|
// grepping for a substring, so a commented-out call or a similarly-named local cannot satisfy it.
|
|
func TestR383_TheDoubleFailureMessageIsWiredToTheBuilder(t *testing.T) {
|
|
fset := token.NewFileSet()
|
|
f, err := parser.ParseFile(fset, "offbox_reconstitute.go", nil, 0)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
|
|
var holdCalled, phraseCalled bool
|
|
ast.Inspect(f, func(n ast.Node) bool {
|
|
call, ok := n.(*ast.CallExpr)
|
|
if !ok {
|
|
return true
|
|
}
|
|
switch fn := call.Fun.(type) {
|
|
case *ast.SelectorExpr:
|
|
// v0.254.0 (R-557 release C): undoCopyPhrase became a METHOD, because a saved note is
|
|
// rendered in the box's language and that needs the Manager. A method call is a
|
|
// SelectorExpr, not an Ident — so it is matched here as well as below, and the test keeps
|
|
// asserting what it always asserted rather than passing because the shape moved.
|
|
if fn.Sel.Name == "holdAppAfterFailedRollback" {
|
|
holdCalled = true
|
|
}
|
|
if fn.Sel.Name == "undoCopyPhrase" {
|
|
phraseCalled = true
|
|
}
|
|
case *ast.Ident:
|
|
if fn.Name == "undoCopyPhrase" {
|
|
phraseCalled = true
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
if !holdCalled {
|
|
t.Fatal("holdAppAfterFailedRollback is no longer called — the double-failure branch moved; " +
|
|
"re-point this test before trusting it")
|
|
}
|
|
if !phraseCalled {
|
|
t.Fatal("undoCopyPhrase is never called from offbox_reconstitute.go — the message is back to " +
|
|
"asserting a file it did not check (R-383)")
|
|
}
|
|
|
|
// And the claim must not be re-typed OUTSIDE the builder. Inside undoCopyPhrase it is the
|
|
// verified branch and belongs there; anywhere else it is unconditional again, which is R-383.
|
|
// The builder's extent comes from the AST, so this cannot be defeated by moving the function.
|
|
var lo, hi token.Pos
|
|
for _, d := range f.Decls {
|
|
if fd, ok := d.(*ast.FuncDecl); ok && fd.Name.Name == "undoCopyPhrase" {
|
|
lo, hi = fd.Pos(), fd.End()
|
|
}
|
|
}
|
|
if lo == token.NoPos {
|
|
t.Fatal("undoCopyPhrase is not declared in offbox_reconstitute.go")
|
|
}
|
|
ast.Inspect(f, func(n ast.Node) bool {
|
|
lit, ok := n.(*ast.BasicLit)
|
|
if !ok || lit.Kind != token.STRING {
|
|
return true
|
|
}
|
|
if lit.Pos() >= lo && lit.End() <= hi {
|
|
return true // inside the builder — the verified branch
|
|
}
|
|
if strings.Contains(lit.Value, "állapot mentése megvan") {
|
|
t.Errorf("%s: the unconditional claim is back outside undoCopyPhrase: %s",
|
|
fset.Position(lit.Pos()), lit.Value)
|
|
}
|
|
return true
|
|
})
|
|
}
|