Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
984b0c2335 | ||
|
|
198ef0e204 | ||
|
|
cf3b396398 | ||
|
|
69af4d19c8 | ||
|
|
92d8efea47 |
+6
-2
@@ -25,5 +25,9 @@ The Nextcloud groups configured for an ExApp (comma-separated deploy env `REQUIR
|
||||
_Avoid_: AppAPI scopes, route access_level, admin-only top menu, treating the ExApp id as an implicit group name
|
||||
|
||||
**Access Gate**:
|
||||
The Library check that enforces Required Groups for the Requesting user on ExApp HTTP traffic (403 or denied UI when not a member; 401 without a user; 503 when membership cannot be determined). Lifecycle paths stay ungated.
|
||||
_Avoid_: Nextcloud middleware, HaRP ACL, admin bypass
|
||||
The Library check that enforces Required Groups for the Requesting user on ExApp HTTP traffic (403 or denied UI when not a member; 401 without a user; 503 when membership cannot be determined). Lifecycle paths and top-menu script URLs under `/js/` stay ungated so Denied UI can load in the Nextcloud shell.
|
||||
_Avoid_: Nextcloud middleware, HaRP ACL, admin bypass, gating the top-menu bootstrap script
|
||||
|
||||
**Top Menu visibility**:
|
||||
Whether the ExApp app icon in the Nextcloud top menu is shown to all logged-in users or to Nextcloud admins only. Configured per deploy via env `TOP_MENU_ADMIN_REQUIRED` (`0` or `1`); the ExApp passes the value to AppAPI when registering the top-menu entry on enable. Independent of route `access_level` in info.xml and of Required Groups.
|
||||
_Avoid_: route access_level, Required Groups, AppAPI group ACL
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# go-nc-exapp
|
||||
|
||||
Shared Go library for Nextcloud ExApp Services: AppAPI authentication, OCS JSON calls, per-user ExApp preferences, and optional Required Groups Access Gate.
|
||||
Shared Go library for Nextcloud ExApp Services: AppAPI authentication, OCS JSON calls, per-user ExApp preferences, and an optional Required Groups Access Gate.
|
||||
|
||||
Import: `gitea.neitzel.de/konrad/go-nc-exapp`
|
||||
Import: `gitea.neitzel.de/konrad/go-nc-exapp` (package `gonexapp`).
|
||||
|
||||
## Scope
|
||||
|
||||
@@ -14,7 +14,8 @@ Import: `gitea.neitzel.de/konrad/go-nc-exapp`
|
||||
- **UserFromRequest** — extract the requesting user from inbound AppAPI-proxied requests
|
||||
- **OCSClient** — authenticated OCS calls that always append `format=json`
|
||||
- **AppAPIPreferences** — parameterized get/set of a string ExApp preference (caller supplies app id and key)
|
||||
- **Access Gate** — optional Required Groups enforcement (`Wrap` + `Check`), English denied HTML, positive membership cache; env helpers for `REQUIRED_GROUPS` / `REQUIRED_GROUPS_CACHE_SECONDS`
|
||||
- **Access Gate** — optional Required Groups enforcement (`Wrap` + `Check`), English denied HTML for browsers (200 + `frame-ancestors 'self'`), positive membership cache; default skip for lifecycle paths and **`/js/`** top-menu scripts; env helpers for `REQUIRED_GROUPS` / `REQUIRED_GROUPS_CACHE_SECONDS`
|
||||
- **Top Menu visibility** — `TopMenuAdminRequired` helper for deploy env `TOP_MENU_ADMIN_REQUIRED` (`0` / `1` for AppAPI top-menu OCS)
|
||||
|
||||
**Excluded**
|
||||
|
||||
@@ -49,9 +50,39 @@ Each ExApp chooses its own preference keys; this library does not hardcode produ
|
||||
|
||||
Declare `REQUIRED_GROUPS` and `REQUIRED_GROUPS_CACHE_SECONDS` in the ExApp `info.xml` so Deploy options can set them.
|
||||
|
||||
The Gate skips `/heartbeat`, `/enabled`, `/init`, and any path under **`/js/`** (AppAPI top-menu bootstrap). Serve the registered top-menu script under `/js/…` so a non-member still loads it and can show Denied UI in the Nextcloud shell. API routes stay gated.
|
||||
|
||||
Denied HTML is **200** with `Content-Security-Policy: … frame-ancestors 'self'`. Without that header AppAPI’s proxy defaults to `frame-ancestors 'none'` and a denied iframe stays blank. Non-HTML denials remain **403**.
|
||||
|
||||
### Top Menu visibility (`TOP_MENU_ADMIN_REQUIRED`)
|
||||
|
||||
Declare in `info.xml` under `<environment-variables>`. At enable time the ExApp reads the env and passes `"0"` or `"1"` to AppAPI’s top-menu OCS `adminRequired`. Only `0` and `1` are valid; anything else falls back to `DefaultTopMenuAdminRequired` (`true` → admins only).
|
||||
|
||||
```go
|
||||
adminRequired := gonexapp.TopMenuAdminRequired(
|
||||
os.Getenv(gonexapp.EnvTopMenuAdminRequired),
|
||||
gonexapp.DefaultTopMenuAdminRequired,
|
||||
)
|
||||
// use adminRequired in POST …/ui/top-menu when registering the menu entry
|
||||
```
|
||||
|
||||
**Applying a change:** AppAPI registers the top menu when the ExApp receives `PUT /enabled?enabled=1`. Changing the deploy env alone does not update the menu entry.
|
||||
|
||||
1. Set the new value in Deploy options (UI) or `occ app_api:app:register … --env TOP_MENU_ADMIN_REQUIRED=…` / update deploy config.
|
||||
2. Recreate or restart the ExApp container so the new env is present.
|
||||
3. Re-run lifecycle: disable then enable the ExApp (UI or `occ app_api:app:disable` / `app_api:app:enable`), or `occ app_api:app:update … -e` after an image/info update.
|
||||
|
||||
Route `access_level` in `info.xml` is separate and only changes when AppAPI re-reads `info.xml` on register/update — not via this env.
|
||||
|
||||
Runnable package examples: `go test -run Example`.
|
||||
|
||||
## Domain language
|
||||
|
||||
See [CONTEXT.md](./CONTEXT.md) for AppAPI credentials, Requesting user, ExApp preference, OCS, Required Groups, and Access Gate terminology.
|
||||
See [CONTEXT.md](./CONTEXT.md) for AppAPI credentials, Requesting user, ExApp preference, OCS, Required Groups, Access Gate, and Top Menu visibility terminology.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests use `httptest` fake OCS servers. No live Nextcloud is required for Library CI.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
+56
-18
@@ -32,9 +32,13 @@ type cacheEntry struct {
|
||||
type CheckResult int
|
||||
|
||||
const (
|
||||
// CheckAllowed means the request may proceed (or the gate is inactive / skipped).
|
||||
CheckAllowed CheckResult = iota
|
||||
// CheckDenied means the Requesting user is not in Required Groups.
|
||||
CheckDenied
|
||||
// CheckUnauthorized means no Requesting user could be read from the request.
|
||||
CheckUnauthorized
|
||||
// CheckUnavailable means group membership could not be determined (e.g. OCS error).
|
||||
CheckUnavailable
|
||||
)
|
||||
|
||||
@@ -81,8 +85,9 @@ func (g *AccessGate) Check(r *http.Request) CheckResult {
|
||||
}
|
||||
|
||||
func (g *AccessGate) memberOfRequired(userID string) (bool, error) {
|
||||
key := g.cacheKey(userID)
|
||||
if g.CacheTTL > 0 {
|
||||
if g.cachedAllowed(userID) {
|
||||
if g.cachedAllowed(key) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
@@ -94,7 +99,7 @@ func (g *AccessGate) memberOfRequired(userID string) (bool, error) {
|
||||
for _, have := range groups {
|
||||
if have == need {
|
||||
if g.CacheTTL > 0 {
|
||||
g.storeAllowed(userID)
|
||||
g.storeAllowed(key)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -103,6 +108,10 @@ func (g *AccessGate) memberOfRequired(userID string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (g *AccessGate) cacheKey(userID string) string {
|
||||
return userID + "\x00" + strings.Join(g.Groups, "\x00")
|
||||
}
|
||||
|
||||
func (g *AccessGate) fetchUserGroups(userID string) ([]string, error) {
|
||||
ocs := g.ocsClient(userID)
|
||||
path := "cloud/users/" + url.PathEscape(userID) + "/groups"
|
||||
@@ -139,24 +148,24 @@ func decodeUserGroups(raw []byte) ([]string, error) {
|
||||
return parsed.OCS.Data.Groups, nil
|
||||
}
|
||||
|
||||
func (g *AccessGate) cachedAllowed(userID string) bool {
|
||||
func (g *AccessGate) cachedAllowed(key string) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
ent, ok := g.cache[userID]
|
||||
ent, ok := g.cache[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if g.Now().After(ent.until) {
|
||||
delete(g.cache, userID)
|
||||
delete(g.cache, key)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (g *AccessGate) storeAllowed(userID string) {
|
||||
func (g *AccessGate) storeAllowed(key string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.cache[userID] = cacheEntry{until: g.Now().Add(g.CacheTTL)}
|
||||
g.cache[key] = cacheEntry{until: g.Now().Add(g.CacheTTL)}
|
||||
}
|
||||
|
||||
func (g AccessGate) normalized() *AccessGate {
|
||||
@@ -172,8 +181,11 @@ func (g AccessGate) normalized() *AccessGate {
|
||||
|
||||
func (g *AccessGate) writeDenied(w http.ResponseWriter, r *http.Request) {
|
||||
if acceptsHTML(r.Header.Get("Accept")) {
|
||||
// 200 plus frame-ancestors 'self': AppAPI's default proxy CSP uses
|
||||
// frame-ancestors 'none' unless the ExApp sets CSP, which blanks iframes.
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Header().Set("Content-Security-Policy", deniedCSP)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(deniedHTML)
|
||||
return
|
||||
}
|
||||
@@ -185,9 +197,9 @@ func acceptsHTML(accept string) bool {
|
||||
}
|
||||
|
||||
func (g *AccessGate) shouldSkip(path string) bool {
|
||||
path = strings.TrimSuffix(path, "/")
|
||||
if path == "" {
|
||||
path = "/"
|
||||
path = normalizeGatePath(path)
|
||||
if isTopMenuScriptPath(path) {
|
||||
return true
|
||||
}
|
||||
for _, p := range defaultSkipPaths {
|
||||
if path == p {
|
||||
@@ -195,22 +207,48 @@ func (g *AccessGate) shouldSkip(path string) bool {
|
||||
}
|
||||
}
|
||||
for _, p := range g.ExtraSkipPaths {
|
||||
p = strings.TrimSuffix(p, "/")
|
||||
if p == "" {
|
||||
p = "/"
|
||||
}
|
||||
if path == p {
|
||||
if path == normalizeGatePath(p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isTopMenuScriptPath reports AppAPI top-menu bootstrap scripts under /js/.
|
||||
// Those must load for a non-member so the shell can show Denied UI; gating
|
||||
// them yields a blank embedded page (script Accept is not text/html → 403).
|
||||
func isTopMenuScriptPath(path string) bool {
|
||||
return path == "/js" || strings.HasPrefix(path, "/js/")
|
||||
}
|
||||
|
||||
func normalizeGatePath(path string) string {
|
||||
path = strings.TrimSuffix(path, "/")
|
||||
if path == "" {
|
||||
return "/"
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
var defaultSkipPaths = []string{"/heartbeat", "/enabled", "/init"}
|
||||
|
||||
// deniedCSP lets AppAPI proxy the denied page into an ExApp iframe.
|
||||
const deniedCSP = "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'; style-src 'unsafe-inline'"
|
||||
|
||||
var deniedHTML = []byte(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"><title>Access denied</title></head>
|
||||
<body><h1>Access denied</h1><p>You are not a member of a required group for this app.</p></body>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Access denied</title>
|
||||
<style>
|
||||
:root { font-family: ui-sans-serif, system-ui, sans-serif; color: #1a1a1a; }
|
||||
body { margin: 2rem; max-width: 40rem; }
|
||||
h1 { font-size: 1.4rem; margin-bottom: 0.5rem; }
|
||||
p { color: #444; line-height: 1.5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Access denied</h1>
|
||||
<p>You are not a member of a required group for this app. Ask an administrator to add you to the group if you need access.</p>
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
|
||||
+55
-1
@@ -159,7 +159,7 @@ func TestAccessGateDeniesNonMemberWithHTML(t *testing.T) {
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
|
||||
@@ -168,6 +168,31 @@ func TestAccessGateDeniesNonMemberWithHTML(t *testing.T) {
|
||||
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) {
|
||||
@@ -213,6 +238,35 @@ func TestAccessGateCachesPositiveMembership(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package gonexapp_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
gonexapp "gitea.neitzel.de/konrad/go-nc-exapp"
|
||||
)
|
||||
|
||||
func ExampleUserFromRequest() {
|
||||
token := base64.StdEncoding.EncodeToString([]byte("alice:secret"))
|
||||
req := httptest.NewRequest(http.MethodGet, "/api", nil)
|
||||
req.Header.Set("AUTHORIZATION-APP-API", token)
|
||||
|
||||
user, err := gonexapp.UserFromRequest(req)
|
||||
if err != nil {
|
||||
fmt.Println("err:", err)
|
||||
return
|
||||
}
|
||||
fmt.Println(user)
|
||||
// Output: alice
|
||||
}
|
||||
|
||||
func ExampleCredentials_AuthHeaders() {
|
||||
cred := gonexapp.Credentials{
|
||||
BaseURL: "https://nextcloud.example", AppID: "myexapp", AppVersion: "0.1.0",
|
||||
AAVersion: "1.0.0", AppSecret: "s", UserID: "alice",
|
||||
}
|
||||
h := cred.AuthHeaders()
|
||||
fmt.Println(h.Get("EX-APP-ID"), h.Get("OCS-APIRequest") != "")
|
||||
// Output: myexapp true
|
||||
}
|
||||
|
||||
func ExampleNewAppAPIPreferences() {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "get-values") {
|
||||
_, _ = io.WriteString(w, `{"ocs":{"data":[{"configkey":"savedDefault","configvalue":"Zones"}]}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, `{"ocs":{"data":{}}}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cred := gonexapp.Credentials{
|
||||
BaseURL: srv.URL, AppID: "myexapp", AppVersion: "0.1.0", AAVersion: "1.0.0",
|
||||
AppSecret: "s", UserID: "alice",
|
||||
}
|
||||
prefs := gonexapp.NewAppAPIPreferences(cred, "myexapp", "savedDefault")
|
||||
prefs.Client = srv.Client()
|
||||
prefs.OCS.Client = srv.Client()
|
||||
|
||||
value, err := prefs.Get()
|
||||
if err != nil {
|
||||
fmt.Println("err:", err)
|
||||
return
|
||||
}
|
||||
if err := prefs.Set("Zones"); err != nil {
|
||||
fmt.Println("set:", err)
|
||||
return
|
||||
}
|
||||
fmt.Println(value)
|
||||
// Output: Zones
|
||||
}
|
||||
|
||||
func ExampleAccessGate_Wrap() {
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, "ok")
|
||||
})
|
||||
h := gonexapp.AccessGate{}.Wrap(inner)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api", nil))
|
||||
fmt.Println(rec.Code, rec.Body.String())
|
||||
// Output: 200 ok
|
||||
}
|
||||
|
||||
func ExampleResolveRequiredGroups() {
|
||||
fmt.Println(gonexapp.ResolveRequiredGroups("", false, nil))
|
||||
fmt.Println(len(gonexapp.ResolveRequiredGroups("", true, []string{"ops"})))
|
||||
// Output:
|
||||
// []
|
||||
// 0
|
||||
}
|
||||
|
||||
func ExampleTopMenuAdminRequired() {
|
||||
fmt.Println(gonexapp.TopMenuAdminRequired("0", gonexapp.DefaultTopMenuAdminRequired))
|
||||
fmt.Println(gonexapp.TopMenuAdminRequired("maybe", gonexapp.DefaultTopMenuAdminRequired))
|
||||
// Output:
|
||||
// 0
|
||||
// 1
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package gonexapp
|
||||
|
||||
import "strings"
|
||||
|
||||
// EnvTopMenuAdminRequired is the conventional deploy env name for Top Menu visibility.
|
||||
// Declare it in the ExApp info.xml environment-variables section.
|
||||
const EnvTopMenuAdminRequired = "TOP_MENU_ADMIN_REQUIRED"
|
||||
|
||||
// DefaultTopMenuAdminRequired is used when TOP_MENU_ADMIN_REQUIRED is unset or invalid.
|
||||
const DefaultTopMenuAdminRequired = true
|
||||
|
||||
// TopMenuAdminRequired returns "1" or "0" for the AppAPI top-menu OCS adminRequired field.
|
||||
// Only "0" and "1" are accepted; any other value falls back to defaultAdminRequired.
|
||||
// An empty envValue means unset and also uses defaultAdminRequired.
|
||||
func TopMenuAdminRequired(envValue string, defaultAdminRequired bool) string {
|
||||
switch strings.TrimSpace(envValue) {
|
||||
case "1":
|
||||
return "1"
|
||||
case "0":
|
||||
return "0"
|
||||
default:
|
||||
if defaultAdminRequired {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package gonexapp
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTopMenuAdminRequired(t *testing.T) {
|
||||
tests := []struct {
|
||||
env string
|
||||
defAdmin bool
|
||||
want string
|
||||
}{
|
||||
{"", true, "1"},
|
||||
{"", false, "0"},
|
||||
{"1", true, "1"},
|
||||
{"0", true, "0"},
|
||||
{" 1 ", true, "1"},
|
||||
{"yes", true, "1"},
|
||||
{"yes", false, "0"},
|
||||
{"2", true, "1"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := TopMenuAdminRequired(tc.env, tc.defAdmin); got != tc.want {
|
||||
t.Errorf("TopMenuAdminRequired(%q, %v) = %q, want %q", tc.env, tc.defAdmin, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user