Files
felhom-controller/controller/internal/web/escrow_wizard_test.go
T

439 lines
16 KiB
Go

package web
import (
"context"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"golang.org/x/crypto/bcrypt"
)
// Escrow wizard handler tests (v0.127.0). The agent + staging seams record CALL ORDER — the
// load-bearing Scenario A/B assertion is "re-stage happened BEFORE the agent trigger", and every
// Scenario E gate must exit with the agent NEVER called.
const wizardPassword = "titkos-jelszo"
type fakeEscrowAgent struct {
order *[]string // shared call-order log (harness-owned)
version string
startResp agentapi.EscrowCeremonyStartResponse
startStatus int
startErr error
status agentapi.EscrowCeremonyStatusResponse
claimCode string
claimStatus int
claimErr error
pf agentapi.EscrowPreflightResponse
}
func (f *fakeEscrowAgent) EscrowPreflight(context.Context) (agentapi.EscrowPreflightResponse, error) {
*f.order = append(*f.order, "preflight")
return f.pf, nil
}
func (f *fakeEscrowAgent) EscrowCeremonyStart(context.Context) (agentapi.EscrowCeremonyStartResponse, int, error) {
*f.order = append(*f.order, "start")
return f.startResp, f.startStatus, f.startErr
}
func (f *fakeEscrowAgent) EscrowCeremonyStatus(context.Context) (agentapi.EscrowCeremonyStatusResponse, error) {
return f.status, nil
}
func (f *fakeEscrowAgent) EscrowCeremonyClaim(context.Context) (string, int, error) {
*f.order = append(*f.order, "claim")
return f.claimCode, f.claimStatus, f.claimErr
}
func (f *fakeEscrowAgent) AgentVersion() string { return f.version }
type escrowWizardHarness struct {
s *Server
sett *settings.Settings
m *backup.Manager
agent *fakeEscrowAgent
order []string
}
func newEscrowWizardHarness(t *testing.T) *escrowWizardHarness {
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
hash, err := bcrypt.GenerateFromPassword([]byte(wizardPassword), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
cfg.Web.PasswordHash = string(hash)
h := &escrowWizardHarness{
sett: sett,
m: backup.NewManager(cfg, sett, lg),
}
h.agent = &fakeEscrowAgent{
order: &h.order,
version: "0.88.0",
startResp: agentapi.EscrowCeremonyStartResponse{JobID: "escrow-1", Phase: "running"},
startStatus: http.StatusAccepted,
}
h.s = &Server{cfg: cfg, backupMgr: h.m, settings: sett, logger: lg}
h.s.escrowAgentFn = func() (escrowAgent, error) { return h.agent, nil }
h.s.escrowStageFn = func(context.Context) error { h.order = append(h.order, "stage"); return nil }
return h
}
// configureOffbox makes OffboxConfigured() true with the given escrow state.
func (h *escrowWizardHarness) configureOffbox(t *testing.T, state string) {
t.Helper()
if err := h.m.WriteOffboxSecrets("KEYMATERIAL", "nas.local ssh-ed25519 HOSTKEY"); err != nil {
t.Fatal(err)
}
if err := h.sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo",
Schedule: "daily", EscrowState: state,
}); err != nil {
t.Fatal(err)
}
if !h.m.OffboxConfigured() {
t.Fatal("setup: offbox should be configured")
}
}
func postStart(t *testing.T, s *Server, password string) *httptest.ResponseRecorder {
t.Helper()
form := url.Values{"password": {password}}
r := httptest.NewRequest("POST", "/api/escrow/start", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.escrowStartAPIHandler(w, r)
return w
}
// Scenario A — pending offsite happy path: correct password → re-stage FIRST, then the agent
// trigger; 200 with the job id.
func TestEscrowStart_StagesBeforeTrigger(t *testing.T) {
h := newEscrowWizardHarness(t)
h.configureOffbox(t, "pending")
w := postStart(t, h.s, wizardPassword)
if w.Code != http.StatusOK {
t.Fatalf("start: got %d (%s)", w.Code, w.Body.String())
}
if got := strings.Join(h.order, ","); got != "stage,start" {
t.Fatalf("call order = %q, want stage BEFORE start (a ceremony without the staged secret mints a hash-less blob)", got)
}
if !strings.Contains(w.Body.String(), "escrow-1") {
t.Fatalf("response lacks the job id: %s", w.Body.String())
}
}
// Scenario B — re-ceremony from ESCROWED state stages too (the same order assertion).
func TestEscrowStart_ReceremonyStagesToo(t *testing.T) {
h := newEscrowWizardHarness(t)
h.configureOffbox(t, "escrowed")
if w := postStart(t, h.s, wizardPassword); w.Code != http.StatusOK {
t.Fatalf("re-ceremony start: got %d", w.Code)
}
if got := strings.Join(h.order, ","); got != "stage,start" {
t.Fatalf("re-ceremony call order = %q, want stage,start", got)
}
}
// Scenario C — no offbox configured: NO staging attempted; the ceremony still runs.
func TestEscrowStart_NoOffboxSkipsStaging(t *testing.T) {
h := newEscrowWizardHarness(t)
if w := postStart(t, h.s, wizardPassword); w.Code != http.StatusOK {
t.Fatalf("start: got %d", w.Code)
}
if got := strings.Join(h.order, ","); got != "start" {
t.Fatalf("call order = %q, want start only (no staging without offbox)", got)
}
}
// Scenario E — every gate refuses BEFORE the agent (and staging) is touched.
func TestEscrowStart_SecurityGates(t *testing.T) {
t.Run("wrong password", func(t *testing.T) {
h := newEscrowWizardHarness(t)
h.configureOffbox(t, "pending")
w := postStart(t, h.s, "rossz-jelszo")
if w.Code != http.StatusUnauthorized {
t.Fatalf("wrong password: got %d, want 401", w.Code)
}
if len(h.order) != 0 {
t.Fatalf("agent/staging touched despite failed re-auth: %v", h.order)
}
// The failure rides the LOGIN rate limiter.
h.s.loginAttemptMu.Lock()
var count int
for _, a := range h.s.loginAttempts {
count += a.count
}
h.s.loginAttemptMu.Unlock()
if count != 1 {
t.Fatalf("wrong password must increment the login rate-limit counter, got %d", count)
}
})
t.Run("rate limited after max attempts", func(t *testing.T) {
h := newEscrowWizardHarness(t)
for i := 0; i < loginMaxAttempts; i++ {
postStart(t, h.s, "rossz-jelszo")
}
w := postStart(t, h.s, wizardPassword) // even the CORRECT password is refused inside the window
if w.Code != http.StatusTooManyRequests {
t.Fatalf("rate limit: got %d, want 429", w.Code)
}
if len(h.order) != 0 {
t.Fatalf("agent touched while rate-limited: %v", h.order)
}
})
t.Run("passwordless box refused", func(t *testing.T) {
h := newEscrowWizardHarness(t)
h.s.cfg.Web.PasswordHash = ""
w := postStart(t, h.s, "")
if w.Code != http.StatusForbidden {
t.Fatalf("passwordless: got %d, want 403", w.Code)
}
if len(h.order) != 0 {
t.Fatal("agent touched on a passwordless box")
}
})
t.Run("stage failure aborts before trigger", func(t *testing.T) {
h := newEscrowWizardHarness(t)
h.configureOffbox(t, "pending")
h.s.escrowStageFn = func(context.Context) error { return fmt.Errorf("agent down") }
w := postStart(t, h.s, wizardPassword)
if w.Code != http.StatusBadGateway {
t.Fatalf("stage failure: got %d, want 502", w.Code)
}
for _, c := range h.order {
if c == "start" {
t.Fatal("the ceremony started despite the failed staging (would mint a hash-less blob)")
}
}
})
t.Run("agent too old", func(t *testing.T) {
h := newEscrowWizardHarness(t)
h.agent.version = "0.87.0"
w := postStart(t, h.s, wizardPassword)
if w.Code != http.StatusConflict {
t.Fatalf("old agent: got %d, want 409", w.Code)
}
for _, c := range h.order {
if c == "start" {
t.Fatal("an old agent must not be triggered")
}
}
})
t.Run("header-less agent counts as older", func(t *testing.T) {
h := newEscrowWizardHarness(t)
h.agent.version = ""
if w := postStart(t, h.s, wizardPassword); w.Code != http.StatusConflict {
t.Fatalf("version-less agent: got %d, want 409", w.Code)
}
})
t.Run("ceremony already running", func(t *testing.T) {
h := newEscrowWizardHarness(t)
h.agent.startStatus = http.StatusConflict
h.agent.startErr = fmt.Errorf("agentapi: POST /escrow/ceremony: HTTP 409: already running")
w := postStart(t, h.s, wizardPassword)
if w.Code != http.StatusConflict {
t.Fatalf("busy agent: got %d, want 409", w.Code)
}
if !strings.Contains(w.Body.String(), "folyamatban") {
t.Fatalf("busy refusal must speak Hungarian: %s", w.Body.String())
}
})
}
// Scenario D (controller half) — the claim proxy: no-store on 200, the 410 void message, and the
// code appears ONLY in the claim response.
func TestEscrowClaim_ProxySemantics(t *testing.T) {
const code = "proba-kod-tiz-szo"
t.Run("success", func(t *testing.T) {
h := newEscrowWizardHarness(t)
h.agent.claimCode = code
h.agent.claimStatus = http.StatusOK
w := httptest.NewRecorder()
h.s.escrowClaimAPIHandler(w, httptest.NewRequest("POST", "/api/escrow/claim", nil))
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), code) {
t.Fatalf("claim: got %d %s", w.Code, w.Body.String())
}
if cc := w.Header().Get("Cache-Control"); cc != "no-store" {
t.Fatalf("claim Cache-Control = %q, want no-store", cc)
}
})
t.Run("gone", func(t *testing.T) {
h := newEscrowWizardHarness(t)
h.agent.claimStatus = http.StatusGone
h.agent.claimErr = fmt.Errorf("gone")
w := httptest.NewRecorder()
h.s.escrowClaimAPIHandler(w, httptest.NewRequest("POST", "/api/escrow/claim", nil))
if w.Code != http.StatusGone || !strings.Contains(w.Body.String(), "újra nem kérhető le") {
t.Fatalf("gone: got %d %s", w.Code, w.Body.String())
}
})
}
// The status proxy relays the agent's non-secret job view verbatim.
func TestEscrowStatus_Proxy(t *testing.T) {
h := newEscrowWizardHarness(t)
h.agent.status = agentapi.EscrowCeremonyStatusResponse{
Phase: "done", JobID: "escrow-1", ResticPwSealed: true, Uploaded: true,
Claimable: true, ClaimExpiresInSec: 599,
}
w := httptest.NewRecorder()
h.s.escrowStatusAPIHandler(w, httptest.NewRequest("GET", "/api/escrow/status", nil))
body := w.Body.String()
for _, want := range []string{`"phase":"done"`, `"claimable":true`, `"restic_pw_sealed":true`} {
if !strings.Contains(body, want) {
t.Fatalf("status proxy missing %s: %s", want, body)
}
}
}
// The wizard page + card render states (Hungarian copy, no server-side R anywhere by
// construction — the template has no code variable to leak).
func TestEscrowTemplates_Render(t *testing.T) {
base := func() map[string]interface{} {
return map[string]interface{}{
"Title": "Helyreállítási kód", "Domain": "example.hu",
"AgentSupported": true, "Receremony": false, "OffboxConfigured": true,
}
}
t.Run("wizard fresh", func(t *testing.T) {
html := renderBackupPage(t, "backups_escrow", base())
for _, want := range []string{
"Előfeltételek ellenőrzése",
"a mentései utolsó kulcsa",
"A folytatáshoz adja meg a bejelentkezési jelszavát",
"Kód létrehozása",
"Kód megjelenítése",
"Ez a kód többé nem jeleníthető meg.",
"nem ezen a szerveren",
"biztonsági okból újra nem kérhető le",
// v0.127.1 polish: the Hungarian preflight detail map + the hide/show toggle
// (v0.127.2: manual-only — initial label "Elrejtés", code visible by default).
"nincs előkészített jelszó — a varázsló indításkor automatikusan előkészíti",
"a rendszerjogosultság hiányzik",
"Megjelenítés",
">Elrejtés</button>",
} {
if !strings.Contains(html, want) {
t.Errorf("wizard missing %q", want)
}
}
// v0.127.2 (Viktor's live finding): the code must NOT auto-blur when a verification input
// gets focus — the customer types from the screen. The hide toggle is manual-only.
if strings.Contains(html, "armCodeBlur") || strings.Contains(html, "onfocus=") {
t.Error("auto-blur-on-focus must be gone (manual toggle only)")
}
if strings.Contains(html, "érvényét veszti") {
t.Error("fresh wizard must not show the re-ceremony supersede warning")
}
// v0.127.1: the agent's operator-English detail strings must never be hardcoded into the
// page — they may only arrive at runtime, inside the muted failure-diagnostic span.
for _, raw := range []string{"DR tier applied", "hub upload target configured", "sudo grant listed"} {
if strings.Contains(html, raw) {
t.Errorf("raw English agent detail %q leaked into the wizard page source", raw)
}
}
})
t.Run("wizard re-ceremony variant", func(t *testing.T) {
d := base()
d["Receremony"] = true
if html := renderBackupPage(t, "backups_escrow", d); !strings.Contains(html, "érvényét veszti") {
t.Error("re-ceremony wizard must show the supersede warning")
}
})
t.Run("wizard agent too old", func(t *testing.T) {
d := base()
d["AgentSupported"] = false
html := renderBackupPage(t, "backups_escrow", d)
if !strings.Contains(html, "az ügynök frissítése szükséges") {
t.Error("old-agent wizard must show the version note")
}
if strings.Contains(html, "Kód létrehozása") {
t.Error("old-agent wizard must be inert (no start form)")
}
})
t.Run("remote card states", func(t *testing.T) {
data := splitTestData()
data["EscrowAgentOK"] = true
data["EscrowStale"] = false
html := renderBackupPage(t, "backups_remote", data) // escrowed clean
if !strings.Contains(html, "Új helyreállítási kód készítése") {
t.Error("escrowed card must offer the secondary re-ceremony link")
}
// v0.127.1: the CTA is a real secondary BUTTON (outline — available-not-urgent), no longer
// an inline link buried in the muted hint.
if !strings.Contains(html, `class="btn btn-sm btn-outline">Új helyreállítási kód készítése`) {
t.Error("escrowed-card CTA must render as a btn-outline button")
}
if strings.Contains(html, "Letét megerősítése") {
t.Error("the deprecated manual-confirm button must be GONE from the card")
}
data["EscrowStale"] = true
html = renderBackupPage(t, "backups_remote", data)
if !strings.Contains(html, "nem fedi a jelenlegi távoli mentési jelszót") {
t.Error("stale card must show the exact stale warning")
}
pending := splitTestData()
pending["EscrowAgentOK"] = true
pending["EscrowStale"] = false
pending["Offbox"].(*settings.OffboxTarget).EscrowState = "pending"
html = renderBackupPage(t, "backups_remote", pending)
if !strings.Contains(html, "Helyreállítási kód szükséges") || !strings.Contains(html, "Helyreállítási kód létrehozása") {
t.Error("pending card must show the CTA state")
}
old := splitTestData()
old["EscrowAgentOK"] = false
old["EscrowStale"] = false
old["Offbox"].(*settings.OffboxTarget).EscrowState = "pending"
html = renderBackupPage(t, "backups_remote", old)
if !strings.Contains(html, "az ügynök frissítése szükséges") || strings.Contains(html, `href="/backup/escrow"`) {
t.Error("old-agent card must show the version note with NO CTA")
}
})
}
// The preflight proxy augments the agent checklist with the version gate + offbox facts.
func TestEscrowPreflight_Augments(t *testing.T) {
h := newEscrowWizardHarness(t)
h.configureOffbox(t, "escrowed")
h.agent.pf = agentapi.EscrowPreflightResponse{OK: true, Items: []agentapi.EscrowPreflightItem{
{ID: "pbs_storage_id", OK: true, Detail: "felhom-pbs"},
}}
w := httptest.NewRecorder()
h.s.escrowPreflightAPIHandler(w, httptest.NewRequest("GET", "/api/escrow/preflight", nil))
body := w.Body.String()
for _, want := range []string{`"agent_supported":true`, `"escrow_state":"escrowed"`, `"offbox_configured":true`, `"pbs_storage_id"`} {
if !strings.Contains(body, want) {
t.Fatalf("preflight missing %s: %s", want, body)
}
}
// An old agent flips both the flag and the aggregate ok.
h.agent.version = "0.87.0"
w2 := httptest.NewRecorder()
h.s.escrowPreflightAPIHandler(w2, httptest.NewRequest("GET", "/api/escrow/preflight", nil))
if !strings.Contains(w2.Body.String(), `"agent_supported":false`) || !strings.Contains(w2.Body.String(), `"ok":false`) {
t.Fatalf("old agent must flip agent_supported + ok: %s", w2.Body.String())
}
}