|
|
|
@@ -0,0 +1,223 @@
|
|
|
|
|
package server
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
"github.com/uopi/uopi/internal/access"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------- //
|
|
|
|
|
// accessMiddleware //
|
|
|
|
|
// -------------------------------------------------------------------------- //
|
|
|
|
|
|
|
|
|
|
// okHandler records that it ran and returns 200.
|
|
|
|
|
func okHandler(ran *bool) http.Handler {
|
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
*ran = true
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestAccessMiddlewareWriteUserPassesMutation(t *testing.T) {
|
|
|
|
|
// A configured policy with an operator (write) user.
|
|
|
|
|
policy := access.New("", []access.GroupSpec{
|
|
|
|
|
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleOperator}},
|
|
|
|
|
})
|
|
|
|
|
var ran bool
|
|
|
|
|
h := accessMiddleware(policy, testUserHeader, okHandler(&ran))
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/interfaces", nil)
|
|
|
|
|
req.Header.Set(testUserHeader, "alice")
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(rec, req)
|
|
|
|
|
|
|
|
|
|
if rec.Code != http.StatusOK || !ran {
|
|
|
|
|
t.Errorf("write user POST: code=%d ran=%v, want 200/true", rec.Code, ran)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestAccessMiddlewareReadUserBlockedFromMutation(t *testing.T) {
|
|
|
|
|
// Configured policy → an unlisted user is a read-only viewer.
|
|
|
|
|
policy := access.New("", []access.GroupSpec{
|
|
|
|
|
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleOperator}},
|
|
|
|
|
})
|
|
|
|
|
var ran bool
|
|
|
|
|
h := accessMiddleware(policy, testUserHeader, okHandler(&ran))
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/interfaces", nil)
|
|
|
|
|
req.Header.Set(testUserHeader, "bob") // viewer
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(rec, req)
|
|
|
|
|
|
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
|
|
|
t.Errorf("read-only POST: code=%d, want 403", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
if ran {
|
|
|
|
|
t.Error("handler must not run for a forbidden mutation")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestAccessMiddlewareReadUserAllowedToGet(t *testing.T) {
|
|
|
|
|
policy := access.New("", []access.GroupSpec{
|
|
|
|
|
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleOperator}},
|
|
|
|
|
})
|
|
|
|
|
var ran bool
|
|
|
|
|
h := accessMiddleware(policy, testUserHeader, okHandler(&ran))
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/interfaces", nil)
|
|
|
|
|
req.Header.Set(testUserHeader, "bob") // viewer
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(rec, req)
|
|
|
|
|
|
|
|
|
|
if rec.Code != http.StatusOK || !ran {
|
|
|
|
|
t.Errorf("read-only GET: code=%d ran=%v, want 200/true", rec.Code, ran)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestAccessMiddlewareMeAlwaysReachable(t *testing.T) {
|
|
|
|
|
policy := access.New("", []access.GroupSpec{
|
|
|
|
|
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleOperator}},
|
|
|
|
|
})
|
|
|
|
|
var ran bool
|
|
|
|
|
h := accessMiddleware(policy, testUserHeader, okHandler(&ran))
|
|
|
|
|
|
|
|
|
|
// Even a mutating method on /me is allowed through (identity discovery).
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/me", nil)
|
|
|
|
|
req.Header.Set(testUserHeader, "bob")
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(rec, req)
|
|
|
|
|
|
|
|
|
|
if rec.Code != http.StatusOK || !ran {
|
|
|
|
|
t.Errorf("/me must always be reachable: code=%d ran=%v", rec.Code, ran)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestAccessMiddlewareStoresUserOnContext(t *testing.T) {
|
|
|
|
|
policy := access.New("", nil) // unconfigured → everyone write
|
|
|
|
|
var got string
|
|
|
|
|
h := accessMiddleware(policy, testUserHeader, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
got = access.UserFrom(r.Context())
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/foo", nil)
|
|
|
|
|
req.Header.Set(testUserHeader, "carol")
|
|
|
|
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
|
|
|
|
|
|
if got != "carol" {
|
|
|
|
|
t.Errorf("context user = %q, want carol", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------- //
|
|
|
|
|
// httpsRedirectHandler //
|
|
|
|
|
// -------------------------------------------------------------------------- //
|
|
|
|
|
|
|
|
|
|
func TestHTTPSRedirectHandler(t *testing.T) {
|
|
|
|
|
cases := []struct {
|
|
|
|
|
name string
|
|
|
|
|
tlsAddr string
|
|
|
|
|
host string
|
|
|
|
|
target string
|
|
|
|
|
want string
|
|
|
|
|
}{
|
|
|
|
|
{"default port omitted", ":443", "example.com", "/panels?x=1", "https://example.com/panels?x=1"},
|
|
|
|
|
{"custom port preserved", ":8443", "host.local:80", "/a", "https://host.local:8443/a"},
|
|
|
|
|
{"root path", "0.0.0.0:443", "h", "/", "https://h/"},
|
|
|
|
|
}
|
|
|
|
|
for _, c := range cases {
|
|
|
|
|
t.Run(c.name, func(t *testing.T) {
|
|
|
|
|
h := httpsRedirectHandler(c.tlsAddr)
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, c.target, nil)
|
|
|
|
|
req.Host = c.host
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(rec, req)
|
|
|
|
|
|
|
|
|
|
if rec.Code != http.StatusMovedPermanently {
|
|
|
|
|
t.Fatalf("code = %d, want 301", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
if loc := rec.Header().Get("Location"); loc != c.want {
|
|
|
|
|
t.Errorf("Location = %q, want %q", loc, c.want)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------- //
|
|
|
|
|
// parseDialogTarget //
|
|
|
|
|
// -------------------------------------------------------------------------- //
|
|
|
|
|
|
|
|
|
|
func TestParseDialogTarget(t *testing.T) {
|
|
|
|
|
cases := []struct {
|
|
|
|
|
in string
|
|
|
|
|
ds, name string
|
|
|
|
|
ok bool
|
|
|
|
|
}{
|
|
|
|
|
{"epics:SR:CURRENT", "epics", "SR:CURRENT", true}, // splits on first ':' only
|
|
|
|
|
{"bareName", "srv", "bareName", true}, // defaults to srv
|
|
|
|
|
{" spaced ", "srv", "spaced", true},
|
|
|
|
|
{"", "", "", false},
|
|
|
|
|
{" ", "", "", false},
|
|
|
|
|
}
|
|
|
|
|
for _, c := range cases {
|
|
|
|
|
ds, name, ok := parseDialogTarget(c.in)
|
|
|
|
|
if ds != c.ds || name != c.name || ok != c.ok {
|
|
|
|
|
t.Errorf("parseDialogTarget(%q) = (%q,%q,%v), want (%q,%q,%v)",
|
|
|
|
|
c.in, ds, name, ok, c.ds, c.name, c.ok)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------- //
|
|
|
|
|
// formatAuditValue //
|
|
|
|
|
// -------------------------------------------------------------------------- //
|
|
|
|
|
|
|
|
|
|
func TestFormatAuditValue(t *testing.T) {
|
|
|
|
|
cases := []struct {
|
|
|
|
|
in any
|
|
|
|
|
want string
|
|
|
|
|
}{
|
|
|
|
|
{"hello", "hello"},
|
|
|
|
|
{float64(3.5), "3.5"},
|
|
|
|
|
{float64(42), "42"},
|
|
|
|
|
{true, "true"},
|
|
|
|
|
{false, "false"},
|
|
|
|
|
{nil, ""},
|
|
|
|
|
{[]any{1.0, 2.0}, "[1,2]"}, // default → JSON
|
|
|
|
|
{map[string]any{"a": 1.0}, `{"a":1}`},
|
|
|
|
|
}
|
|
|
|
|
for _, c := range cases {
|
|
|
|
|
if got := formatAuditValue(c.in); got != c.want {
|
|
|
|
|
t.Errorf("formatAuditValue(%v) = %q, want %q", c.in, got, c.want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------- //
|
|
|
|
|
// clientIP //
|
|
|
|
|
// -------------------------------------------------------------------------- //
|
|
|
|
|
|
|
|
|
|
func TestClientIP(t *testing.T) {
|
|
|
|
|
t.Run("X-Forwarded-For first hop", func(t *testing.T) {
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
|
|
|
req.Header.Set("X-Forwarded-For", "203.0.113.7, 10.0.0.1")
|
|
|
|
|
if got := clientIP(req); got != "203.0.113.7" {
|
|
|
|
|
t.Errorf("got %q, want 203.0.113.7", got)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
t.Run("X-Real-IP", func(t *testing.T) {
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
|
|
|
req.Header.Set("X-Real-IP", "198.51.100.4")
|
|
|
|
|
if got := clientIP(req); got != "198.51.100.4" {
|
|
|
|
|
t.Errorf("got %q, want 198.51.100.4", got)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
t.Run("RemoteAddr fallback", func(t *testing.T) {
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
|
|
|
req.RemoteAddr = "192.0.2.9:54321"
|
|
|
|
|
if got := clientIP(req); got != "192.0.2.9" {
|
|
|
|
|
t.Errorf("got %q, want 192.0.2.9", got)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|