Files
felhom-controller/controller/internal/web/r729_offbox_clear_test.go
T
admin 27b373b93c R-729 + R-545: the household can remove its own off-site target („Távoli mentési cél törlése")
POST /backup/offbox/clear (reveal-then-confirm) forgets the target and deletes ssh_key + known_hosts;
nothing on the target is touched. repo_password is kept whenever anything could depend on it (hub
sealed package — the R-241 rule; escrowed; a successful run; snapshots) and deleted only otherwise.
Refused for the Felhom tier (rclone-pinned), while the single-flight is held, and while an
abandonment countdown runs. i18n parity fixtures of backups_remote gain the additive block only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 21:55:37 +02:00

120 lines
4.6 KiB
Go

package web
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-729 / R-545 — the „Távoli mentési cél törlése" press: the route is wired, needs confirm=1, and
// clears the household's own target; the page offers it for an own target and NOT for the Felhom tier.
func clearServer(t *testing.T, tgt *settings.OffboxTarget) (*Server, string) {
t.Helper()
s := noteServer(t)
s.cfg.Paths.DataDir = t.TempDir()
bm := backup.NewManager(s.cfg, s.settings, s.logger)
if err := s.settings.SetOffboxTarget(tgt); err != nil {
t.Fatal(err)
}
if err := bm.WriteOffboxSecrets("KEYMATERIAL", "nas.local ssh-ed25519 HOSTKEY"); err != nil {
t.Fatal(err)
}
s.backupMgr = bm
s.wipeStagedEscrowFn = func(context.Context) error { return nil }
return s, filepath.Join(s.cfg.Paths.DataDir, "offbox")
}
func postClear(t *testing.T, s *Server, form string) (flash, flashErr string) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/backup/offbox/clear", strings.NewReader(form))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.offboxClearHandler(w, req)
if w.Code != http.StatusFound {
t.Fatalf("want redirect, got %d", w.Code)
}
u, _ := url.Parse(w.Header().Get("Location"))
return u.Query().Get("flash"), u.Query().Get("flash_error")
}
func TestR729_ClearHandler_NeedsConfirmAndThenClears(t *testing.T) {
s, dir := clearServer(t, &settings.OffboxTarget{Enabled: false, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo"})
// Without confirm=1 nothing happens.
if _, fe := postClear(t, s, ""); fe != "flash.offbox.clear_needs_confirmation" {
t.Fatalf("no-confirm: flash_error=%q", fe)
}
if s.settings.GetOffboxTarget() == nil {
t.Fatal("an unconfirmed press removed the target")
}
f, fe := postClear(t, s, "confirm=1")
if fe != "" || f != "flash.offbox.target_cleared" {
t.Fatalf("confirmed clear: flash=%q flash_error=%q", f, fe)
}
if s.settings.GetOffboxTarget() != nil {
t.Fatal("R-729: the target survived a confirmed clear")
}
if _, err := os.Stat(filepath.Join(dir, "ssh_key")); !os.IsNotExist(err) {
t.Fatalf("R-545: the SSH key survived a confirmed clear (stat err=%v)", err)
}
// Both languages say the backups on the destination were not touched.
if hu := s.msgLang("hu", f); !strings.Contains(hu, "nem ny") {
t.Errorf("hu success text does not say the destination was untouched: %q", hu)
}
if en := s.msgLang("en", f); !strings.Contains(en, "not touched") {
t.Errorf("en success text does not say the destination was untouched: %q", en)
}
}
func TestR729_ClearHandler_RefusesHubTier(t *testing.T) {
s, dir := clearServer(t, &settings.OffboxTarget{Enabled: true, Host: "box.example", Port: 23, User: "u1", RepoPath: "/home/repo", Transport: settings.TransportRclonePinned})
if _, fe := postClear(t, s, "confirm=1"); fe != "flash.offbox.clear_hub_tier" {
t.Fatalf("hub tier: flash_error=%q", fe)
}
if s.settings.GetOffboxTarget() == nil {
t.Fatal("the Felhom tier was removed from the box")
}
if _, err := os.Stat(filepath.Join(dir, "ssh_key")); err != nil {
t.Fatalf("the Felhom tier's key was deleted: %v", err)
}
}
// Render test per branch of the template gate (seam-built-but-never-wired lesson).
func TestR729_RemotePage_OffersClearOnlyForOwnTarget(t *testing.T) {
d := splitTestData()
html := renderBackupPage(t, "backups_remote", d)
if !strings.Contains(html, `action="/backup/offbox/clear"`) {
t.Fatal("R-729: an own target renders no clear press")
}
d["Offbox"] = &settings.OffboxTarget{Enabled: true, Host: "box.example", LastStatus: "ok", EscrowState: "escrowed", Transport: settings.TransportRclonePinned}
if strings.Contains(renderBackupPage(t, "backups_remote", d), `action="/backup/offbox/clear"`) {
t.Fatal("R-729: the Felhom tier must not be offered for removal")
}
d["Offbox"] = nil
d["OffboxConfigured"] = false
if strings.Contains(renderBackupPage(t, "backups_remote", d), `action="/backup/offbox/clear"`) {
t.Fatal("R-729: no target, yet a clear press renders")
}
}
// The route reaches the handler (a case only in a comment is the decoy this guards against).
func TestR729_ClearRouteIsWired(t *testing.T) {
src, err := os.ReadFile("server.go")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(src), `path == "/backup/offbox/clear" && r.Method == http.MethodPost:`+" // R-729 / R-545\n\t\ts.offboxClearHandler(w, r)") {
t.Fatal("R-729: /backup/offbox/clear is not routed to offboxClearHandler")
}
}