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