138 lines
4.9 KiB
Go
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)
|
|
}
|
|
}
|