Files
go-nc-exapp/access_gate_test.go

414 lines
12 KiB
Go

package gonexapp_test
import (
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"gitea.neitzel.de/konrad/go-nc-exapp"
)
func authHeader(userID string) string {
return base64.StdEncoding.EncodeToString([]byte(userID + ":secret"))
}
func okInner() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "ok")
})
}
func TestAccessGateEmptyGroupsPassesThrough(t *testing.T) {
gate := gonexapp.AccessGate{}
h := gate.Wrap(okInner())
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || rec.Body.String() != "ok" {
t.Fatalf("got %d %q", rec.Code, rec.Body.String())
}
}
func TestAccessGateSkipsLifecyclePaths(t *testing.T) {
gate := gonexapp.AccessGate{Groups: []string{"dns-ops"}}
h := gate.Wrap(okInner())
for _, path := range []string{"/heartbeat", "/enabled", "/init"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s: got %d", path, rec.Code)
}
}
}
func TestAccessGateExtraSkipPaths(t *testing.T) {
gate := gonexapp.AccessGate{Groups: []string{"dns-ops"}, ExtraSkipPaths: []string{"/healthz"}}
h := gate.Wrap(okInner())
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("got %d", rec.Code)
}
}
func TestAccessGateMissingUserUnauthorized(t *testing.T) {
gate := gonexapp.AccessGate{Groups: []string{"dns-ops"}}
h := gate.Wrap(okInner())
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("got %d", rec.Code)
}
}
func groupsOCSServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
return srv
}
func gateWithOCS(t *testing.T, groups []string, ttl time.Duration, srv *httptest.Server) gonexapp.AccessGate {
t.Helper()
cred := gonexapp.Credentials{
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s",
}
return gonexapp.AccessGate{
Cred: cred,
Groups: groups,
CacheTTL: ttl,
Client: srv.Client(),
OCS: gonexapp.OCSClient{Cred: cred, Client: srv.Client()},
}
}
func TestAccessGateAllowsAnyOfMember(t *testing.T) {
var calls atomic.Int32
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/cloud/users/alice/groups") {
http.Error(w, "bad path "+r.URL.Path, http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"ocs": map[string]any{"data": map[string]any{"groups": []string{"other", "dns-ops"}}},
})
})
gate := gateWithOCS(t, []string{"dns-ops", "dns-admins"}, 0, srv)
h := gate.Wrap(okInner())
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || rec.Body.String() != "ok" {
t.Fatalf("got %d %q", rec.Code, rec.Body.String())
}
if calls.Load() != 1 {
t.Fatalf("ocs calls=%d", calls.Load())
}
}
func TestAccessGateDeniesNonMemberWith403(t *testing.T) {
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"ocs": map[string]any{"data": map[string]any{"groups": []string{"users"}}},
})
})
gate := gateWithOCS(t, []string{"dns-ops"}, 0, srv)
h := gate.Wrap(okInner())
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("got %d", rec.Code)
}
if strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
t.Fatalf("unexpected html content-type")
}
}
func TestAccessGateDeniesNonMemberWithHTML(t *testing.T) {
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"ocs": map[string]any{"data": map[string]any{"groups": []string{"users"}}},
})
})
gate := gateWithOCS(t, []string{"dns-ops"}, 0, srv)
h := gate.Wrap(okInner())
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
req.Header.Set("Accept", "text/html,application/xhtml+xml")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("got %d", rec.Code)
}
if !strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
t.Fatalf("content-type=%q", rec.Header().Get("Content-Type"))
}
if !strings.Contains(rec.Body.String(), "Access denied") {
t.Fatalf("body=%q", rec.Body.String())
}
csp := rec.Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "frame-ancestors 'self'") {
t.Fatalf("csp=%q", csp)
}
}
func TestAccessGateSkipsTopMenuScriptPrefix(t *testing.T) {
gate := gonexapp.AccessGate{Groups: []string{"dns-ops"}}
h := gate.Wrap(okInner())
for _, path := range []string{"/js/checkdns-main.js", "/js/app.js", "/js"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || rec.Body.String() != "ok" {
t.Fatalf("%s: got %d %q", path, rec.Code, rec.Body.String())
}
}
req := httptest.NewRequest(http.MethodGet, "/json", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("/json should stay gated, got %d", rec.Code)
}
}
func TestAccessGateLookupFailureServiceUnavailable(t *testing.T) {
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
})
gate := gateWithOCS(t, []string{"dns-ops"}, 0, srv)
h := gate.Wrap(okInner())
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("got %d", rec.Code)
}
}
func TestAccessGateCachesPositiveMembership(t *testing.T) {
var calls atomic.Int32
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{
"ocs": map[string]any{"data": map[string]any{"groups": []string{"dns-ops"}}},
})
})
now := time.Unix(1_700_000_000, 0)
gate := gateWithOCS(t, []string{"dns-ops"}, time.Minute, srv)
gate.Now = func() time.Time { return now }
h := gate.Wrap(okInner())
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
for i := range 2 {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("pass %d: got %d", i, rec.Code)
}
}
if calls.Load() != 1 {
t.Fatalf("ocs calls=%d want 1", calls.Load())
}
}
func TestAccessGateCacheKeyedByGroupSet(t *testing.T) {
var calls atomic.Int32
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{
"ocs": map[string]any{"data": map[string]any{"groups": []string{"dns-ops"}}},
})
})
cred := gonexapp.Credentials{
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s",
}
gate := &gonexapp.AccessGate{
Cred: cred, Groups: []string{"dns-ops"}, CacheTTL: time.Minute,
Client: srv.Client(), OCS: gonexapp.OCSClient{Cred: cred, Client: srv.Client()},
}
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
if gate.Check(req) != gonexapp.CheckAllowed {
t.Fatal("first allow")
}
gate.Groups = []string{"other-group"}
if gate.Check(req) != gonexapp.CheckDenied {
t.Fatalf("after group-set change want denied, calls=%d", calls.Load())
}
if calls.Load() != 2 {
t.Fatalf("ocs calls=%d want 2 (cache must not reuse prior group-set)", calls.Load())
}
}
func TestAccessGateDoesNotCacheDenial(t *testing.T) {
var calls atomic.Int32
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
n := calls.Add(1)
groups := []string{"users"}
if n >= 2 {
groups = []string{"dns-ops"}
}
_ = json.NewEncoder(w).Encode(map[string]any{
"ocs": map[string]any{"data": map[string]any{"groups": groups}},
})
})
gate := gateWithOCS(t, []string{"dns-ops"}, time.Minute, srv)
h := gate.Wrap(okInner())
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("first: got %d", rec.Code)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("second: got %d", rec.Code)
}
if calls.Load() != 2 {
t.Fatalf("ocs calls=%d want 2", calls.Load())
}
}
func TestAccessGateZeroTTLDisablesCache(t *testing.T) {
var calls atomic.Int32
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{
"ocs": map[string]any{"data": map[string]any{"groups": []string{"dns-ops"}}},
})
})
gate := gateWithOCS(t, []string{"dns-ops"}, 0, srv)
h := gate.Wrap(okInner())
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
for i := range 2 {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("pass %d: got %d", i, rec.Code)
}
}
if calls.Load() != 2 {
t.Fatalf("ocs calls=%d want 2", calls.Load())
}
}
func TestAccessGateDoesNotCacheLookupErrors(t *testing.T) {
var calls atomic.Int32
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
n := calls.Add(1)
if n == 1 {
http.Error(w, "boom", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"ocs": map[string]any{"data": map[string]any{"groups": []string{"dns-ops"}}},
})
})
gate := gateWithOCS(t, []string{"dns-ops"}, time.Minute, srv)
h := gate.Wrap(okInner())
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("first: got %d", rec.Code)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("second: got %d", rec.Code)
}
}
func TestParseRequiredGroups(t *testing.T) {
got := gonexapp.ParseRequiredGroups(" dns-ops, dns-admins ,, ")
if len(got) != 2 || got[0] != "dns-ops" || got[1] != "dns-admins" {
t.Fatalf("got %#v", got)
}
if len(gonexapp.ParseRequiredGroups("")) != 0 {
t.Fatalf("empty should be empty")
}
}
func TestResolveRequiredGroups(t *testing.T) {
def := []string{"checkdns"}
if got := gonexapp.ResolveRequiredGroups("", false, def); len(got) != 1 || got[0] != "checkdns" {
t.Fatalf("unset: %#v", got)
}
if got := gonexapp.ResolveRequiredGroups("", true, def); len(got) != 0 {
t.Fatalf("set empty: %#v", got)
}
if got := gonexapp.ResolveRequiredGroups("ops", true, def); len(got) != 1 || got[0] != "ops" {
t.Fatalf("set: %#v", got)
}
}
func TestParseCacheSeconds(t *testing.T) {
if d := gonexapp.ParseCacheSeconds("", 60); d != 60*time.Second {
t.Fatalf("default unset: %v", d)
}
if d := gonexapp.ParseCacheSeconds("0", 60); d != 0 {
t.Fatalf("zero: %v", d)
}
if d := gonexapp.ParseCacheSeconds("30", 60); d != 30*time.Second {
t.Fatalf("thirty: %v", d)
}
if d := gonexapp.ParseCacheSeconds("nope", 60); d != 60*time.Second {
t.Fatalf("invalid: %v", d)
}
}
func TestAccessGateCheckStandalone(t *testing.T) {
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"ocs": map[string]any{"data": map[string]any{"groups": []string{"dns-ops"}}},
})
})
gate := gateWithOCS(t, []string{"dns-ops"}, 0, srv)
ptr := &gonexapp.AccessGate{
Cred: gate.Cred, Groups: gate.Groups, CacheTTL: gate.CacheTTL, Client: gate.Client, OCS: gate.OCS,
}
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
if got := ptr.Check(req); got != gonexapp.CheckAllowed {
t.Fatalf("got %v", got)
}
}