318 lines
11 KiB
Go
318 lines
11 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"pve-confsync/internal/demo"
|
|
"pve-confsync/internal/pve/pvetest"
|
|
"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("<html>")}, "login.html": {Data: []byte("<html>")}},
|
|
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)["password"]; ok && s != "" {
|
|
t.Fatalf("password 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)
|
|
}
|
|
if b, _ := json.Marshal(d); strings.Contains(string(b), "HashOnlyForTheDemo") || !strings.Contains(string(b), "pve-password") {
|
|
t.Fatalf("password hashes must be planned but never shown: %s", b)
|
|
}
|
|
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")
|
|
}
|
|
}
|
|
|
|
// TestFromScratch: empty database, first administrator from the web, clusters
|
|
// added with their root password, certificate and SSH host key pinned by the
|
|
// first test, primary chosen, replication of the accounts to one target.
|
|
func TestFromScratch(t *testing.T) {
|
|
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)
|
|
}
|
|
srv, err := New(Options{Store: st, Version: "test",
|
|
Static: fstest.MapFS{"index.html": {Data: []byte("<html>")}, "login.html": {Data: []byte("<html>")}},
|
|
Auth: AuthConfig{Local: true, SessionTTL: time.Hour, DataDir: dir}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hs := httptest.NewServer(srv.Handler())
|
|
ref, tgt := pvetest.New("9.1.4"), pvetest.New("9.0.10")
|
|
t.Cleanup(func() { hs.Close(); srv.Wait(); ref.Close(); tgt.Close(); st.Close() })
|
|
ref.Groups["ops"] = map[string]any{"comment": "Ops"}
|
|
ref.Users["alice@pve"] = map[string]any{"enable": 1, "groups": "ops"}
|
|
ref.Shadow["alice"] = "$5$s$aliceSecretHash"
|
|
|
|
anon := &client{t: t, url: hs.URL, c: http.DefaultClient}
|
|
if _, d := anon.do("GET", "/auth/config", nil, false); d["setup"] != true {
|
|
t.Fatalf("setup expected: %v", d)
|
|
}
|
|
jar, _ := cookiejar.New(nil)
|
|
adm := &client{t: t, url: hs.URL, c: &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
|
|
r, err := adm.c.PostForm(hs.URL+"/auth/setup", url.Values{"user": {"cedric"}, "pass": {"a-long-password"}, "pass2": {"a-long-password"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.Body.Close()
|
|
if r.StatusCode != http.StatusSeeOther || r.Header.Get("Location") != "/#clusters" {
|
|
t.Fatalf("setup: %d %s", r.StatusCode, r.Header.Get("Location"))
|
|
}
|
|
if r, _ := anon.c.PostForm(hs.URL+"/auth/setup", url.Values{"user": {"intrus"}, "pass": {"a-long-password"}, "pass2": {"a-long-password"}}); r != nil {
|
|
r.Body.Close()
|
|
}
|
|
if n, _ := st.CountUsers(ctx); n != 1 {
|
|
t.Fatal("a second setup must be refused")
|
|
}
|
|
if _, d := adm.do("GET", "/api/state", nil, false); d["setup"] != "clusters" {
|
|
t.Fatalf("state on an empty database: %v", d)
|
|
}
|
|
// a primary alone: no target yet, the state lists none (not null); no port: 8006
|
|
if code, d := adm.do("PUT", "/api/clusters/-", map[string]any{"name": "solo", "role": "primary", "url": "https://pve-solo", "password": "x", "tls": "insecure"}, true); code != http.StatusOK {
|
|
t.Fatalf("save solo: %d %v", code, d)
|
|
}
|
|
if _, d := adm.do("GET", "/api/clusters", nil, false); d["clusters"].([]any)[0].(map[string]any)["url"] != "https://pve-solo:8006" {
|
|
t.Fatalf("default port expected: %v", d)
|
|
}
|
|
if _, d := adm.do("GET", "/api/state", nil, false); d["state"] == nil || d["state"].(map[string]any)["clients"] == nil {
|
|
t.Fatalf("clients must be an empty list: %v", d)
|
|
}
|
|
if code, d := adm.do("DELETE", "/api/clusters/solo", nil, true); code != http.StatusOK {
|
|
t.Fatalf("delete solo: %d %v", code, d)
|
|
}
|
|
for _, c := range []struct {
|
|
name string
|
|
s *pvetest.Server
|
|
}{{"ref", ref}, {"tgt", tgt}} {
|
|
_, port := c.s.SSHAddr()
|
|
code, d := adm.do("PUT", "/api/clusters/-", map[string]any{"name": c.name, "url": c.s.URL, "password": pvetest.Password,
|
|
"tls": "fp", "sshPort": port}, true)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("save %s: %d %v", c.name, code, d)
|
|
}
|
|
code, d = adm.do("POST", "/api/clusters/"+c.name+"/check", nil, true)
|
|
if code != http.StatusOK || d["ok"] != true || d["tlsFingerprint"] == "" || d["sshFingerprint"] == "" {
|
|
t.Fatalf("check %s: %d %v", c.name, code, d)
|
|
}
|
|
if code, d = adm.do("POST", "/api/clusters/"+c.name+"/check", nil, true); d["ok"] != true || d["sshFingerprint"] != nil {
|
|
t.Fatalf("second check %s (pinned): %d %v", c.name, code, d)
|
|
}
|
|
}
|
|
if _, d := adm.do("GET", "/api/state", nil, false); d["setup"] != "primary" {
|
|
t.Fatalf("state without primary: %v", d)
|
|
}
|
|
if code, d := adm.do("POST", "/api/clusters/ref/role", map[string]any{"role": "primary"}, true); code != http.StatusOK {
|
|
t.Fatalf("role: %d %v", code, d)
|
|
}
|
|
// accounts left out of the run ("do not replicate"): their passwords go with them
|
|
skipped := map[string]any{"scope": "global", "targets": []string{"tgt"}, "skip": []string{"pve-user", "pve-group", "pve-role"}}
|
|
if code, d := adm.do("POST", "/api/dryrun", skipped, true); code != http.StatusOK || strings.Contains(fmt.Sprint(d["plans"]), "pve-") {
|
|
t.Fatalf("dry-run without the accounts: %d %v", code, d)
|
|
}
|
|
sel := map[string]any{"scope": "global", "targets": []string{"tgt"}}
|
|
code, d := adm.do("POST", "/api/dryrun", sel, true)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("dry-run: %d %v", code, d)
|
|
}
|
|
sel["digests"] = digests(d)
|
|
if code, d = adm.do("POST", "/api/apply", sel, true); 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 = adm.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)
|
|
}
|
|
if tgt.Shadow["alice"] != "$5$s$aliceSecretHash" || tgt.Users["alice@pve"]["groups"] != "ops" {
|
|
t.Fatalf("target: %v %v", tgt.Shadow, tgt.Users)
|
|
}
|
|
if b, _ := json.Marshal(run); strings.Contains(string(b), "aliceSecretHash") {
|
|
t.Fatal("hash in the journal")
|
|
}
|
|
if ref.Calls != nil {
|
|
t.Fatalf("the primary must never be written: %v", ref.Calls)
|
|
}
|
|
}
|
|
|
|
func itoa(i int) string { b, _ := json.Marshal(i); return string(b) }
|