feat(web): C2 Parts 2+3 — POST /backup/tier2/restore + "Fájlok visszaállítása" button

- Endpoint next to /backup/restore; handler mirrors backupRestoreHandler
  (ParseForm → validStackName → backupMgr guard → WARN with RemoteAddr →
  RestoreTier2Files → flash). Flash strings: "<stack>: N fájl visszaállítva a
  másodlagos másolatból." / "Nincs hiányzó fájl — minden fájl megvan a helyén."
  / "Fájl-visszaállítás sikertelen: <err>" (refusals carry the Hungarian
  reasons from the engine).
- backups.html: the button on the healthy Tier-2 layer row only (the
  Tier2Configured branch already excludes disconnected/inactive; additionally
  gated on Tier2LastRun), inline POST form with CSRF + confirm dialog naming
  the additive-only semantics and the last-copy timestamp. Template gates
  (id + emoji) green.
- Handler guard test (C6): traversal/empty → exact Hungarian flash, no work
  started (nil backupMgr would panic if reached).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-05 13:20:56 +02:00
parent 30b11100e0
commit 27aeb415f4
4 changed files with 97 additions and 0 deletions
@@ -0,0 +1,50 @@
package web
import (
"io"
"log"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// postTier2Restore drives the handler with a form body, as the UI's inline form does.
func postTier2Restore(t *testing.T, s *Server, stackName string) *httptest.ResponseRecorder {
t.Helper()
form := url.Values{"stack_name": {stackName}}
req := httptest.NewRequest(http.MethodPost, "/backup/tier2/restore", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
s.backupTier2RestoreHandler(rec, req)
return rec
}
// TestTier2RestoreHandler_Guards proves Scenario C6 + the missing-param guard: traversal and empty
// names are rejected with the exact Hungarian flash BEFORE any restore work (backupMgr is nil here —
// reaching it would panic, so a pass also proves no work started).
func TestTier2RestoreHandler_Guards(t *testing.T) {
s := &Server{logger: log.New(io.Discard, "", 0)} // backupMgr nil on purpose
for name, want := range map[string]string{
"../../etc": "%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", // Érvénytelen alkalmazásnév
"a/b": "%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v",
"": "Hi%C3%A1nyz%C3%B3+param%C3%A9terek", // Hiányzó paraméterek
} {
rec := postTier2Restore(t, s, name)
if rec.Code != http.StatusFound {
t.Errorf("%q: status = %d, want 302", name, rec.Code)
continue
}
if loc := rec.Header().Get("Location"); !strings.Contains(loc, want) {
t.Errorf("%q: redirect = %q, want flash %q", name, loc, want)
}
}
// Valid name but no backup manager → the not-configured flash (still no panic, no work).
rec := postTier2Restore(t, s, "nextcloud")
if loc := rec.Header().Get("Location"); !strings.Contains(loc, "Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva") {
t.Errorf("nil backupMgr: redirect = %q", loc)
}
}