package server import ( "html/template" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "testing" "armdash/internal/config" "armdash/internal/system" ) // newStoreServer is a fresh install: a data directory and no login. func newStoreServer(t *testing.T) (*Server, string) { t.Helper() t.Setenv("AD_CORE_AUTH_USER", "") t.Setenv("AD_CORE_AUTH_PASSWORD_HASH", "") cfg, err := config.Load() if err != nil { t.Fatal(err) } path := filepath.Join(t.TempDir(), "settings.json") if err := cfg.UseStore(path); err != nil { t.Fatal(err) } s, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)), nil) if err != nil { t.Fatal(err) } return s, path } func post(target string, form url.Values) *http.Request { r := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") return r } func setUp(t *testing.T, s *Server) *http.Cookie { t.Helper() rec := do(s, post("/settings/setup", url.Values{"user": {"owner"}, "password": {"correct horse"}, "confirm": {"correct horse"}})) if rec.Code != http.StatusSeeOther { t.Fatalf("setup = %d: %s", rec.Code, rec.Body.String()) } return sessionFrom(t, rec) } func TestFreshInstallAsksForTheLogin(t *testing.T) { s, path := newStoreServer(t) if rec := serve(s, http.MethodGet, "/"); rec.Header().Get("Location") != "/settings" { t.Errorf("/ before setup goes to %q, want /settings", rec.Header().Get("Location")) } if rec := serve(s, http.MethodGet, "/metrics"); rec.Code != http.StatusOK { t.Errorf("/metrics before setup = %d", rec.Code) } body := serve(s, http.MethodGet, "/settings").Body.String() if !strings.Contains(body, `action="/settings/setup"`) || strings.Contains(body, `action="/settings/core"`) { t.Error("settings before setup show more than the login form") } for name, form := range map[string]url.Values{ "mismatch": {"user": {"owner"}, "password": {"correct horse"}, "confirm": {"correct horsf"}}, "short": {"user": {"owner"}, "password": {"short"}, "confirm": {"short"}}, "no name": {"user": {" "}, "password": {"correct horse"}, "confirm": {"correct horse"}}, } { if rec := do(s, post("/settings/setup", form)); rec.Code != http.StatusUnprocessableEntity { t.Errorf("%s: setup = %d, want 422", name, rec.Code) } } if _, err := os.Stat(path); err == nil { t.Fatal("a refused setup wrote the settings file") } c := setUp(t, s) if rec := do(s, httptest.NewRequest(http.MethodGet, "/settings", nil), c); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `action="/settings/core"`) { t.Errorf("the new owner is not logged in straight away (%d)", rec.Code) } if rec := do(s, post("/settings/setup", url.Values{"user": {"thief"}, "password": {"another one"}, "confirm": {"another one"}})); rec.Code != http.StatusForbidden { t.Errorf("a second setup = %d, want 403", rec.Code) } if rec := do(s, loginRequest("owner", "correct horse", "/")); rec.Code != http.StatusSeeOther { t.Errorf("logging in with the new login = %d", rec.Code) } b, _ := os.ReadFile(path) if strings.Contains(string(b), "correct horse") || !strings.Contains(string(b), "pbkdf2-sha256") { t.Errorf("the stored login is not a hash: %s", b) } } func TestSavedSettingApplyAtOnce(t *testing.T) { s, path := newStoreServer(t) c := setUp(t, s) rec := do(s, post("/settings/core", url.Values{prometheusURLKey: {"http://prom:9090"}}), c) if rec.Code != http.StatusSeeOther { t.Fatalf("save = %d: %s", rec.Code, rec.Body.String()) } if got := s.PromURL(); got != "http://prom:9090" { t.Errorf("Prometheus URL %q after saving", got) } b, _ := os.ReadFile(path) if !strings.Contains(string(b), "http://prom:9090") { t.Error("the value was not written") } rec = do(s, post("/settings/core", url.Values{prometheusURLKey: {"prom:9090"}}), c) if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "is-danger") { t.Errorf("a bad URL = %d, want the form back with the error", rec.Code) } if got := s.PromURL(); got != "http://prom:9090" { t.Errorf("a refused value was applied: %q", got) } if rec := do(s, post("/settings/core", url.Values{prometheusURLKey: {"http://x"}})); rec.Code != http.StatusUnauthorized { t.Errorf("saving without a session = %d, want 401", rec.Code) } } func TestLinksAreEditedOnThePage(t *testing.T) { s, _ := newStoreServer(t) c := setUp(t, s) form := url.Values{"rows": {"1"}, "new.0": {"1"}, "id.0": {""}, "title.0": {"My Wiki"}, "url.0": {"http://127.0.0.1:8081"}, "mode.0": {"proxy"}} if rec := do(s, post("/settings/links", form), c); rec.Code != http.StatusSeeOther { t.Fatalf("adding a link = %d: %s", rec.Code, rec.Body.String()) } if rec := do(s, httptest.NewRequest(http.MethodGet, "/x/mywiki/", nil), c); rec.Code != http.StatusBadGateway { t.Errorf("the new proxy link = %d, want it mounted (502 from the dead upstream)", rec.Code) } if rec := do(s, httptest.NewRequest(http.MethodGet, "/settings", nil), c); rec.Code != http.StatusOK { t.Errorf("the session did not survive the rebuild: %d", rec.Code) } bad := url.Values{"rows": {"1"}, "new.0": {"1"}, "title.0": {"Bad"}, "url.0": {"ftp://x"}, "mode.0": {"frame"}} if rec := do(s, post("/settings/links", bad), c); rec.Code != http.StatusUnprocessableEntity { t.Errorf("a link that does not build = %d, want 422", rec.Code) } if rec := do(s, httptest.NewRequest(http.MethodGet, "/x/mywiki/", nil), c); rec.Code != http.StatusBadGateway { t.Error("a failed save replaced the working links") } if got := s.cfg.Get("links"); got != "mywiki" { t.Errorf("links = %q after the failed save", got) } remove := url.Values{"rows": {"1"}, "id.0": {"mywiki"}, "title.0": {"My Wiki"}, "url.0": {"http://127.0.0.1:8081"}, "mode.0": {"proxy"}, "remove": {"0"}} if rec := do(s, post("/settings/links", remove), c); rec.Code != http.StatusSeeOther { t.Fatalf("removing = %d", rec.Code) } if s.cfg.Get("link.mywiki.url") != "" || s.cfg.Get("links") != "" { t.Error("the removed link left settings behind") } } type fakeSystem struct{} func (fakeSystem) ID() string { return "box" } func (fakeSystem) Title() string { return "Box" } func (fakeSystem) Nav() []system.NavItem { return []system.NavItem{{Slug: "a", Title: "A"}} } func (fakeSystem) ConfigSchema() []system.ConfigField { return []system.ConfigField{ {Key: "url", Kind: system.KindURL}, {Key: "password", Kind: system.KindPassword, Secret: true}, {Key: "flag", Kind: system.KindBool}, } } func (fakeSystem) Render(string, *http.Request) (template.HTML, error) { return "", nil } func (fakeSystem) Register(*http.ServeMux, string, system.Deps) {} func TestMovingTheAddressDropsTheStoredSecret(t *testing.T) { s, _ := newStoreServer(t) c := setUp(t, s) save := func(form url.Values) { t.Helper() s.tree().systems = []system.System{fakeSystem{}} if rec := do(s, post("/settings/system/box", form), c); rec.Code != http.StatusSeeOther { t.Fatalf("save = %d: %s", rec.Code, rec.Body.String()) } } save(url.Values{"system.box.url": {"http://box"}, "system.box.password": {"secret"}, "system.box.enabled": {"1"}}) save(url.Values{"system.box.url": {"http://box"}, "system.box.password": {""}, "system.box.enabled": {"1"}}) if got := s.cfg.Get("system.box.password"); got != "secret" { t.Fatalf("an empty password field dropped the password: %q", got) } save(url.Values{"system.box.url": {"http://elsewhere"}, "system.box.enabled": {"1"}}) if got := s.cfg.Get("system.box.password"); got != "" { t.Errorf("the password followed the address to another host") } save(url.Values{"system.box.url": {"http://elsewhere"}}) if s.cfg.Enabled("box") { t.Error("an unticked box did not hide the system") } } func TestPasswordChangeNeedsTheCurrentOne(t *testing.T) { s, _ := newStoreServer(t) c := setUp(t, s) other := sessionFrom(t, do(s, loginRequest("owner", "correct horse", "/"))) wrong := url.Values{"user": {"owner"}, "current": {"guess"}, "password": {"new password"}, "confirm": {"new password"}} if rec := do(s, post("/settings/login", wrong), c); rec.Code != http.StatusUnprocessableEntity { t.Errorf("a wrong current password = %d, want 422", rec.Code) } right := url.Values{"user": {"owner"}, "current": {"correct horse"}, "password": {"new password"}, "confirm": {"new password"}} if rec := do(s, post("/settings/login", right), c); rec.Code != http.StatusSeeOther { t.Fatalf("change = %d: %s", rec.Code, rec.Body.String()) } if rec := do(s, httptest.NewRequest(http.MethodGet, "/settings", nil), c); rec.Code != http.StatusOK { t.Error("the change logged out the browser that made it") } if rec := do(s, httptest.NewRequest(http.MethodGet, "/settings", nil), other); rec.Code != http.StatusFound { t.Error("the other browser is still logged in") } if rec := do(s, loginRequest("owner", "new password", "/")); rec.Code != http.StatusSeeOther { t.Error("the new password does not work") } } // A value from the environment wins, so the page may not change it. func TestEnvironmentLocksAField(t *testing.T) { s, _ := newStoreServer(t) c := setUp(t, s) t.Setenv("AD_CORE_PROMETHEUS_URL", "http://env:9090") cfg, _ := config.Load() _ = cfg.UseStore(s.cfg.StorePath()) s.cfg = cfg body := do(s, httptest.NewRequest(http.MethodGet, "/settings", nil), c).Body.String() if !strings.Contains(body, "set in environment") { t.Error("the locked field does not say where it is set") } do(s, post("/settings/core", url.Values{prometheusURLKey: {"http://page:9090"}}), c) if got := s.PromURL(); got != "http://env:9090" { t.Errorf("the page overrode the environment: %q", got) } } func TestCheckboxFieldsAndSystemSaves(t *testing.T) { s, _ := newStoreServer(t) c := setUp(t, s) save := func(form url.Values) { t.Helper() s.tree().systems = []system.System{fakeSystem{}} if rec := do(s, post("/settings/system/box", form), c); rec.Code != http.StatusSeeOther { t.Fatalf("save = %d: %s", rec.Code, rec.Body.String()) } } save(url.Values{"system.box.enabled": {"1"}, "system.box.flag": {"1"}}) if !s.cfg.Bool("system.box.flag") { t.Fatal("a ticked box was not saved") } save(url.Values{"system.box.enabled": {"1"}}) if s.cfg.Has("system.box.flag") { t.Error("an unticked box stayed on") } if err := s.saveScoped("system.box.")(map[string]string{"flag": "1"}); err != nil { t.Fatal(err) } if !s.cfg.Bool("system.box.flag") || s.cfg.Has("flag") { t.Error("a system's own save left its namespace") } }