// ui_restore_test.go — covers the restore wizard backend (P3-01). package http import ( "context" "encoding/json" stdhttp "net/http" "net/url" "strings" "testing" "time" "github.com/coder/websocket" "github.com/oklog/ulid/v2" "gitea.dcglab.co.uk/steve/restic-manager/internal/api" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) // seedSnapshot creates a snapshot row directly via ReplaceHostSnapshots. // Returns the snapshot ID. func seedSnapshot(t *testing.T, st *store.Store, hostID, hostname string) string { t.Helper() id := strings.ReplaceAll(ulid.Make().String(), "-", "") short := id[:8] if err := st.ReplaceHostSnapshots(context.Background(), hostID, []store.Snapshot{{ ID: id, ShortID: short, Time: time.Now().UTC().Add(-2 * time.Hour), Hostname: hostname, Paths: []string{"/etc"}, Tags: []string{"system-config"}, SizeBytes: 612 * 1024 * 1024, FileCount: 100, }}, time.Now().UTC()); err != nil { t.Fatalf("seed snapshot: %v", err) } return id } // seedTwoSnapshots seeds two snapshots in one ReplaceHostSnapshots call // so both end up in the host's list. ReplaceHostSnapshots is atomic- // swap, so calling seedSnapshot twice would only leave the second. func seedTwoSnapshots(t *testing.T, st *store.Store, hostID, hostname string) (string, string) { t.Helper() a := strings.ReplaceAll(ulid.Make().String(), "-", "") b := strings.ReplaceAll(ulid.Make().String(), "-", "") if err := st.ReplaceHostSnapshots(context.Background(), hostID, []store.Snapshot{ { ID: a, ShortID: a[:8], Time: time.Now().UTC().Add(-3 * time.Hour), Hostname: hostname, Paths: []string{"/etc"}, Tags: []string{"system-config"}, }, { ID: b, ShortID: b[:8], Time: time.Now().UTC().Add(-1 * time.Hour), Hostname: hostname, Paths: []string{"/etc"}, Tags: []string{"system-config"}, }, }, time.Now().UTC()); err != nil { t.Fatalf("seed snapshots: %v", err) } return a, b } // TestRestoreWizardGetRendersStep1 verifies the snapshot picker is on // the page when no snapshot is pre-selected. func TestRestoreWizardGetRendersStep1(t *testing.T) { t.Parallel() srv, ts, st := rawTestServerWithUI(t) hostID, _ := enrolHostForUI(t, srv, st, "rstore-host-1") _ = seedSnapshot(t, st, hostID, "rstore-host-1") cookie := loginAsAdmin(t, st) req, _ := stdhttp.NewRequest("GET", ts.URL+"/hosts/"+hostID+"/restore", nil) req.AddCookie(cookie) res, err := stdhttp.DefaultClient.Do(req) if err != nil { t.Fatalf("do: %v", err) } defer res.Body.Close() if res.StatusCode != stdhttp.StatusOK { t.Fatalf("status: got %d, want 200", res.StatusCode) } body := readBody(t, res.Body) if !strings.Contains(body, "Restore from snapshot") { t.Errorf("expected wizard heading; body: %s", short(body)) } if !strings.Contains(body, "Pick a snapshot first") && !strings.Contains(body, "Pick the point-in-time you want to restore from") { t.Errorf("expected step-1 prompt") } } // TestRestoreWizardGetWithSnapshotPreselected verifies the deep-link // path puts the snapshot summary card on the page. func TestRestoreWizardGetWithSnapshotPreselected(t *testing.T) { t.Parallel() srv, ts, st := rawTestServerWithUI(t) hostID, _ := enrolHostForUI(t, srv, st, "rstore-host-2") sid := seedSnapshot(t, st, hostID, "rstore-host-2") cookie := loginAsAdmin(t, st) req, _ := stdhttp.NewRequest("GET", ts.URL+"/hosts/"+hostID+"/snapshots/"+sid+"/restore", nil) req.AddCookie(cookie) res, err := stdhttp.DefaultClient.Do(req) if err != nil { t.Fatalf("do: %v", err) } defer res.Body.Close() if res.StatusCode != stdhttp.StatusOK { t.Fatalf("status: got %d", res.StatusCode) } body := readBody(t, res.Body) // The selected summary card should reference the snapshot's short ID. if !strings.Contains(body, sid[:8]) { t.Errorf("expected snapshot short id in body") } if !strings.Contains(body, "picked from") { t.Errorf("expected 'picked from N snapshots' summary line") } } // TestRestorePostRequiresSnapshot: form without snapshot_id re-renders // with an error. func TestRestorePostRequiresSnapshot(t *testing.T) { t.Parallel() srv, ts, st := rawTestServerWithUI(t) hostID, _ := enrolHostForUI(t, srv, st, "rstore-no-snap") cookie := loginAsAdmin(t, st) form := url.Values{ "snapshot_id": {""}, "target_mode": {"new_dir"}, "paths": {"/etc/foo"}, } req, _ := stdhttp.NewRequest("POST", ts.URL+"/hosts/"+hostID+"/restore", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) res, err := stdhttp.DefaultClient.Do(req) if err != nil { t.Fatalf("do: %v", err) } defer res.Body.Close() if res.StatusCode != stdhttp.StatusUnprocessableEntity { t.Fatalf("status: got %d, want 422", res.StatusCode) } body := readBody(t, res.Body) if !strings.Contains(body, "Pick a snapshot") { t.Errorf("expected 'Pick a snapshot' error in body") } } // TestRestorePostRequiresPaths: form with snapshot but no paths is rejected. func TestRestorePostRequiresPaths(t *testing.T) { t.Parallel() srv, ts, st := rawTestServerWithUI(t) hostID, _ := enrolHostForUI(t, srv, st, "rstore-no-paths") sid := seedSnapshot(t, st, hostID, "rstore-no-paths") cookie := loginAsAdmin(t, st) form := url.Values{ "snapshot_id": {sid}, "target_mode": {"new_dir"}, } req, _ := stdhttp.NewRequest("POST", ts.URL+"/hosts/"+hostID+"/restore", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) res, err := stdhttp.DefaultClient.Do(req) if err != nil { t.Fatalf("do: %v", err) } defer res.Body.Close() if res.StatusCode != stdhttp.StatusUnprocessableEntity { t.Fatalf("status: got %d, want 422", res.StatusCode) } body := readBody(t, res.Body) if !strings.Contains(body, "at least one file") { t.Errorf("expected paths-required error") } } // TestRestorePostInPlaceRequiresHostnameMatch: in-place mode with the // wrong hostname typed re-renders + does not dispatch. func TestRestorePostInPlaceRequiresHostnameMatch(t *testing.T) { t.Parallel() srv, ts, st := rawTestServerWithUI(t) hostID, token := enrolHostForUI(t, srv, st, "rstore-inplace") sid := seedSnapshot(t, st, hostID, "rstore-inplace") c := agentDial(t, srv, ts, hostID, token) sendHello(t, c, "rstore-inplace") _ = drainUntil(t, c, api.MsgScheduleSet) cookie := loginAsAdmin(t, st) form := url.Values{ "snapshot_id": {sid}, "target_mode": {"in_place"}, "paths": {"/etc/nginx/nginx.conf"}, "confirm_hostname": {"WRONG"}, } req, _ := stdhttp.NewRequest("POST", ts.URL+"/hosts/"+hostID+"/restore", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) res, err := stdhttp.DefaultClient.Do(req) if err != nil { t.Fatalf("do: %v", err) } defer res.Body.Close() if res.StatusCode != stdhttp.StatusUnprocessableEntity { t.Fatalf("status: got %d, want 422", res.StatusCode) } // No restore command should arrive at the agent. ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel() for { mt, raw, rerr := c.Read(ctx) if rerr != nil { break } if mt == websocket.MessageText && strings.Contains(string(raw), `"command.run"`) && strings.Contains(string(raw), `"kind":"restore"`) { t.Fatal("unexpected restore command.run after wrong-hostname rejection") } } } // TestRestorePostHappyPathDispatches: well-formed new-directory form // dispatches a JobRestore command.run with the expected payload + writes // an audit row + redirects. func TestRestorePostHappyPathDispatches(t *testing.T) { t.Parallel() srv, ts, st := rawTestServerWithUI(t) hostID, token := enrolHostForUI(t, srv, st, "rstore-happy") sid := seedSnapshot(t, st, hostID, "rstore-happy") c := agentDial(t, srv, ts, hostID, token) sendHello(t, c, "rstore-happy") _ = drainUntil(t, c, api.MsgScheduleSet) cookie := loginAsAdmin(t, st) form := url.Values{ "snapshot_id": {sid}, "target_mode": {"new_dir"}, "paths": {"/etc/nginx/nginx.conf", "/etc/nginx/sites-available/alfa.conf"}, } req, _ := stdhttp.NewRequest("POST", ts.URL+"/hosts/"+hostID+"/restore", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("HX-Request", "true") req.AddCookie(cookie) // Don't follow redirects — we want to inspect the HX-Redirect header. client := &stdhttp.Client{ CheckRedirect: func(*stdhttp.Request, []*stdhttp.Request) error { return stdhttp.ErrUseLastResponse }, } res, err := client.Do(req) if err != nil { t.Fatalf("do: %v", err) } defer res.Body.Close() if res.StatusCode != stdhttp.StatusNoContent { t.Fatalf("status: got %d, want 204", res.StatusCode) } if res.Header.Get("HX-Redirect") == "" { t.Fatal("expected HX-Redirect header pointing at the live job page") } // Find the dispatched command.run on the agent socket. deadline := time.Now().Add(2 * time.Second) var got api.Envelope for time.Now().Before(deadline) { ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) mt, raw, rerr := c.Read(ctx) cancel() if rerr != nil { break } if mt != websocket.MessageText { continue } if !strings.Contains(string(raw), `"command.run"`) || !strings.Contains(string(raw), `"kind":"restore"`) { continue } if err := json.Unmarshal(raw, &got); err != nil { t.Fatalf("unmarshal: %v", err) } break } if got.Type != api.MsgCommandRun { t.Fatal("never received restore command.run") } var cp api.CommandRunPayload if err := got.UnmarshalPayload(&cp); err != nil { t.Fatalf("unmarshal payload: %v", err) } if cp.Kind != api.JobRestore { t.Fatalf("kind: got %q", cp.Kind) } if cp.Restore == nil { t.Fatal("restore payload is nil") } if cp.Restore.SnapshotID != sid { t.Fatalf("snapshot id: got %q want %q", cp.Restore.SnapshotID, sid) } if cp.Restore.InPlace { t.Fatal("expected new-directory mode (in_place=false)") } if !strings.HasPrefix(cp.Restore.TargetDir, "$HOME/rm-restore/") { t.Fatalf("target_dir: got %q, want prefix $HOME/rm-restore/", cp.Restore.TargetDir) } // placeholder substituted with the dispatched job_id. if !strings.Contains(cp.Restore.TargetDir, "/01") { t.Errorf("target_dir: expected job_id substituted into the path; got %q", cp.Restore.TargetDir) } if len(cp.Restore.Paths) != 2 { t.Fatalf("paths: got %d, want 2", len(cp.Restore.Paths)) } // Audit row. var n int if err := st.DB().QueryRow( `SELECT COUNT(*) FROM audit_log WHERE action = 'host.restore' AND target_id = ?`, hostID).Scan(&n); err != nil { t.Fatalf("audit count: %v", err) } if n != 1 { t.Fatalf("audit rows: got %d, want 1", n) } } // TestRestorePostOfflineHostRejected: agent not connected → 503 + // no command.run. func TestRestorePostOfflineHostRejected(t *testing.T) { t.Parallel() srv, ts, st := rawTestServerWithUI(t) hostID, _ := enrolHostForUI(t, srv, st, "rstore-offline") sid := seedSnapshot(t, st, hostID, "rstore-offline") cookie := loginAsAdmin(t, st) form := url.Values{ "snapshot_id": {sid}, "target_mode": {"new_dir"}, "paths": {"/etc/foo"}, } req, _ := stdhttp.NewRequest("POST", ts.URL+"/hosts/"+hostID+"/restore", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) res, err := stdhttp.DefaultClient.Do(req) if err != nil { t.Fatalf("do: %v", err) } defer res.Body.Close() if res.StatusCode != stdhttp.StatusServiceUnavailable { t.Fatalf("status: got %d, want 503", res.StatusCode) } _ = srv } // helpers -------------------------------------------------------------- func readBody(t *testing.T, body interface{ Read(p []byte) (int, error) }) string { t.Helper() buf := make([]byte, 0, 16*1024) tmp := make([]byte, 4096) for { n, err := body.Read(tmp) if n > 0 { buf = append(buf, tmp[:n]...) } if err != nil { break } } return string(buf) } func short(s string) string { if len(s) > 400 { return s[:400] + "…" } return s }