package server import ( "bytes" "context" "encoding/json" "io" "net/http" "net/http/cookiejar" "net/http/httptest" "net/url" "path/filepath" "testing" "testing/fstest" "time" "pve-confsync/internal/demo" "pve-confsync/internal/store" ) type client struct { t *testing.T url string c *http.Client } func newServer(t *testing.T) string { t.Helper() ctx := context.Background() dir := t.TempDir() st, err := store.Open(filepath.Join(dir, "t.db"), bytes.Repeat([]byte{7}, 32)) if err != nil { t.Fatal(err) } clusters, err := demo.Start(ctx, st) if err != nil { t.Fatal(err) } srv, err := New(Options{Store: st, Version: "test", Demo: true, Static: fstest.MapFS{"index.html": {Data: []byte("")}, "login.html": {Data: []byte("")}}, Auth: AuthConfig{Local: true, SessionTTL: time.Hour, DataDir: dir}}) if err != nil { t.Fatal(err) } hs := httptest.NewServer(srv.Handler()) t.Cleanup(func() { hs.Close(); srv.Wait(); clusters.Close(); st.Close() }) return hs.URL } func login(t *testing.T, base, user string) *client { jar, _ := cookiejar.New(nil) c := &client{t: t, url: base, c: &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} r, err := c.c.PostForm(base+"/auth/login", url.Values{"user": {user}, "pass": {demo.Password}}) if err != nil { t.Fatal(err) } r.Body.Close() if loc := r.Header.Get("Location"); r.StatusCode != http.StatusSeeOther || loc != "/" { t.Fatalf("login %s: %d %s", user, r.StatusCode, loc) } return c } func (c *client) do(method, path string, body any, csrf bool) (int, map[string]any) { c.t.Helper() var rd io.Reader if body != nil { b, _ := json.Marshal(body) rd = bytes.NewReader(b) } req, _ := http.NewRequest(method, c.url+path, rd) req.Header.Set("Content-Type", "application/json") if csrf { req.Header.Set("X-Confsync", "1") } r, err := c.c.Do(req) if err != nil { c.t.Fatal(err) } defer r.Body.Close() var out map[string]any _ = json.NewDecoder(r.Body).Decode(&out) return r.StatusCode, out } func digests(d map[string]any) map[string]string { out := map[string]string{} for _, p := range d["plans"].([]any) { p := p.(map[string]any) out[p["client"].(string)] = p["digest"].(string) } return out } func TestAuthAndRoles(t *testing.T) { base := newServer(t) anon := &client{t: t, url: base, c: http.DefaultClient} if code, _ := anon.do("GET", "/api/state", nil, false); code != http.StatusUnauthorized { t.Fatalf("anonymous state: %d", code) } viewer := login(t, base, "lecteur") if code, _ := viewer.do("GET", "/api/state", nil, false); code != http.StatusOK { t.Fatalf("viewer state: %d", code) } if code, _ := viewer.do("POST", "/api/dryrun", map[string]any{"scope": "global"}, true); code != http.StatusForbidden { t.Fatalf("viewer dry-run: %d", code) } op := login(t, base, "operateur") if code, _ := op.do("GET", "/api/audit", nil, false); code != http.StatusForbidden { t.Fatalf("operator audit: %d", code) } if code, d := op.do("POST", "/api/dryrun", map[string]any{"scope": "global"}, false); code != http.StatusForbidden || d["code"] != "csrf" { t.Fatalf("dry-run without header: %d %v", code, d) } code, d := op.do("GET", "/api/clusters", nil, false) if code != http.StatusOK { t.Fatalf("clusters: %d", code) } for _, c := range d["clusters"].([]any) { if s, ok := c.(map[string]any)["secret"]; ok && s != "" { t.Fatalf("secret returned: %v", c) } } } func TestDryRunAndApply(t *testing.T) { base := newServer(t) op := login(t, base, "operateur") // ceph needs the zone stor, which site-b cannot create (no bridge mapping) code, d := op.do("POST", "/api/dryrun", map[string]any{"scope": "object", "client": "site-b", "objects": []string{"sdn-vnet/ceph"}}, true) if code != http.StatusOK { t.Fatalf("dry-run site-b: %d %v", code, d) } p := d["plans"].([]any)[0].(map[string]any)["plan"].(map[string]any) if errs, _ := p["errors"].([]any); len(errs) == 0 { t.Fatalf("ceph without its zone must be blocked: %v", p) } code, d = op.do("POST", "/api/apply", map[string]any{"scope": "object", "client": "site-b", "objects": []string{"sdn-vnet/ceph"}, "digests": digests(d), "confirm": "site-b"}, true) if code != http.StatusConflict { t.Fatalf("blocked apply: %d %v", code, d) } sel := map[string]any{"scope": "client", "client": "site-a"} code, d = op.do("POST", "/api/dryrun", sel, true) if code != http.StatusOK { t.Fatalf("dry-run site-a: %d %v", code, d) } sel["digests"] = digests(d) if code, d := op.do("POST", "/api/apply", sel, true); code != http.StatusBadRequest || d["code"] != "confirm" { t.Fatalf("SDN apply without confirmation: %d %v", code, d) } stale := map[string]any{"scope": "client", "client": "site-a", "digests": map[string]string{"site-a": "0"}, "confirm": "site-a"} if code, d := op.do("POST", "/api/apply", stale, true); code != http.StatusConflict || d["code"] != "stale" { t.Fatalf("stale digest: %d %v", code, d) } sel["confirm"] = "site-a" code, d = op.do("POST", "/api/apply", sel, true) if code != http.StatusAccepted { t.Fatalf("apply: %d %v", code, d) } id := int(d["run"].(float64)) var run map[string]any for i := 0; i < 100; i++ { _, run = op.do("GET", "/api/runs/"+itoa(id), nil, false) if run["status"] != "running" { break } time.Sleep(50 * time.Millisecond) } if run["status"] != "ok" { t.Fatalf("run: %v", run) } _, d = op.do("GET", "/api/state?refresh=1", nil, false) for _, c := range d["state"].(map[string]any)["clients"].([]any) { c := c.(map[string]any) if c["name"] == "site-a" { if ch, _ := c["plan"].(map[string]any)["changes"].([]any); len(ch) != 0 { t.Fatalf("site-a still differs after the apply: %v", ch) } } } admin := login(t, base, "admin") _, d = admin.do("GET", "/api/audit", nil, false) if len(d["events"].([]any)) == 0 { t.Fatal("empty audit trail") } } func itoa(i int) string { b, _ := json.Marshal(i); return string(b) }