Files
Beach/internal/beach/server_test.go
T
2026-10-27 16:57:53 -06:00

138 lines
4.9 KiB
Go

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