AppAPI cannot restrict ExApps by Nextcloud group; enforce any-of membership in-library with Wrap/Check, deploy-env helpers, and positive-only cache. Co-authored-by: Cursor <cursoragent@cursor.com>
360 lines
11 KiB
Go
360 lines
11 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.StatusForbidden {
|
|
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())
|
|
}
|
|
}
|
|
|
|
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 := 0; i < 2; i++ {
|
|
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 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 := 0; i < 2; i++ {
|
|
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)
|
|
}
|
|
}
|