v0.279.0: after_install (decision 45), known default logins on the page, Part D empty-backup alarm, night chain (R-705), R-706
gates / gates (push) Successful in 27s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-28 18:38:30 +02:00
parent 2e9a948cbd
commit 0c702f834a
35 changed files with 1119 additions and 16 deletions
@@ -0,0 +1,76 @@
package api
import (
"go/ast"
"go/parser"
"go/token"
"io"
"log"
"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-706 (v0.279.0) — a removal "with its backups" also deletes the app's off-site verification copy, and
// ONLY that app's. COMPANION RED-PROOF: remove the removeVerificationCopy call from removeStack → the AST
// check fails; make the helper skip the delete → the copy is still on disk.
func TestR706_RemovalWithBackupsDeletesTheVerificationCopy(t *testing.T) {
dir := t.TempDir()
drive := filepath.Join(dir, "drive")
cfg := &config.Config{}
cfg.Paths.DataDir = filepath.Join(dir, "data")
_ = os.MkdirAll(cfg.Paths.DataDir, 0o755)
_ = os.MkdirAll(drive, 0o755)
sett, err := settings.Load(filepath.Join(cfg.Paths.DataDir, "settings.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "HDD", Schedulable: true}); err != nil {
t.Fatal(err)
}
bm := backup.NewManager(cfg, sett, log.New(io.Discard, "", 0))
mine, other := bm.OffsiteRestoreScratchPath("cloudapp"), bm.OffsiteRestoreScratchPath("otherapp")
if mine == "" || other == "" {
t.Fatalf("no verification-copy location resolved (%q, %q)", mine, other)
}
for _, p := range []string{mine, other} {
_ = os.MkdirAll(p, 0o755)
_ = os.WriteFile(filepath.Join(p, "restored.bin"), []byte("x"), 0o644)
}
r := &Router{backupMgr: bm, logger: log.New(io.Discard, "", 0)}
got := r.removeVerificationCopy("cloudapp")
if len(got) != 1 || !strings.HasPrefix(got[0], mine) {
t.Fatalf("reported %v, want the copy at %s", got, mine)
}
if _, err := os.Stat(mine); !os.IsNotExist(err) {
t.Fatalf("the verification copy is still on disk: %v", err)
}
if _, err := os.Stat(filepath.Join(other, "restored.bin")); err != nil {
t.Fatalf("another app's verification copy was touched: %v", err)
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "router.go", nil, 0)
if err != nil {
t.Fatal(err)
}
called := false
for _, d := range f.Decls {
if fn, ok := d.(*ast.FuncDecl); ok && fn.Name.Name == "removeStack" && fn.Body != nil {
ast.Inspect(fn.Body, func(n ast.Node) bool {
if s, ok := n.(*ast.SelectorExpr); ok && s.Sel.Name == "removeVerificationCopy" {
called = true
}
return true
})
}
}
if !called {
t.Fatal("removeStack does not call removeVerificationCopy (R-706)")
}
}
+26
View File
@@ -929,6 +929,29 @@ func (r *Router) dropLeftoverHold(name, why string) {
}
}
// removeVerificationCopy (R-706, v0.279.0) deletes the app's off-site VERIFICATION copy
// (`backups/offsite-restore/<app>`, left by a full off-site restore for the household to inspect) when the
// app is removed "with its backups". Measured 2026-09-28 on demo-hp: ~1 GB stayed after such a removal, and
// the app list no longer showed anything it belonged to. Returns the removed path as the removal reports it.
// Pinned by TestR706_RemovalWithBackupsDeletesTheVerificationCopy.
func (r *Router) removeVerificationCopy(name string) []string {
if r.backupMgr == nil {
return nil
}
var out []string
for _, c := range r.backupMgr.ListOffsiteRestoreCopies() {
if c.Stack != name {
continue
}
if err := r.backupMgr.DeleteOffsiteRestoreCopy(name); err != nil {
r.logger.Printf("[WARN] [api] remove %s: its off-site verification copy %s could not be deleted: %v", name, c.Path, err)
continue
}
out = append(out, fmt.Sprintf("%s (%s)", c.Path, c.SizeHuman))
}
return out
}
func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name string) {
if name == "" {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"})
@@ -1008,6 +1031,9 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri
if body.RemoveBackups && r.backupMgr != nil && len(mirrorDirs) > 0 {
resp.BackupPathsRemoved = append(resp.BackupPathsRemoved, r.backupMgr.RemoveTier2Mirrors(name, mirrorDirs)...)
}
if body.RemoveBackups {
resp.BackupPathsRemoved = append(resp.BackupPathsRemoved, r.removeVerificationCopy(name)...)
}
// R-486 (v0.240.0): the app's backup preferences — and with them the Tier-2 RECORD that
// tier2RecordedCopyDir needs — are forgotten ONLY when the customer asked for the backups to be