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