77a8590e3a
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.
401 lines
13 KiB
Go
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")
|
|
}
|
|
}
|