596505ed64
- Part A: classify restic cat-config failure (wrong-password=orphaned vs no-repo vs other); ORPHANED state + Hungarian card + offbox_repo_orphaned/reset events (once, not nightly); reset = move-aside (never delete) + init, unclaimed auto / claimed confirm. Red-proofs TestOffbox_OrphanDetection_* + ConfirmedReset. - Part C: GET /backup/offbox/status + poll on backups_remote → flips Fut→Rendben/Hiba without manual reload.
188 lines
7.0 KiB
Go
188 lines
7.0 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"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/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
)
|
|
|
|
func newOffboxWebServer(t *testing.T) (*Server, *settings.Settings, *backup.Manager) {
|
|
t.Helper()
|
|
tmp := t.TempDir()
|
|
lg := log.New(io.Discard, "", 0)
|
|
sett, err := settings.Load(filepath.Join(tmp, "settings.json"), lg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := &config.Config{}
|
|
cfg.Paths.DataDir = tmp
|
|
m := backup.NewManager(cfg, sett, lg)
|
|
return &Server{cfg: cfg, backupMgr: m, settings: sett, logger: lg}, sett, m
|
|
}
|
|
|
|
// The run handler refuses while escrow is pending, and confirm-escrow flips to escrowed + runnable.
|
|
func TestOffboxWeb_RunGatedUntilConfirm(t *testing.T) {
|
|
s, sett, m := newOffboxWebServer(t)
|
|
if err := m.WriteOffboxSecrets("KEYMATERIAL", "nas.local ssh-ed25519 HOSTKEY"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
|
|
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", Schedule: "daily",
|
|
EscrowState: "pending",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !m.OffboxConfigured() {
|
|
t.Fatal("target should be configured")
|
|
}
|
|
// run while pending → refused with the escrow-wait flash, no run launched
|
|
w := httptest.NewRecorder()
|
|
s.offboxRunHandler(w, httptest.NewRequest("POST", "/backup/offbox/run", nil))
|
|
if loc := w.Header().Get("Location"); w.Code != 302 || !strings.Contains(loc, "let%C3%A9t") {
|
|
t.Fatalf("pending run must redirect with the escrow-wait flash, got %d %q", w.Code, loc)
|
|
}
|
|
if m.OffboxRunnable() {
|
|
t.Fatal("must not be runnable while pending")
|
|
}
|
|
// confirm-escrow → escrowed + runnable
|
|
w2 := httptest.NewRecorder()
|
|
s.offboxConfirmEscrowHandler(w2, httptest.NewRequest("POST", "/backup/offbox/confirm-escrow", nil))
|
|
if w2.Code != 302 {
|
|
t.Fatalf("confirm: got %d", w2.Code)
|
|
}
|
|
if got := sett.GetOffboxTarget().EscrowState; got != "escrowed" {
|
|
t.Fatalf("confirm must set EscrowState=escrowed, got %q", got)
|
|
}
|
|
if !m.OffboxRunnable() {
|
|
t.Fatal("must be runnable after confirm")
|
|
}
|
|
}
|
|
|
|
// Part C — the status endpoint (poll source) reports the current run status/snapshots as JSON.
|
|
func TestOffboxStatusHandler(t *testing.T) {
|
|
s, sett, _ := newOffboxWebServer(t)
|
|
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
|
|
Enabled: true, Host: "nas.local", User: "felhom", RepoPath: "/srv/repo",
|
|
LastStatus: "running", SnapshotCount: 7,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
s.offboxStatusHandler(w, httptest.NewRequest("GET", "/backup/offbox/status", nil))
|
|
var d map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &d); err != nil {
|
|
t.Fatalf("decode: %v (%s)", err, w.Body.String())
|
|
}
|
|
if d["status"] != "running" {
|
|
t.Fatalf("status = %v, want running", d["status"])
|
|
}
|
|
if d["snapshots"].(float64) != 7 {
|
|
t.Fatalf("snapshots = %v, want 7", d["snapshots"])
|
|
}
|
|
if d["orphaned"] != false {
|
|
t.Fatalf("orphaned = %v, want false", d["orphaned"])
|
|
}
|
|
}
|
|
|
|
// Part A edge — an ORPHANED repo routes "Távoli mentés most" to the card, never attempting the write.
|
|
func TestOffboxRun_RefusedWhenOrphaned(t *testing.T) {
|
|
s, sett, m := newOffboxWebServer(t)
|
|
if err := m.WriteOffboxSecrets("KEYMATERIAL", "nas.local ssh-ed25519 HOSTKEY"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
|
|
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", Schedule: "daily",
|
|
EscrowState: "escrowed", RepoState: "orphaned",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
s.offboxRunHandler(w, httptest.NewRequest("POST", "/backup/offbox/run", nil))
|
|
if w.Code != 302 {
|
|
t.Fatalf("orphaned run must redirect, got %d", w.Code)
|
|
}
|
|
if loc := w.Header().Get("Location"); !strings.Contains(loc, "el%C3%A1rvult") {
|
|
t.Fatalf("orphaned run must redirect to the orphan-card flash, got %q", loc)
|
|
}
|
|
}
|
|
|
|
// Scenario E — the confirm flip wipes the agent-staged secret; a wipe failure is logged loudly but does
|
|
// NOT fail the confirm (the state flip is the primary effect).
|
|
func TestOffboxWeb_ConfirmWipesStagedSecret(t *testing.T) {
|
|
s, sett, m := newOffboxWebServer(t)
|
|
if err := m.WriteOffboxSecrets("KEYMATERIAL", "nas.local ssh-ed25519 HOSTKEY"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
|
|
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", Schedule: "daily",
|
|
EscrowState: "pending",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wipes := 0
|
|
s.wipeStagedEscrowFn = func(context.Context) error { wipes++; return nil }
|
|
w := httptest.NewRecorder()
|
|
s.offboxConfirmEscrowHandler(w, httptest.NewRequest("POST", "/backup/offbox/confirm-escrow", nil))
|
|
if w.Code != 302 || sett.GetOffboxTarget().EscrowState != "escrowed" {
|
|
t.Fatalf("confirm failed: code=%d state=%q", w.Code, sett.GetOffboxTarget().EscrowState)
|
|
}
|
|
if wipes != 1 {
|
|
t.Fatalf("confirm must wipe the staged secret exactly once, got %d", wipes)
|
|
}
|
|
|
|
// wipe failure → confirm still succeeds (best-effort), loud ERROR logged
|
|
var logbuf bytes.Buffer
|
|
s.logger = log.New(&logbuf, "", 0)
|
|
_ = sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.EscrowState = "pending" })
|
|
s.wipeStagedEscrowFn = func(context.Context) error { return errors.New("agent unreachable") }
|
|
w2 := httptest.NewRecorder()
|
|
s.offboxConfirmEscrowHandler(w2, httptest.NewRequest("POST", "/backup/offbox/confirm-escrow", nil))
|
|
if w2.Code != 302 || sett.GetOffboxTarget().EscrowState != "escrowed" {
|
|
t.Fatal("a wipe failure must NOT fail the confirm (state flip is primary)")
|
|
}
|
|
if !strings.Contains(logbuf.String(), "NOT wiped") {
|
|
t.Fatal("a failed wipe must log the loud NOT-wiped signal")
|
|
}
|
|
}
|
|
|
|
// The inject endpoint pre-places a recovered password (DR seam).
|
|
func TestOffboxWeb_InjectPassword(t *testing.T) {
|
|
s, _, _ := newOffboxWebServer(t)
|
|
pwPath := filepath.Join(s.cfg.Paths.DataDir, "offbox", "repo_password")
|
|
const pw = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
form := url.Values{"password": {pw}}
|
|
r := httptest.NewRequest("POST", "/backup/offbox/inject-password", strings.NewReader(form.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
w := httptest.NewRecorder()
|
|
s.offboxInjectPasswordHandler(w, r)
|
|
if w.Code != 302 {
|
|
t.Fatalf("inject: got %d", w.Code)
|
|
}
|
|
got, err := os.ReadFile(pwPath)
|
|
if err != nil || string(got) != pw {
|
|
t.Fatalf("injected password not placed 0600 at offboxPwPath: err=%v", err)
|
|
}
|
|
// an invalid password is refused (error flash)
|
|
bad := url.Values{"password": {"nope"}}
|
|
rb := httptest.NewRequest("POST", "/backup/offbox/inject-password", strings.NewReader(bad.Encode()))
|
|
rb.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
wb := httptest.NewRecorder()
|
|
s.offboxInjectPasswordHandler(wb, rb)
|
|
if loc := wb.Header().Get("Location"); !strings.Contains(loc, "flash_error") {
|
|
t.Fatalf("invalid password must produce an error flash, got %q", loc)
|
|
}
|
|
}
|