Files
restic-manager/internal/server/http/p2r01_test.go
T
steve b35f1736f7 server: populate audit UserID on credential mutations + slog prune push errors
Switch handleSetHostCredentials, handleSetAdminCredentials, and
handleDeleteAdminCredentials from authedUser (bool) to requireUser
(*store.User) so AuditEntry.UserID and Actor are populated correctly.
Add slog.Warn on the non-ErrNotFound pushAdminCredsToAgent path in
handleRunRepoPrune so decrypt/send failures surface in the server log
rather than appearing as a generic host_offline 503.
2026-05-04 10:19:15 +01:00

527 lines
15 KiB
Go

// p2r01_test.go — HTTP-level coverage for the slim-shape REST surface
// landed in P2R-01: schedules, source-groups, repo-maintenance, the
// per-source-group Run-now endpoint, schedule_push reconciliation,
// and auto-init at hello.
package http
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
stdhttp "net/http"
"strings"
"testing"
"time"
"github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
"gitea.dcglab.co.uk/steve/restic-manager/internal/auth"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
// loginAsAdmin creates an admin user + a session in the store and
// returns a cookie ready to attach to outgoing requests.
func loginAsAdmin(t *testing.T, st *store.Store) *stdhttp.Cookie {
t.Helper()
ctx := context.Background()
uid := ulid.Make().String()
hash, _ := auth.HashPassword("very-long-test-password")
if err := st.CreateUser(ctx, store.User{
ID: uid, Username: "tester-" + uid[:6],
PasswordHash: hash, Role: store.RoleAdmin,
CreatedAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("create user: %v", err)
}
tok, _ := auth.NewToken()
if err := st.CreateSession(ctx, store.Session{
UserID: uid,
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().Add(time.Hour).UTC(),
}, auth.HashToken(tok)); err != nil {
t.Fatalf("create session: %v", err)
}
return &stdhttp.Cookie{Name: sessionCookieName, Value: tok}
}
// loginAsAdminWithID is like loginAsAdmin but also returns the user ID.
// Use this when tests need to assert that the user ID was recorded
// (e.g. on audit entries).
func loginAsAdminWithID(t *testing.T, st *store.Store) (*stdhttp.Cookie, string) {
t.Helper()
ctx := context.Background()
uid := ulid.Make().String()
hash, _ := auth.HashPassword("very-long-test-password")
if err := st.CreateUser(ctx, store.User{
ID: uid, Username: "tester-" + uid[:6],
PasswordHash: hash, Role: store.RoleAdmin,
CreatedAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("create user: %v", err)
}
tok, _ := auth.NewToken()
if err := st.CreateSession(ctx, store.Session{
UserID: uid,
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().Add(time.Hour).UTC(),
}, auth.HashToken(tok)); err != nil {
t.Fatalf("create session: %v", err)
}
return &stdhttp.Cookie{Name: sessionCookieName, Value: tok}, uid
}
// makeHost inserts a minimal Host row directly via the store. Used by
// HTTP-level tests that don't want to go through the full enrollment
// path. Returns the host id.
func makeHost(t *testing.T, st *store.Store, name string) string {
t.Helper()
id := ulid.Make().String()
if err := st.CreateHost(context.Background(), store.Host{
ID: id, Name: name, OS: "linux", Arch: "amd64",
ProtocolVersion: api.CurrentProtocolVersion,
EnrolledAt: time.Now().UTC(),
}, "tokhash-"+id, ""); err != nil {
t.Fatalf("create host: %v", err)
}
return id
}
// doJSON issues a JSON request with the given method and body, returns
// status + decoded JSON map (nil on empty body).
func doJSON(t *testing.T, baseURL, method, path string, body any, cookie *stdhttp.Cookie) (int, map[string]any) {
t.Helper()
var rdr io.Reader
if body != nil {
raw, _ := json.Marshal(body)
rdr = bytes.NewReader(raw)
}
req, err := stdhttp.NewRequest(method, baseURL+path, rdr)
if err != nil {
t.Fatalf("new req: %v", err)
}
if rdr != nil {
req.Header.Set("Content-Type", "application/json")
}
if cookie != nil {
req.AddCookie(cookie)
}
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
if len(raw) == 0 {
return res.StatusCode, nil
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
// Non-JSON (HTMX action paths return plain text on error).
return res.StatusCode, map[string]any{"raw": string(raw)}
}
return res.StatusCode, out
}
// ----- source-groups ------------------------------------------------
func TestSourceGroupsCRUD(t *testing.T) {
t.Parallel()
_, url, st := newTestServerWithHub(t)
cookie := loginAsAdmin(t, st)
hostID := makeHost(t, st, "sg-host")
// Empty list at start.
status, body := doJSON(t, url, "GET", "/api/hosts/"+hostID+"/source-groups", nil, cookie)
if status != 200 {
t.Fatalf("list status: %d", status)
}
if got := body["source_groups"].([]any); len(got) != 0 {
t.Fatalf("expected empty list, got %d", len(got))
}
// Create.
status, body = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/source-groups",
map[string]any{
"name": "etc",
"includes": []string{"/etc"},
"excludes": []string{"/etc/shadow"},
"retention_policy": map[string]int{
"keep_daily": 7,
},
"retry_max": 3,
"retry_backoff_seconds": 60,
}, cookie)
if status != 201 {
t.Fatalf("create status: %d, body: %+v", status, body)
}
gid, _ := body["id"].(string)
if gid == "" {
t.Fatalf("create: no id returned: %+v", body)
}
// Duplicate name → 409.
status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/source-groups",
map[string]any{"name": "etc", "includes": []string{"/x"}}, cookie)
if status != 409 {
t.Errorf("duplicate name: want 409, got %d", status)
}
// Update — rename + add another include.
status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/source-groups/"+gid,
map[string]any{
"name": "system",
"includes": []string{"/etc", "/var/log"},
"retention_policy": map[string]int{
"keep_daily": 14,
"keep_weekly": 4,
},
}, cookie)
if status != 200 {
t.Fatalf("update status: %d, body: %+v", status, body)
}
if got := body["name"]; got != "system" {
t.Errorf("rename: got %v want system", got)
}
// Delete.
status, _ = doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/source-groups/"+gid, nil, cookie)
if status != 204 {
t.Errorf("delete status: %d", status)
}
// Already gone.
status, _ = doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/source-groups/"+gid, nil, cookie)
if status != 404 {
t.Errorf("delete-after-delete: want 404, got %d", status)
}
}
func TestSourceGroupDeleteRefusesIfInUse(t *testing.T) {
t.Parallel()
_, url, st := newTestServerWithHub(t)
cookie := loginAsAdmin(t, st)
hostID := makeHost(t, st, "sg-inuse-host")
// Create a group via the store directly.
gid := ulid.Make().String()
if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{
ID: gid, HostID: hostID, Name: "default", Includes: []string{"/home"},
}); err != nil {
t.Fatalf("create group: %v", err)
}
// Attach a schedule.
sid := ulid.Make().String()
if err := st.CreateSchedule(context.Background(), &store.Schedule{
ID: sid, HostID: hostID,
CronExpr: "0 3 * * *", Enabled: true,
SourceGroupIDs: []string{gid},
}); err != nil {
t.Fatalf("create schedule: %v", err)
}
status, body := doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/source-groups/"+gid, nil, cookie)
if status != 409 {
t.Fatalf("want 409, got %d body=%+v", status, body)
}
if body["code"] != "group_in_use" {
t.Errorf("wrong code: %+v", body)
}
}
// ----- schedules ----------------------------------------------------
func TestSchedulesCRUDValidation(t *testing.T) {
t.Parallel()
_, url, st := newTestServerWithHub(t)
cookie := loginAsAdmin(t, st)
hostID := makeHost(t, st, "sched-host")
// Bad cron → 400.
status, body := doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
map[string]any{
"cron": "not-a-cron", "enabled": true,
"source_group_ids": []string{"x"},
}, cookie)
if status != 400 {
t.Fatalf("bad cron: want 400, got %d (body=%+v)", status, body)
}
// Missing groups → 400.
status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
map[string]any{
"cron": "0 3 * * *", "enabled": true,
"source_group_ids": []string{},
}, cookie)
if status != 400 {
t.Errorf("missing groups: want 400, got %d", status)
}
// Group not on host → 400.
status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
map[string]any{
"cron": "0 3 * * *", "enabled": true,
"source_group_ids": []string{"non-existent"},
}, cookie)
if status != 400 {
t.Errorf("bogus group: want 400, got %d", status)
}
// Create a real group.
gid := ulid.Make().String()
if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{
ID: gid, HostID: hostID, Name: "default", Includes: []string{"/etc"},
}); err != nil {
t.Fatalf("group: %v", err)
}
// Happy create.
status, body = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
map[string]any{
"cron": "0 3 * * *", "enabled": true,
"source_group_ids": []string{gid},
}, cookie)
if status != 201 {
t.Fatalf("create: %d body=%+v", status, body)
}
sid, _ := body["id"].(string)
if sid == "" {
t.Fatalf("no id: %+v", body)
}
// List.
status, body = doJSON(t, url, "GET", "/api/hosts/"+hostID+"/schedules", nil, cookie)
if status != 200 {
t.Fatalf("list: %d", status)
}
rows, _ := body["schedules"].([]any)
if len(rows) != 1 {
t.Fatalf("expected 1 schedule, got %d", len(rows))
}
// Update — change cron, keep group.
status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/schedules/"+sid,
map[string]any{
"cron": "@hourly", "enabled": false,
"source_group_ids": []string{gid},
}, cookie)
if status != 200 {
t.Fatalf("update: %d body=%+v", status, body)
}
if body["cron"] != "@hourly" || body["enabled"] != false {
t.Errorf("update fields: %+v", body)
}
// Delete.
status, _ = doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/schedules/"+sid, nil, cookie)
if status != 204 {
t.Errorf("delete: %d", status)
}
}
// ----- repo-maintenance --------------------------------------------
func TestRepoMaintenanceGetSeedsAndPutValidates(t *testing.T) {
t.Parallel()
_, url, st := newTestServerWithHub(t)
cookie := loginAsAdmin(t, st)
hostID := makeHost(t, st, "maint-host")
// GET on a host that hasn't had the row seeded auto-creates one.
status, body := doJSON(t, url, "GET", "/api/hosts/"+hostID+"/repo-maintenance", nil, cookie)
if status != 200 {
t.Fatalf("get: %d body=%+v", status, body)
}
if body["host_id"] != hostID {
t.Errorf("host_id mismatch: %+v", body)
}
// PUT with bad cron.
status, _ = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/repo-maintenance",
map[string]any{
"forget_cron": "junk", "prune_cron": "@weekly",
"check_cron": "@monthly", "check_subset_pct": 10,
}, cookie)
if status != 400 {
t.Errorf("bad cron: want 400, got %d", status)
}
// PUT with subset out of range.
status, _ = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/repo-maintenance",
map[string]any{
"forget_cron": "@daily", "prune_cron": "@weekly",
"check_cron": "@monthly", "check_subset_pct": 200,
}, cookie)
if status != 400 {
t.Errorf("bad subset: want 400, got %d", status)
}
// Happy PUT.
status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/repo-maintenance",
map[string]any{
"forget_cron": "@daily",
"forget_enabled": true,
"prune_cron": "@weekly",
"prune_enabled": true,
"check_cron": "@monthly",
"check_enabled": false,
"check_subset_pct": 25,
}, cookie)
if status != 200 {
t.Fatalf("happy put: %d body=%+v", status, body)
}
if body["forget_cron"] != "@daily" || body["check_subset_pct"] != float64(25) {
t.Errorf("fields: %+v", body)
}
}
// ----- 410 Gone on retired routes ----------------------------------
func TestPerHostRunBackupReturns410(t *testing.T) {
t.Parallel()
_, url, st := newTestServerWithHub(t)
cookie := loginAsAdmin(t, st)
hostID := makeHost(t, st, "gone-host")
req, _ := stdhttp.NewRequest("POST", url+"/hosts/"+hostID+"/run-backup", nil)
req.AddCookie(cookie)
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusGone {
t.Errorf("want 410, got %d", res.StatusCode)
}
}
// ----- schedule_push payload ---------------------------------------
func TestBuildScheduleSetPayload(t *testing.T) {
t.Parallel()
srv, _, st := newTestServerWithHub(t)
hostID := makeHost(t, st, "push-host")
gid := ulid.Make().String()
keepDaily := 7
if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{
ID: gid, HostID: hostID, Name: "default",
Includes: []string{"/etc", "/home"},
Excludes: []string{"/etc/shadow"},
RetentionPolicy: store.RetentionPolicy{KeepDaily: &keepDaily},
RetryMax: 2, RetryBackoffSeconds: 30,
}); err != nil {
t.Fatalf("group: %v", err)
}
sid := ulid.Make().String()
if err := st.CreateSchedule(context.Background(), &store.Schedule{
ID: sid, HostID: hostID,
CronExpr: "0 3 * * *", Enabled: true,
SourceGroupIDs: []string{gid},
}); err != nil {
t.Fatalf("schedule: %v", err)
}
payload, err := srv.buildScheduleSetPayload(context.Background(), hostID)
if err != nil {
t.Fatalf("build: %v", err)
}
if payload.Version == 0 {
t.Fatalf("version should be > 0, got %d", payload.Version)
}
if len(payload.Schedules) != 1 {
t.Fatalf("schedules: %d", len(payload.Schedules))
}
entry := payload.Schedules[0]
if entry.ID != sid || entry.CronExpr != "0 3 * * *" || !entry.Enabled {
t.Errorf("schedule fields: %+v", entry)
}
if len(entry.SourceGroups) != 1 {
t.Fatalf("groups in schedule: %d", len(entry.SourceGroups))
}
g := entry.SourceGroups[0]
if g.Name != "default" {
t.Errorf("group name: %s", g.Name)
}
if !equalStrings(g.Includes, []string{"/etc", "/home"}) {
t.Errorf("includes: %v", g.Includes)
}
var rp map[string]any
_ = json.Unmarshal(g.RetentionPolicy, &rp)
if rp["keep_daily"] != float64(7) {
t.Errorf("retention: %+v", rp)
}
}
// ----- per-source-group Run-now -----------------------------------
func TestRunSourceGroupOfflineHost(t *testing.T) {
t.Parallel()
_, url, st := newTestServerWithHub(t)
cookie := loginAsAdmin(t, st)
hostID := makeHost(t, st, "offline-host")
gid := ulid.Make().String()
if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{
ID: gid, HostID: hostID, Name: "default", Includes: []string{"/etc"},
}); err != nil {
t.Fatalf("group: %v", err)
}
// JSON path → 503 (host offline).
req, _ := stdhttp.NewRequest("POST",
url+"/hosts/"+hostID+"/source-groups/"+gid+"/run", nil)
req.AddCookie(cookie)
req.Header.Set("Accept", "application/json")
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusServiceUnavailable {
t.Errorf("offline: want 503, got %d", res.StatusCode)
}
}
func TestRunSourceGroupUnknownGroup(t *testing.T) {
t.Parallel()
_, url, st := newTestServerWithHub(t)
cookie := loginAsAdmin(t, st)
hostID := makeHost(t, st, "noh-host")
req, _ := stdhttp.NewRequest("POST",
url+"/hosts/"+hostID+"/source-groups/no-such-gid/run", nil)
req.AddCookie(cookie)
req.Header.Set("Accept", "application/json")
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusNotFound {
t.Errorf("unknown group: want 404, got %d", res.StatusCode)
}
}
// ----- helpers ----------------------------------------------------
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// keep fmt import live — used for occasional debug.
var (
_ = fmt.Sprintf
_ = strings.HasPrefix
)