aa9fc330fc
The `schedules` table was already laid down in migration 0001; this
slice adds the Go-side data model, store CRUD with atomic version
bumps, and REST endpoints.
* `store.Schedule` + `RetentionPolicy` + `ScheduleOptions` typed
views (the wire form on the agent side keeps retention/options
as raw JSON since the agent just forwards them to restic).
* Store CRUD: CreateSchedule / GetSchedule / ListSchedulesByHost /
UpdateSchedule / DeleteSchedule. Each mutation bumps
`host_schedule_version` atomically in the same tx via UPSERT on
`host_schedule_version`. SetHostAppliedScheduleVersion records
what the agent has confirmed via schedule.ack (P2-02 will use it).
* REST endpoints under /api/hosts/{id}/schedules + /{sid}:
GET (list, with the version envelope so callers can detect
drift), POST (create), PUT (update — kind is immutable), DELETE.
* Validation: cron expressions parse via robfig/cron/v3 (same
parser the agent will use, so anything that validates here will
fire there); kind ∈ {backup, forget, prune, check} (init/unlock
are operator-only one-shot kinds, not schedulable); backup
schedules require ≥1 path; hooks rejected on non-backup kinds
(spec §14.3).
* All mutations audit-logged.
* Tests: store-level CRUD + version-bump invariants; REST happy
path (create→list→update→delete with version progression); REST
validation table covers each rejection code.
newTestServerWithHub now sets BootstrapToken so the schedules
handler tests can use the existing login flow without a parallel
test-server constructor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
191 lines
5.8 KiB
Go
191 lines
5.8 KiB
Go
package http
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
stdhttp "net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
|
)
|
|
|
|
// loginAndCookie bootstraps an admin, logs in, and returns the
|
|
// session cookie ready to attach to subsequent requests.
|
|
func loginAndCookie(t *testing.T, url string) *stdhttp.Cookie {
|
|
t.Helper()
|
|
bs, _ := json.Marshal(bootstrapRequest{
|
|
Token: "test-token", Username: "alice", Password: "averylongpassword",
|
|
})
|
|
res, err := stdhttp.Post(url+"/api/bootstrap", "application/json", bytes.NewReader(bs))
|
|
if err != nil {
|
|
t.Fatalf("bootstrap: %v", err)
|
|
}
|
|
res.Body.Close()
|
|
|
|
body, _ := json.Marshal(loginRequest{Username: "alice", Password: "averylongpassword"})
|
|
res, err = stdhttp.Post(url+"/api/auth/login", "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("login: %v", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != stdhttp.StatusOK {
|
|
got, _ := io.ReadAll(res.Body)
|
|
t.Fatalf("login: %d %s", res.StatusCode, got)
|
|
}
|
|
for _, c := range res.Cookies() {
|
|
if c.Name == sessionCookieName {
|
|
return c
|
|
}
|
|
}
|
|
t.Fatal("no session cookie")
|
|
return nil
|
|
}
|
|
|
|
// makeHTTPHost inserts a host directly via the store so we can hit
|
|
// the schedule endpoints without dragging in the enrollment flow.
|
|
func makeHTTPHost(t *testing.T, st *store.Store) string {
|
|
t.Helper()
|
|
const id = "01HSCHEDHTTP000000000000Z"
|
|
if err := st.CreateHost(context.Background(), store.Host{
|
|
ID: id, Name: "h", OS: "linux", Arch: "amd64",
|
|
AgentVersion: "dev", ResticVersion: "0.16.0", ProtocolVersion: 1,
|
|
EnrolledAt: time.Now().UTC(),
|
|
}, "tokenhash", ""); err != nil {
|
|
t.Fatalf("create host: %v", err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func TestSchedulesAPIHappyPath(t *testing.T) {
|
|
t.Parallel()
|
|
_, url, st := newTestServerWithHub(t)
|
|
cookie := loginAndCookie(t, url)
|
|
hostID := makeHTTPHost(t, st)
|
|
|
|
doReq := func(method, path string, body any, want int) []byte {
|
|
t.Helper()
|
|
var b []byte
|
|
if body != nil {
|
|
b, _ = json.Marshal(body)
|
|
}
|
|
req, _ := stdhttp.NewRequest(method, url+path, bytes.NewReader(b))
|
|
req.AddCookie(cookie)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
res, err := stdhttp.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("%s %s: %v", method, path, err)
|
|
}
|
|
defer res.Body.Close()
|
|
got, _ := io.ReadAll(res.Body)
|
|
if res.StatusCode != want {
|
|
t.Fatalf("%s %s: status %d (want %d) body=%s", method, path, res.StatusCode, want, got)
|
|
}
|
|
return got
|
|
}
|
|
|
|
// Empty list returns version 0.
|
|
body := doReq("GET", "/api/hosts/"+hostID+"/schedules", nil, stdhttp.StatusOK)
|
|
var listed listSchedulesResp
|
|
if err := json.Unmarshal(body, &listed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if listed.Version != 0 || len(listed.Schedules) != 0 {
|
|
t.Fatalf("initial list: %+v", listed)
|
|
}
|
|
|
|
// Create.
|
|
keepLast := 3
|
|
create := scheduleAPI{
|
|
Kind: "backup", CronExpr: "0 */6 * * *",
|
|
Paths: []string{"/etc"},
|
|
Tags: []string{"nightly"},
|
|
RetentionPolicy: store.RetentionPolicy{KeepLast: &keepLast},
|
|
Enabled: true,
|
|
}
|
|
body = doReq("POST", "/api/hosts/"+hostID+"/schedules", create, stdhttp.StatusCreated)
|
|
var created scheduleAPI
|
|
if err := json.Unmarshal(body, &created); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if created.ID == "" || created.CronExpr != create.CronExpr {
|
|
t.Fatalf("create returned: %+v", created)
|
|
}
|
|
|
|
// Version bumped.
|
|
body = doReq("GET", "/api/hosts/"+hostID+"/schedules", nil, stdhttp.StatusOK)
|
|
_ = json.Unmarshal(body, &listed)
|
|
if listed.Version != 1 {
|
|
t.Fatalf("version after create: %d", listed.Version)
|
|
}
|
|
|
|
// Update changes the cron expr; kind silently preserved even if request tries otherwise.
|
|
created.CronExpr = "*/15 * * * *"
|
|
created.Kind = "prune" // should be ignored
|
|
body = doReq("PUT", "/api/hosts/"+hostID+"/schedules/"+created.ID, created, stdhttp.StatusOK)
|
|
var updated scheduleAPI
|
|
_ = json.Unmarshal(body, &updated)
|
|
if updated.Kind != "backup" || updated.CronExpr != "*/15 * * * *" {
|
|
t.Fatalf("update: %+v", updated)
|
|
}
|
|
|
|
// Delete.
|
|
doReq("DELETE", "/api/hosts/"+hostID+"/schedules/"+created.ID, nil, stdhttp.StatusNoContent)
|
|
body = doReq("GET", "/api/hosts/"+hostID+"/schedules", nil, stdhttp.StatusOK)
|
|
_ = json.Unmarshal(body, &listed)
|
|
if listed.Version != 3 || len(listed.Schedules) != 0 {
|
|
t.Fatalf("after delete: %+v", listed)
|
|
}
|
|
}
|
|
|
|
func TestSchedulesAPIValidation(t *testing.T) {
|
|
t.Parallel()
|
|
_, url, st := newTestServerWithHub(t)
|
|
cookie := loginAndCookie(t, url)
|
|
hostID := makeHTTPHost(t, st)
|
|
|
|
post := func(s scheduleAPI) (int, []byte) {
|
|
b, _ := json.Marshal(s)
|
|
req, _ := stdhttp.NewRequest("POST",
|
|
url+"/api/hosts/"+hostID+"/schedules", bytes.NewReader(b))
|
|
req.AddCookie(cookie)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
res, err := stdhttp.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("post: %v", err)
|
|
}
|
|
defer res.Body.Close()
|
|
body, _ := io.ReadAll(res.Body)
|
|
return res.StatusCode, body
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
in scheduleAPI
|
|
want string // expected error code
|
|
}{
|
|
{"bad kind", scheduleAPI{Kind: "init", CronExpr: "@hourly", Paths: []string{"/etc"}}, "invalid_kind"},
|
|
{"missing cron", scheduleAPI{Kind: "backup", Paths: []string{"/etc"}}, "missing_cron_expr"},
|
|
{"bad cron", scheduleAPI{Kind: "backup", CronExpr: "not a cron", Paths: []string{"/etc"}}, "invalid_cron_expr"},
|
|
{"backup without paths", scheduleAPI{Kind: "backup", CronExpr: "@hourly"}, "missing_paths"},
|
|
{"hooks on non-backup", scheduleAPI{Kind: "prune", CronExpr: "@daily", PreHook: "echo hi"}, "hooks_not_allowed"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
status, body := post(c.in)
|
|
if status != stdhttp.StatusBadRequest {
|
|
t.Fatalf("status %d body=%s", status, body)
|
|
}
|
|
var env struct {
|
|
Code string `json:"code"`
|
|
}
|
|
_ = json.Unmarshal(body, &env)
|
|
if env.Code != c.want {
|
|
t.Fatalf("error code: got %q want %q (body=%s)", env.Code, c.want, body)
|
|
}
|
|
})
|
|
}
|
|
}
|