package web import ( "bytes" "context" "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") } } // 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) } }