package beach import ( "encoding/json" "io" "net/http" "net/http/cookiejar" "net/http/httptest" "path/filepath" "strings" "testing" ) type client struct { t *testing.T c *http.Client u string } func newClient(t *testing.T) (*client, *Store) { st, err := OpenStore(filepath.Join(t.TempDir(), "beach.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) ts := httptest.NewServer(NewServer(st)) t.Cleanup(ts.Close) jar, _ := cookiejar.New(nil) return &client{t, &http.Client{Jar: jar}, ts.URL}, st } func (c *client) do(method, path, body string, hdr bool) (int, map[string]any) { req, _ := http.NewRequest(method, c.u+path, strings.NewReader(body)) if hdr { req.Header.Set("X-Beach", "1") } resp, err := c.c.Do(req) if err != nil { c.t.Fatal(err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) var m map[string]any json.Unmarshal(b, &m) return resp.StatusCode, m } func TestAuthFlow(t *testing.T) { c, _ := newClient(t) if code, _ := c.do("GET", "/api/overview", "", false); code != 401 { t.Fatalf("overview without a session: %d", code) } if code, _ := c.do("POST", "/api/setup", `{"name":"a","password":"longenough"}`, false); code != 403 { t.Fatalf("writes without X-Beach must be refused, got %d", code) } if code, _ := c.do("POST", "/api/setup", `{"name":"a","password":"short"}`, true); code != 400 { t.Fatalf("short password: %d", code) } if _, m := c.do("GET", "/api/session", "", false); m["setup"] != true { t.Fatalf("fresh install should ask for setup: %v", m) } if code, _ := c.do("POST", "/api/setup", `{"name":"alex","password":"longenough"}`, true); code != 200 { t.Fatalf("setup: %d", code) } if code, _ := c.do("POST", "/api/setup", `{"name":"eve","password":"longenough"}`, true); code != 409 { t.Fatalf("second setup must be refused, got %d", code) } if code, m := c.do("GET", "/api/overview", "", false); code != 200 || m["host"] == nil { t.Fatalf("overview after setup: %d %v", code, m) } c.do("POST", "/api/logout", "", true) if code, _ := c.do("GET", "/api/overview", "", false); code != 401 { t.Fatal("logout should end the session") } if code, _ := c.do("POST", "/api/login", `{"name":"alex","password":"wrongwrong"}`, true); code != 401 { t.Fatalf("bad password: %d", code) } if code, _ := c.do("POST", "/api/login", `{"name":"alex","password":"longenough"}`, true); code != 200 { t.Fatalf("login: %d", code) } } func TestLoginRateLimit(t *testing.T) { c, _ := newClient(t) c.do("POST", "/api/setup", `{"name":"alex","password":"longenough"}`, true) c.do("POST", "/api/logout", "", true) var last int for i := 0; i < 9; i++ { last, _ = c.do("POST", "/api/login", `{"name":"alex","password":"nope-nope"}`, true) } if last != 429 { t.Fatalf("expected lockout after repeated failures, got %d", last) } if code, _ := c.do("POST", "/api/login", `{"name":"alex","password":"longenough"}`, true); code != 429 { t.Fatalf("lockout should hold even for the right password, got %d", code) } } func TestSettingsKeepSecrets(t *testing.T) { c, st := newClient(t) c.do("POST", "/api/setup", `{"name":"alex","password":"longenough"}`, true) body := `{"dockerHost":"","truenas":{"url":"https://nas","apiKey":"k1"},"hammerhead":{"url":"https://hh","org":"o","apiKey":"k2"},"refreshSeconds":5}` code, m := c.do("PUT", "/api/settings", body, true) if code != 200 || m["truenasKeySet"] != true || m["hammerheadKeySet"] != true { t.Fatalf("%d %v", code, m) } if tn := m["truenas"].(map[string]any); tn["apiKey"] != "" { t.Fatal("API key must never be sent back to the browser") } // saving again with blank key boxes keeps the stored keys c.do("PUT", "/api/settings", `{"dockerHost":"","truenas":{"url":"https://nas","apiKey":""},"hammerhead":{"url":"https://hh","org":"o","apiKey":""},"refreshSeconds":5}`, true) if s := st.Settings(); s.TrueNAS.APIKey != "k1" || s.Hammerhead.APIKey != "k2" { t.Fatalf("keys lost: %+v", s) } if code, _ := c.do("PUT", "/api/settings", `{"truenas":{"url":"ftp://x"}}`, true); code != 400 { t.Fatalf("bad URL scheme accepted: %d", code) } } func TestOverviewSectionsFollowSettings(t *testing.T) { c, _ := newClient(t) c.do("POST", "/api/setup", `{"name":"alex","password":"longenough"}`, true) c.do("PUT", "/api/settings", `{"dockerHost":"","refreshSeconds":10}`, true) _, m := c.do("GET", "/api/overview", "", false) if m["docker"] != nil || m["storage"] != nil || m["tunnels"] != nil { t.Fatalf("unconfigured sections must be absent: %v", m) } c.do("PUT", "/api/settings", `{"dockerHost":"127.0.0.1:1","refreshSeconds":10}`, true) _, m = c.do("GET", "/api/overview", "", false) d, _ := m["docker"].(map[string]any) if d == nil || d["error"] == nil { t.Fatalf("unreachable Docker should show an error, not break the page: %v", m) } if code, _ := c.do("POST", "/api/containers/..%2Fx/start", "", true); code != 400 && code != 404 { t.Fatalf("path tricks in container id: %d", code) } }