Files
restic-manager/internal/server/http/ui_restore_test.go
steve 02e4ef7544 testing: bootstrap UI, agent reliability, NS-01..04 + alert username
Smoothes the rough edges that came up exercising a live deployment.

First-run bootstrap UI: /bootstrap renders a username + password form
that uses the in-memory token directly (operator no longer copies it
out of the log); /login redirects there while bootstrap is available.

Agent reliability: failJob synthetic envelopes so command.run early
returns no longer hang the server-side job; runtime probe of restic
restore --help drives --no-ownership instead of version sniffing
(0.18.x had it removed). Server unit re-shaped: ProtectSystem=full
plus ReadWritePaths=/etc/restic-manager, no ProtectHome — restore
can now write anywhere a user might want.

Restore wizard: default target is /root/rm-restore/<job-id>/ with
clearer help text. Re-init confirm input uses .field (was .input,
which doesn't exist — text was invisible).

NS-01 host delete: store DeleteHost, admin-band /hosts/{id}/delete
with hostname-confirm danger zone, audit, FK cascade, live WS close.

NS-02 enrollment-token recovery: outstanding-tokens panel on
/hosts/new, regenerate (preserves attachments) and revoke handlers
+ audit, store-level ListOutstandingEnrollmentTokens and
DeleteEnrollmentToken.

NS-03 repo init / probe surface: migration 0020 adds
hosts.repo_status + repo_status_error; WS handler projects every
init job's outcome onto the host row (idempotent already-initialised
collapses to ready); creds-save resets status and dispatches a fresh
probe; /hosts/{id}/repo/probe retry endpoint with banner.

NS-04 dashboard live + sort + filter: query-string filter
(q/status/repo_status/tag/sort/dir), 5s htmx live poll mirroring the
alerts pattern with a localStorage live toggle, sortable column
headers, filter row + clear.

Alerts page: ack'd-by line resolves user_id ULID to username.

Compose.yaml ignored — host-specific.
2026-05-05 22:03:15 +01:00

381 lines
12 KiB
Go

// 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, "/root/rm-restore/") {
t.Fatalf("target_dir: got %q, want prefix /root/rm-restore/", cp.Restore.TargetDir)
}
// <job-id> 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
}