From ccccc6aa339d24b61cebc0ecbbc52c6ac79832ba Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 3 May 2026 23:18:16 +0100 Subject: [PATCH] =?UTF-8?q?ui:=20Slice=20E=20=E2=80=94=20admin=20creds=20f?= =?UTF-8?q?orm=20+=20run-now=20buttons=20+=20repo=20health=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hostRepoPage gains AdminURL/AdminUsername/HasAdminPassword, Online, and StatsView (pre-dereferenced projection of host_repo_stats). - loadHostRepoPage loads the admin slot (tolerating ErrNotFound), hub.Connected, and stats (tolerating ErrNotFound). - renderRepoPage gains an adminErr parameter; all callers updated. - handleUIAdminCredentialsSave / handleUIAdminCredentialsDelete added (form-POST handlers mirroring the repo-creds pattern, with audit). - Routes /hosts/{id}/admin-credentials POST and /delete POST registered. - Template: Admin credentials form after Connection, Run-now HTMX buttons after Maintenance, Repo health stats panel in right rail. - Tests: 9 new tests covering rendering, disabled states, save/delete round-trips, audit rows, and idempotent delete. --- internal/server/http/server.go | 3 + internal/server/http/ui_repo.go | 263 +++++++++++++++++- internal/server/http/ui_repo_test.go | 396 +++++++++++++++++++++++++++ web/templates/pages/host_repo.html | 114 ++++++++ 4 files changed, 761 insertions(+), 15 deletions(-) create mode 100644 internal/server/http/ui_repo_test.go diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 5a34699..51d3774 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -220,6 +220,9 @@ func (s *Server) routes(r chi.Router) { r.Post("/hosts/{id}/repo/credentials", s.handleUIRepoCredentialsSave) r.Post("/hosts/{id}/repo/bandwidth", s.handleUIRepoBandwidthSave) r.Post("/hosts/{id}/repo/maintenance", s.handleUIRepoMaintenanceSave) + // Admin credentials form (separate slot for prune-capable user). + r.Post("/hosts/{id}/admin-credentials", s.handleUIAdminCredentialsSave) + r.Post("/hosts/{id}/admin-credentials/delete", s.handleUIAdminCredentialsDelete) // Schedules tab + create/edit/delete forms. r.Get("/hosts/{id}/schedules", s.handleUISchedulesList) r.Get("/hosts/{id}/schedules/new", s.handleUIScheduleNewGet) diff --git a/internal/server/http/ui_repo.go b/internal/server/http/ui_repo.go index a8c9136..9e46ead 100644 --- a/internal/server/http/ui_repo.go +++ b/internal/server/http/ui_repo.go @@ -7,6 +7,9 @@ import ( stdhttp "net/http" "strconv" "strings" + "time" + + "github.com/oklog/ulid/v2" "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" @@ -17,10 +20,31 @@ import ( // the page into three independent forms so saving one section // doesn't disturb the others. // -// GET /hosts/{id}/repo — render -// POST /hosts/{id}/repo/credentials — connection -// POST /hosts/{id}/repo/bandwidth — host-wide bw caps -// POST /hosts/{id}/repo/maintenance — forget/prune/check cadences +// GET /hosts/{id}/repo — render +// POST /hosts/{id}/repo/credentials — connection +// POST /hosts/{id}/repo/bandwidth — host-wide bw caps +// POST /hosts/{id}/repo/maintenance — forget/prune/check cadences +// POST /hosts/{id}/admin-credentials — admin (prune) creds +// POST /hosts/{id}/admin-credentials/delete — clear admin creds + +// repoStatsView is a flat, pre-dereferenced projection of +// store.HostRepoStats for use in templates. Nil pointer fields are +// collapsed to zero/false and accompanied by a Has* sentinel so the +// template can distinguish "zero" from "not yet known." +type repoStatsView struct { + HasTotalSize bool + TotalSizeBytes int64 + HasRawSize bool + RawSizeBytes int64 + HasLastCheck bool + LastCheckAt time.Time + LastCheckAgo string + LastCheckStatus string + LockPresent bool + HasLastPrune bool + LastPruneAt time.Time + LastPruneAgo string +} type hostRepoPage struct { hostChromeData @@ -30,6 +54,11 @@ type hostRepoPage struct { RepoUsername string HasPassword bool + // Admin credentials (optional, prune-only — separate slot). + AdminURL string + AdminUsername string + HasAdminPassword bool + // Bandwidth (form values, blank means "no cap") BandwidthUp string BandwidthDown string @@ -37,6 +66,14 @@ type hostRepoPage struct { // Maintenance row Maintenance store.HostRepoMaintenance + // Online mirrors Hub.Connected so Run-now button disabled state is + // accurate at render time. + Online bool + + // StatsView is a pre-dereferenced projection of host_repo_stats. + // Nil when no row exists yet (fresh hosts). + StatsView *repoStatsView + // Snapshots-by-tag — map[group_name]count, plus an "untagged" row. SnapshotsByTag map[string]int UntaggedSnapshots int @@ -44,6 +81,7 @@ type hostRepoPage struct { // Inline form-error banners. Empty when no error for that section. CredentialsError string + AdminCredsError string BandwidthError string MaintenanceError string @@ -79,6 +117,60 @@ func (s *Server) loadHostRepoPage(r *stdhttp.Request, host store.Host) (*hostRep return nil, err } + // Admin credentials (optional — prune-only slot). + adminEnc, aerr := s.deps.Store.GetHostCredentials(r.Context(), host.ID, store.CredKindAdmin) + switch { + case aerr == nil: + plain, derr := s.deps.AEAD.Decrypt(adminEnc, []byte("host:"+host.ID+":admin")) + if derr == nil { + var blob repoCredsBlob + if jerr := json.Unmarshal(plain, &blob); jerr == nil { + p.AdminURL = blob.RepoURL + p.AdminUsername = blob.RepoUsername + p.HasAdminPassword = blob.RepoPassword != "" + } + } + case errors.Is(aerr, store.ErrNotFound): + // admin slot not configured — fine + default: + return nil, aerr + } + + // Online status. + if s.deps.Hub != nil { + p.Online = s.deps.Hub.Connected(host.ID) + } + + // Repo stats (tolerate ErrNotFound — fresh hosts have no row yet). + if stats, serr := s.deps.Store.GetHostRepoStats(r.Context(), host.ID); serr == nil { + sv := &repoStatsView{} + if stats.TotalSizeBytes != nil { + sv.HasTotalSize = true + sv.TotalSizeBytes = *stats.TotalSizeBytes + } + if stats.RawSizeBytes != nil { + sv.HasRawSize = true + sv.RawSizeBytes = *stats.RawSizeBytes + } + if stats.LastCheckAt != nil { + sv.HasLastCheck = true + sv.LastCheckAt = *stats.LastCheckAt + sv.LastCheckAgo = relTimeAgo(*stats.LastCheckAt) + } + sv.LastCheckStatus = stats.LastCheckStatus + if stats.LockPresent != nil { + sv.LockPresent = *stats.LockPresent + } + if stats.LastPruneAt != nil { + sv.HasLastPrune = true + sv.LastPruneAt = *stats.LastPruneAt + sv.LastPruneAgo = relTimeAgo(*stats.LastPruneAt) + } + p.StatsView = sv + } else if !errors.Is(serr, store.ErrNotFound) { + return nil, serr + } + // Bandwidth. if host.BandwidthUpKBps != nil { p.BandwidthUp = strconv.Itoa(*host.BandwidthUpKBps) @@ -152,11 +244,11 @@ func (s *Server) handleUIHostRepo(w stdhttp.ResponseWriter, r *stdhttp.Request) } } -// renderRepoFormError loads the page state, overlays the section's -// error banner, and renders with a 422. Save-success goes through a -// 303 redirect with `?saved=
` instead, so this path is for -// validation failures only. -func (s *Server) renderRepoPage(w stdhttp.ResponseWriter, r *stdhttp.Request, u *ui.User, host *store.Host, credErr, bwErr, mntErr string) { +// renderRepoPage loads the page state, overlays section error banners, +// and renders with a 422. Save-success goes through a 303 redirect +// with `?saved=
` instead, so this path is for validation +// failures only. +func (s *Server) renderRepoPage(w stdhttp.ResponseWriter, r *stdhttp.Request, u *ui.User, host *store.Host, credErr, adminErr, bwErr, mntErr string) { page, err := s.loadHostRepoPage(r, *host) if err != nil { slog.Error("ui repo: reload after save", "host_id", host.ID, "err", err) @@ -164,6 +256,7 @@ func (s *Server) renderRepoPage(w stdhttp.ResponseWriter, r *stdhttp.Request, u return } page.CredentialsError = credErr + page.AdminCredsError = adminErr page.BandwidthError = bwErr page.MaintenanceError = mntErr view := s.baseView(u) @@ -198,7 +291,7 @@ func (s *Server) handleUIRepoCredentialsSave(w stdhttp.ResponseWriter, r *stdhtt repoPass := r.PostForm.Get("repo_password") // do NOT trim — operators may use trailing space deliberately if repoURL == "" { - s.renderRepoPage(w, r, u, host, "Repo URL is required.", "", "") + s.renderRepoPage(w, r, u, host, "Repo URL is required.", "", "", "") return } @@ -217,7 +310,7 @@ func (s *Server) handleUIRepoCredentialsSave(w stdhttp.ResponseWriter, r *stdhtt if existing.RepoPassword == "" { s.renderRepoPage(w, r, u, host, "No password on file yet — set one before saving the URL/username.", - "", "") + "", "", "") return } @@ -256,7 +349,7 @@ func (s *Server) handleUIRepoBandwidthSave(w stdhttp.ResponseWriter, r *stdhttp. up, upErr := parseOptionalNonNegInt(r.PostForm.Get("bandwidth_up")) down, downErr := parseOptionalNonNegInt(r.PostForm.Get("bandwidth_down")) if upErr != nil || downErr != nil { - s.renderRepoPage(w, r, u, host, "", + s.renderRepoPage(w, r, u, host, "", "", "Bandwidth caps must be non-negative whole numbers (or blank for no cap).", "") return @@ -294,19 +387,19 @@ func (s *Server) handleUIRepoMaintenanceSave(w stdhttp.ResponseWriter, r *stdhtt "forget": forgetCron, "prune": pruneCron, "check": checkCron, } { if expr == "" { - s.renderRepoPage(w, r, u, host, "", "", + s.renderRepoPage(w, r, u, host, "", "", "", label+" cadence is required.") return } if _, err := cronParser.Parse(expr); err != nil { - s.renderRepoPage(w, r, u, host, "", "", + s.renderRepoPage(w, r, u, host, "", "", "", label+" cadence didn't parse: "+err.Error()) return } } subset, err := strconv.Atoi(subsetStr) if err != nil || subset < 0 || subset > 100 { - s.renderRepoPage(w, r, u, host, "", "", + s.renderRepoPage(w, r, u, host, "", "", "", "check subset % must be between 0 and 100.") return } @@ -348,3 +441,143 @@ func parseOptionalNonNegInt(s string) (*int, error) { } return &n, nil } + +// relTimeAgo returns a short human-readable relative-time string like +// "5m ago", "3h ago", "2d ago" for use in stats panels. Does not use +// the template funcMap so it can be called from Go directly. +func relTimeAgo(t time.Time) string { + d := time.Since(t) + if d < 0 { + d = 0 + } + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return strconv.Itoa(int(d.Minutes())) + "m ago" + case d < 24*time.Hour: + return strconv.Itoa(int(d.Hours())) + "h ago" + case d < 30*24*time.Hour: + return strconv.Itoa(int(d.Hours()/24)) + "d ago" + default: + return t.Format("2006-01-02") + } +} + +// handleUIAdminCredentialsSave handles the HTML form POST to +// /hosts/{id}/admin-credentials. Mirrors handleUIRepoCredentialsSave +// but operates on the admin slot (store.CredKindAdmin, AAD "host::admin"). +// Re-renders the page with an inline error on validation failure; +// redirects with ?saved=admin_credentials on success. +func (s *Server) handleUIAdminCredentialsSave(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) + return + } + repoURL := strings.TrimSpace(r.PostForm.Get("repo_url")) + repoUser := strings.TrimSpace(r.PostForm.Get("repo_username")) + repoPass := r.PostForm.Get("repo_password") + + // All blank → no-op save (operator hit Save without filling anything). + // We treat this as harmless — they may have wanted to clear via the + // Clear button instead. Only validate if they've started filling fields. + if repoURL == "" && repoUser == "" && repoPass == "" { + stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=admin_credentials", stdhttp.StatusSeeOther) + return + } + + aad := []byte("host:" + host.ID + ":admin") + + // Merge with the existing admin row, if any. + existing := repoCredsBlob{} + if cur, err := s.deps.Store.GetHostCredentials(r.Context(), host.ID, store.CredKindAdmin); err == nil { + if plain, derr := s.deps.AEAD.Decrypt(cur, aad); derr == nil { + _ = json.Unmarshal(plain, &existing) + } + } + existing.RepoURL = repoURL + existing.RepoUsername = repoUser + if repoPass != "" { + existing.RepoPassword = repoPass + } + + if existing.RepoURL == "" { + s.renderRepoPage(w, r, u, host, "", "Repo URL is required.", "", "") + return + } + if existing.RepoPassword == "" { + s.renderRepoPage(w, r, u, host, "", + "No password on file yet — set one before saving the URL/username.", + "", "") + return + } + + enc, err := s.encryptRepoCreds(existing, aad) + if err != nil { + slog.Error("ui admin creds: encrypt", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if err := s.deps.Store.SetHostCredentials(r.Context(), host.ID, store.CredKindAdmin, enc); err != nil { + slog.Error("ui admin creds: persist", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), + UserID: &u.ID, + Actor: "user", + Action: "host.admin_credentials_set", + TargetKind: ptr("host"), + TargetID: &host.ID, + TS: nowUTC(), + }) + if s.deps.Hub != nil && s.deps.Hub.Connected(host.ID) { + if perr := s.pushAdminCredsToAgent(r.Context(), host.ID); perr != nil { + slog.Warn("ui admin creds: push to agent", "host_id", host.ID, "err", perr) + } + } + stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=admin_credentials", stdhttp.StatusSeeOther) +} + +// handleUIAdminCredentialsDelete handles the HTML form POST to +// /hosts/{id}/admin-credentials/delete. Removes the admin slot and +// redirects back to the repo page. Treats "not found" as success +// (idempotent delete from the operator's point of view). +func (s *Server) handleUIAdminCredentialsDelete(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + + err := s.deps.Store.DeleteHostCredentials(r.Context(), host.ID, store.CredKindAdmin) + if err != nil && !errors.Is(err, store.ErrNotFound) { + slog.Error("ui admin creds: delete", "host_id", host.ID, "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if err == nil { + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), + UserID: &u.ID, + Actor: "user", + Action: "host.admin_credentials_deleted", + TargetKind: ptr("host"), + TargetID: &host.ID, + TS: nowUTC(), + }) + } + stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=admin_credentials", stdhttp.StatusSeeOther) +} diff --git a/internal/server/http/ui_repo_test.go b/internal/server/http/ui_repo_test.go new file mode 100644 index 0000000..a116312 --- /dev/null +++ b/internal/server/http/ui_repo_test.go @@ -0,0 +1,396 @@ +// ui_repo_test.go — integration tests for the Repo page HTML UI. +// Covers: admin-creds form rendering, stats panel, lock banner, +// run-now button disabled states, admin-creds form save/delete. +package http + +import ( + "context" + "io" + stdhttp "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + "time" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/crypto" + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/config" + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui" + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +// newTestServerWithUI creates a server that includes the UI renderer so +// HTML page tests can render and inspect the full template output. +func newTestServerWithUI(t *testing.T) (*Server, string, *store.Store) { + t.Helper() + dir := t.TempDir() + st, err := store.Open(context.Background(), filepath.Join(dir, "rm.db")) + if err != nil { + t.Fatalf("store: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + + keyPath := filepath.Join(dir, "secret.key") + _ = crypto.GenerateKeyFile(keyPath) + key, _ := crypto.LoadKeyFromFile(keyPath) + aead, _ := crypto.NewAEAD(key) + + renderer, err := ui.New() + if err != nil { + t.Fatalf("ui.New: %v", err) + } + + deps := Deps{ + Cfg: config.Config{Listen: ":0", DataDir: dir, SecretKeyFile: keyPath}, + Store: st, + AEAD: aead, + Hub: ws.NewHub(), + UI: renderer, + } + s := New(deps) + ts := httptest.NewServer(s.srv.Handler) + t.Cleanup(ts.Close) + return s, ts.URL, st +} + +// getRepoPage fetches /hosts/{id}/repo and returns the body string. +func getRepoPage(t *testing.T, baseURL, hostID string, cookie *stdhttp.Cookie) string { + t.Helper() + client := &stdhttp.Client{ + CheckRedirect: func(_ *stdhttp.Request, _ []*stdhttp.Request) error { + return stdhttp.ErrUseLastResponse + }, + } + req, err := stdhttp.NewRequest("GET", baseURL+"/hosts/"+hostID+"/repo", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.AddCookie(cookie) + res, err := client.Do(req) + if err != nil { + t.Fatalf("GET /hosts/%s/repo: %v", hostID, err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusOK { + t.Fatalf("GET /hosts/%s/repo: want 200, got %d", hostID, res.StatusCode) + } + raw, _ := io.ReadAll(res.Body) + return string(raw) +} + +// postForm posts URL-encoded form data to path, following no redirects, +// and returns the status code and Location header. +func postForm(t *testing.T, baseURL, path string, data url.Values, cookie *stdhttp.Cookie) (int, string) { + t.Helper() + client := &stdhttp.Client{ + CheckRedirect: func(_ *stdhttp.Request, _ []*stdhttp.Request) error { + return stdhttp.ErrUseLastResponse + }, + } + req, err := stdhttp.NewRequest("POST", baseURL+path, strings.NewReader(data.Encode())) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if cookie != nil { + req.AddCookie(cookie) + } + res, err := client.Do(req) + if err != nil { + t.Fatalf("POST %s: %v", path, err) + } + defer res.Body.Close() + return res.StatusCode, res.Header.Get("Location") +} + +// ----- rendering tests ------------------------------------------------ + +// TestUIRepoPageRendersAdminCredsForm — visit /hosts/{id}/repo for a +// host with no admin creds. Assert the page contains the admin-creds +// section heading and the "not yet set" placeholder text. +func TestUIRepoPageRendersAdminCredsForm(t *testing.T) { + t.Parallel() + _, baseURL, st := newTestServerWithUI(t) + cookie := loginAsAdmin(t, st) + hostID := makeHost(t, st, "repo-page-admin-form") + + body := getRepoPage(t, baseURL, hostID, cookie) + + if !strings.Contains(body, "Admin credentials") { + t.Error("page missing 'Admin credentials' heading") + } + if !strings.Contains(body, "— not yet set —") { + t.Error("page missing '— not yet set —' placeholder for admin password") + } +} + +// TestUIRepoPageRendersStatsPanel — seed a host_repo_stats row, render +// the page, assert "Repo health" panel and the seeded values appear. +func TestUIRepoPageRendersStatsPanel(t *testing.T) { + t.Parallel() + _, baseURL, st := newTestServerWithUI(t) + cookie := loginAsAdmin(t, st) + hostID := makeHost(t, st, "repo-page-stats") + + totalSize := int64(5_000_000_000) // 5 GB + checkStatus := "ok" + checkAt := time.Now().Add(-2 * time.Hour).UTC() + if err := st.UpsertHostRepoStats(context.Background(), hostID, store.HostRepoStats{ + TotalSizeBytes: &totalSize, + LastCheckAt: &checkAt, + LastCheckStatus: checkStatus, + }); err != nil { + t.Fatalf("upsert stats: %v", err) + } + + body := getRepoPage(t, baseURL, hostID, cookie) + + if !strings.Contains(body, "Repo health") { + t.Error("page missing 'Repo health' heading") + } + // The bytes helper renders 5 GB as "5.0 GB" (with a unit suffix) + if !strings.Contains(body, "5.0") { + t.Error("page missing '5.0' (total size formatted bytes)") + } + if !strings.Contains(body, "ok") { + t.Error("page missing 'ok' check status") + } +} + +// TestUIRepoPageRendersLockBanner — seed stats with LockPresent=true, +// render, assert stale lock warning appears. +func TestUIRepoPageRendersLockBanner(t *testing.T) { + t.Parallel() + _, baseURL, st := newTestServerWithUI(t) + cookie := loginAsAdmin(t, st) + hostID := makeHost(t, st, "repo-page-lock") + + lockPresent := true + if err := st.UpsertHostRepoStats(context.Background(), hostID, store.HostRepoStats{ + LockPresent: &lockPresent, + }); err != nil { + t.Fatalf("upsert stats: %v", err) + } + + body := getRepoPage(t, baseURL, hostID, cookie) + + if !strings.Contains(body, "Stale lock detected") { + t.Error("page missing stale lock warning") + } +} + +// TestUIRepoRunNowButtonsDisabledWhenOffline — host not in the Hub +// (not connected), render, assert all three buttons carry disabled. +func TestUIRepoRunNowButtonsDisabledWhenOffline(t *testing.T) { + t.Parallel() + _, baseURL, st := newTestServerWithUI(t) + cookie := loginAsAdmin(t, st) + hostID := makeHost(t, st, "repo-page-offline") + + // No WS connection → Hub.Connected returns false. + body := getRepoPage(t, baseURL, hostID, cookie) + + // All three Run-now buttons should have disabled. + // Each button appears once in the template with class "btn btn-secondary" + // and hx-post attributes. The disabled attribute is added conditionally. + // Count occurrences of 'disabled' in the Run-now section. + runNowIdx := strings.Index(body, "Run now · one-time") + dangerIdx := strings.Index(body, "Danger zone") + if runNowIdx < 0 { + t.Fatal("page missing 'Run now · one-time' section") + } + if dangerIdx < 0 { + t.Fatal("page missing 'Danger zone' section") + } + runNowSection := body[runNowIdx:dangerIdx] + disabledCount := strings.Count(runNowSection, "disabled") + if disabledCount < 3 { + t.Errorf("expected at least 3 disabled attributes in Run-now section (one per button), got %d", disabledCount) + } +} + +// TestUIRepoPruneButtonDisabledWithoutAdminCreds — host is online but +// no admin creds set. Assert prune button has disabled and mentions +// "set admin credentials first". +func TestUIRepoPruneButtonDisabledWithoutAdminCreds(t *testing.T) { + t.Parallel() + srv, baseURL, st := newTestServerWithUI(t) + cookie := loginAsAdmin(t, st) + hostID := makeHost(t, st, "repo-page-prune-no-admin") + + // Register the host as "connected" in the Hub so the online check passes. + // We use a fake conn by injecting directly — for a simpler approach, + // rely on the fact that the Hub.Connected call just needs the ID registered. + // We can't easily fake a WS conn in a unit test, so instead we verify + // that even without the hub connected the prune button still has + // "set admin credentials first" text since that check runs first. + _ = srv // suppress unused warning + + body := getRepoPage(t, baseURL, hostID, cookie) + + if !strings.Contains(body, "set admin credentials first") { + t.Error("page missing 'set admin credentials first' on prune button") + } +} + +// ----- admin-creds form save/delete tests ---------------------------- + +// TestUIAdminCredentialsSaveRoundTrip — POST form-encoded body to +// /hosts/{id}/admin-credentials, follow redirect, assert page now shows +// "stored, leave blank to keep" placeholder. Audit row landed. +func TestUIAdminCredentialsSaveRoundTrip(t *testing.T) { + t.Parallel() + _, baseURL, st := newTestServerWithUI(t) + cookie, userID := loginAsAdminWithID(t, st) + hostID := makeHost(t, st, "admin-save-roundtrip") + + // POST admin credentials. + status, loc := postForm(t, baseURL, "/hosts/"+hostID+"/admin-credentials", url.Values{ + "repo_url": {"rest:http://admin.example/h"}, + "repo_username": {"admin-user"}, + "repo_password": {"s3cr3t-admin"}, + }, cookie) + if status != stdhttp.StatusSeeOther { + t.Fatalf("save: want 303, got %d", status) + } + if !strings.Contains(loc, "saved=admin_credentials") { + t.Errorf("redirect location should contain saved=admin_credentials, got %q", loc) + } + + // Follow redirect. + body := getRepoPage(t, baseURL, hostID, cookie) + if !strings.Contains(body, "stored, leave blank to keep") { + t.Error("after save: page missing 'stored, leave blank to keep' placeholder for admin password") + } + + // Audit row should exist. + ctx := context.Background() + rows, err := st.DB().QueryContext(ctx, + `SELECT action, user_id FROM audit_log WHERE target_id = ? AND action = 'host.admin_credentials_set'`, + hostID) + if err != nil { + t.Fatalf("query audit: %v", err) + } + defer rows.Close() + found := false + for rows.Next() { + var action string + var gotUID *string + if err := rows.Scan(&action, &gotUID); err != nil { + t.Fatalf("scan: %v", err) + } + found = true + if gotUID == nil || *gotUID != userID { + t.Errorf("audit row user_id: want %q, got %v", userID, gotUID) + } + } + if err := rows.Err(); err != nil { + t.Fatalf("rows.Err: %v", err) + } + if !found { + t.Error("audit row with action='host.admin_credentials_set' not found") + } +} + +// TestUIAdminCredentialsDelete — POST to the delete route, assert +// admin row gone and audit row landed. +func TestUIAdminCredentialsDelete(t *testing.T) { + t.Parallel() + srv, baseURL, st := newTestServerWithUI(t) + cookie, userID := loginAsAdminWithID(t, st) + hostID := makeHost(t, st, "admin-delete") + + ctx := context.Background() + + // Seed admin creds directly. + enc, err := srv.encryptRepoCreds(repoCredsBlob{ + RepoURL: "rest:http://admin.example/h", + RepoPassword: "pw", + }, []byte("host:"+hostID+":admin")) + if err != nil { + t.Fatalf("encrypt: %v", err) + } + if err := st.SetHostCredentials(ctx, hostID, store.CredKindAdmin, enc); err != nil { + t.Fatalf("set admin creds: %v", err) + } + + // POST to delete route. + status, loc := postForm(t, baseURL, "/hosts/"+hostID+"/admin-credentials/delete", url.Values{}, cookie) + if status != stdhttp.StatusSeeOther { + t.Fatalf("delete: want 303, got %d", status) + } + if !strings.Contains(loc, "saved=admin_credentials") { + t.Errorf("redirect location: want saved=admin_credentials, got %q", loc) + } + + // Admin row should be gone. + if _, err := st.GetHostCredentials(ctx, hostID, store.CredKindAdmin); err == nil { + t.Error("admin creds row still present after delete") + } + + // Audit row. + rows, err := st.DB().QueryContext(ctx, + `SELECT action, user_id FROM audit_log WHERE target_id = ? AND action = 'host.admin_credentials_deleted'`, + hostID) + if err != nil { + t.Fatalf("query audit: %v", err) + } + defer rows.Close() + found := false + for rows.Next() { + var action string + var gotUID *string + if err := rows.Scan(&action, &gotUID); err != nil { + t.Fatalf("scan: %v", err) + } + found = true + if gotUID == nil || *gotUID != userID { + t.Errorf("audit row user_id: want %q, got %v", userID, gotUID) + } + } + if err := rows.Err(); err != nil { + t.Fatalf("rows.Err: %v", err) + } + if !found { + t.Error("audit row with action='host.admin_credentials_deleted' not found") + } +} + +// TestUIAdminCredentialsDeleteIdempotent — POST to the delete route +// when no admin creds exist → 303 redirect (no 404 / 500). +func TestUIAdminCredentialsDeleteIdempotent(t *testing.T) { + t.Parallel() + _, baseURL, st := newTestServerWithUI(t) + cookie := loginAsAdmin(t, st) + hostID := makeHost(t, st, "admin-delete-noop") + + status, _ := postForm(t, baseURL, "/hosts/"+hostID+"/admin-credentials/delete", url.Values{}, cookie) + if status != stdhttp.StatusSeeOther { + t.Fatalf("delete (noop): want 303, got %d", status) + } +} + +// TestUIAdminCredentialsSaveAllBlankIsNoop — POST empty form → 303 +// redirect, no row created. +func TestUIAdminCredentialsSaveAllBlankIsNoop(t *testing.T) { + t.Parallel() + _, baseURL, st := newTestServerWithUI(t) + cookie := loginAsAdmin(t, st) + hostID := makeHost(t, st, "admin-save-blank") + + status, _ := postForm(t, baseURL, "/hosts/"+hostID+"/admin-credentials", url.Values{ + "repo_url": {""}, + "repo_username": {""}, + "repo_password": {""}, + }, cookie) + if status != stdhttp.StatusSeeOther { + t.Fatalf("blank save: want 303, got %d", status) + } + + // No admin row should have been created. + if _, err := st.GetHostCredentials(context.Background(), hostID, store.CredKindAdmin); err == nil { + t.Error("admin creds row created unexpectedly for blank save") + } +} diff --git a/web/templates/pages/host_repo.html b/web/templates/pages/host_repo.html index 5580e7f..c41ca17 100644 --- a/web/templates/pages/host_repo.html +++ b/web/templates/pages/host_repo.html @@ -42,6 +42,54 @@ + {{/* ---------- Admin credentials (optional) ---------- */}} +

+ Admin credentials · prune-only · optional +

+
+ {{if $page.AdminCredsError}} +
+ {{$page.AdminCredsError}} +
+ {{end}} + {{if eq $page.SavedSection "admin_credentials"}} +
✓ saved
+ {{end}} +

+ Only needed for rest-server repos that distinguish an append-only + user (everyday backups) from a delete-capable user (prune / + forget). For S3 / B2 / SFTP / local, leave this blank — the + everyday repo credentials handle prune too. +

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {{if $page.HasAdminPassword}} + + {{end}} +
+
+ {{if $page.HasAdminPassword}} +
+ {{end}} + {{/* ---------- Bandwidth ---------- */}}

Bandwidth · host-wide

@@ -138,6 +186,37 @@
+ {{/* ---------- Run now · one-time ---------- */}} +

Run now · one-time

+
+

+ Operator-triggered. Output streams live to the job log. Cadence-driven runs land independently from the server-side ticker. +

+
+ + + +
+
+ {{/* ---------- Danger zone ---------- */}}

Danger zone

+ {{/* ---------- Repo health ---------- */}} + {{if $page.StatsView}} + {{$s := $page.StatsView}} +

Repo health

+
+ {{if $s.LockPresent}} +
+ Stale lock detected on the most recent check. Run unlock above to clear it before the next backup. +
+ {{end}} +
+ {{if $s.HasTotalSize}} +
Total size
+
{{bytes $s.TotalSizeBytes}}
+ {{end}} + {{if $s.HasRawSize}} +
Raw size · pre-dedup
+
{{bytes $s.RawSizeBytes}}
+ {{end}} + {{if $s.HasLastCheck}} +
Last check
+
+ {{$s.LastCheckAgo}} + {{if $s.LastCheckStatus}} · {{$s.LastCheckStatus}}{{end}} +
+ {{end}} + {{if $s.HasLastPrune}} +
Last prune
+
{{$s.LastPruneAgo}}
+ {{end}} +
+
+ {{end}} + {{if gt (len $page.GroupNames) 0}}

Snapshots by source