// 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 )