Files
restic-manager/internal/server/http/ui_repo_test.go
T
steve edce90d196 ui: hx-swap none on Run-now + truthful save banner + tailwind rebuild
Add hx-swap="none" to the three Run-now buttons (check/prune/unlock) in
host_repo.html to match the existing pattern on host_sources.html and
host_schedules.html. Fix all-blank admin-credentials save to redirect
without ?saved= query string so no false-positive banner is shown;
strengthen the corresponding test to assert Location has no ?saved=.
Rebuild CSS bundle via Tailwind to pick up max-w-[640px] JIT class.
2026-05-04 10:15:18 +01:00

401 lines
13 KiB
Go

// 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 <span> 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, loc := 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)
}
// All-blank is a no-op: redirect must not carry ?saved= banner.
if strings.Contains(loc, "?saved=") {
t.Errorf("blank save: redirect Location %q must not contain ?saved=", loc)
}
// 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")
}
}