a781e95c94
Three small follow-ups from review:
1. Restore target is now operator-editable. Default value is the
literal '\$HOME/rm-restore/<job-id>/' (agent expands \$HOME at
run time using os.UserHomeDir(); also handles \${HOME} and ~/
prefixes). Operator can replace with any absolute path.
- ui_restore.go validates the input is either absolute or starts
with one of the recognised prefixes; other env-var refs (\$PATH
etc.) are deliberately rejected so operator paths can't pick up
arbitrary agent env values.
- host_restore.html replaces the read-only mono-text display with
a real <input>; help text spells out that \$HOME resolves
agent-side and <job-id> is substituted on dispatch.
- install.sh + the systemd unit prep /root/rm-restore so the
default works under the sandbox: ReadWritePaths gains a soft
'-/root/rm-restore' entry (the '-' makes the bind-mount soft-fail
if missing, but install.sh pre-creates it root-owned 0700).
2. --no-ownership flag now gated on restic version. The flag was
added in restic 0.17 and 0.16 rejects it. Previously dropped it
wholesale — that meant new-dir restores silently preserved
ownership against design intent on 0.17+. Now the agent threads
its detected restic version (sysinfo already collects it) through
runner.Config -> restic.Env, and RunRestore appends --no-ownership
only when AtLeastVersion(0, 17) returns true. 0.16 hosts still
restore with original uid/gid; help text in the wizard explicitly
notes this. The previous 'Original ownership is preserved' copy
was wrong for new-dir mode and is corrected.
3. golangci-lint misspell locale switched US -> UK and the codebase
swept (73 corrections, mostly behaviour/serialise/recognise/honour).
Wire-format ErrorCode 'unauthorized' -> 'unauthorised' is a tiny
contract change but the agent doesn't parse those codes today and
no external API consumers exist yet. Tests passed before + after.
Tests:
- internal/restic/version_test.go covers Env.AtLeastVersion across
edge cases (empty, exact match, patch above, minor below, non-
numeric) and expandHome on \$HOME / \${HOME} / ~/, plus
pass-through for absolute paths and refusal of other env vars.
- ui_restore_test updated: TargetDir now starts '\$HOME/rm-restore/'
with the job_id substituted into the placeholder.
Live verified on the smoke env: default target restored to
/root/rm-restore/<job-id>/ as the agent's expanded \$HOME (2 files,
14 bytes); custom override '/tmp/custom-restore/<job-id>/' restored
into the agent's PrivateTmp namespace (1 file, 6 bytes); both jobs
'succeeded', exit 0.
381 lines
12 KiB
Go
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, "$HOME/rm-restore/") {
|
|
t.Fatalf("target_dir: got %q, want prefix $HOME/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
|
|
}
|