Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc2f9f63c2 | ||
|
|
9c1c2f4310 | ||
|
|
984b0c2335 | ||
|
|
198ef0e204 | ||
|
|
cf3b396398 | ||
|
|
69af4d19c8 | ||
|
|
92d8efea47 | ||
|
|
3980263146 |
+26
-2
@@ -1,6 +1,6 @@
|
||||
# go-nc-exapp
|
||||
|
||||
Shared Go Library for Nextcloud ExApp Services: AppAPI authentication, OCS calls, and per-user ExApp preferences. ExApps import `gitea.neitzel.de/konrad/go-nc-exapp`. File storage and folder visits live in go-nc-files.
|
||||
Shared Go Library for Nextcloud ExApp Services: AppAPI authentication, OCS calls, per-user ExApp preferences, Notifications, Users and Groups, and optional Required Groups gating. ExApps import `gitea.neitzel.de/konrad/go-nc-exapp`. File storage and folder visits live in go-nc-files.
|
||||
|
||||
## Language
|
||||
|
||||
@@ -9,13 +9,37 @@ The ExApp's shared secret and Nextcloud base URL, plus optional per-request user
|
||||
_Avoid_: API key (generic), session token
|
||||
|
||||
**Requesting user**:
|
||||
The Nextcloud user on whose behalf the current ExApp request runs, taken from AppAPI authorization headers. WebDAV and preferences use this user; there is no separate ExApp login.
|
||||
The Nextcloud user on whose behalf the current ExApp request runs, taken from AppAPI authorization headers. WebDAV, preferences, and a Notification to that same user use this identity; there is no separate ExApp login.
|
||||
_Avoid_: service account (for per-request identity), anonymous
|
||||
|
||||
**Recipient**:
|
||||
The Nextcloud user a Notification is created for. May be the Requesting user or another user. AppAPI accepts one Recipient per call; sending to a group or to all admins is many Notifications.
|
||||
_Avoid_: destination, target (HTTP), addressee, treating a group as a Recipient
|
||||
|
||||
**ExApp preference**:
|
||||
A string value stored in Nextcloud for one user and one ExApp, keyed by the ExApp (not admin AppConfig). Libraries expose a parameterized key; each ExApp chooses its own key names.
|
||||
_Avoid_: settings file in User Files, instance-wide config
|
||||
|
||||
**Notification**:
|
||||
A Nextcloud bell-icon message that an ExApp creates for one Recipient via AppAPI. It has a Subject, optional Message, optional Link, and optional rich-object params. AppAPI’s OCS is limited: no actions and no custom icon. One AppAPI call creates one Notification; the Recipient is the user the ExApp impersonates for that call, not a field in the message body.
|
||||
_Avoid_: Denied UI, email, Talk message, in-app banner, toast, treating this as a full PHP INotifier
|
||||
|
||||
**OCS**:
|
||||
Nextcloud's legacy HTTP API surface under `/ocs/v2.php/…`. This Library requests JSON responses (`format=json`) for machine-readable bodies.
|
||||
_Avoid_: assuming XML responses, REST-only Nextcloud APIs for ExApp prefs
|
||||
|
||||
**Required Groups**:
|
||||
The Nextcloud groups configured for an ExApp (comma-separated deploy env `REQUIRED_GROUPS`) such that membership in any one of them is enough to use the ExApp. Empty or unset means no group restriction. AppAPI does not enforce this; the ExApp does.
|
||||
_Avoid_: AppAPI scopes, route access_level, admin-only top menu, treating the ExApp id as an implicit group name, Users and Groups (that is the OCS directory, not this ACL)
|
||||
|
||||
**Users and Groups**:
|
||||
Nextcloud's Provisioning OCS this Library wraps as three reads: the groups of one user (as that user), the members of one group, and the instance group list. Member and instance lists run as the Requesting user and succeed only if that user is an admin or a subadmin of the group. Distinct from Required Groups.
|
||||
_Avoid_: Group-API, Required Groups, AppAPI scopes, treating a group as a Recipient
|
||||
|
||||
**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). It reads the user's groups through Users and Groups. 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,10 +1,10 @@
|
||||
# go-nc-exapp
|
||||
|
||||
Shared Go library for Nextcloud ExApp Services: AppAPI authentication, OCS JSON calls, and per-user ExApp preferences.
|
||||
Shared Go library for Nextcloud ExApp Services: AppAPI authentication, OCS JSON calls, per-user ExApp preferences, Notifications, Users and Groups, 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`).
|
||||
|
||||
## v1 scope
|
||||
## Scope
|
||||
|
||||
**Included**
|
||||
|
||||
@@ -14,14 +14,21 @@ 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)
|
||||
- **AppAPINotifications** — `Send` (Recipient = Credentials user) and `SendTo` (explicit Recipient); Subject required; Message, Link, and rich-object params optional. AppAPI’s notification OCS is limited (no actions, no custom icon)
|
||||
- **Groups** — Users and Groups reads: `UserGroups`, `GroupMembers`, `ListGroups` (no search/paging). Directory calls (`GroupMembers` / `ListGroups`) run as the Credentials user and need an admin or subadmin
|
||||
- **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 from v1**
|
||||
**Excluded**
|
||||
|
||||
- ExApp lifecycle HTTP routes (`/heartbeat`, `/enabled`, …)
|
||||
- ExApp lifecycle HTTP routes (`/heartbeat`, `/enabled`, …) — the Gate *skips* these by default but does not implement them
|
||||
- HaRP listen / `serve()` and unix-socket bootstrap
|
||||
- Top-menu, script, and iframe UI registration
|
||||
- WebDAV and file storage (see **go-nc-files**)
|
||||
- **Visit** folder resolution (see **go-nc-files**)
|
||||
- Fan-out Notifications (`SendToGroup` / `SendToAdmins`)
|
||||
- A Library default privileged / admin user for directory OCS (callers who need that use `WithUser` themselves)
|
||||
- CheckDNS (or any ExApp) wiring for Notifications or Groups — products opt in separately
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -37,15 +44,57 @@ cred := gonexapp.Credentials{
|
||||
|
||||
prefs := gonexapp.NewAppAPIPreferences(cred, "myexapp", "savedDefault")
|
||||
value, err := prefs.Get()
|
||||
|
||||
err = gonexapp.NewAppAPINotifications(cred).Send(gonexapp.Notification{Subject: "Job finished"})
|
||||
members, err := gonexapp.NewGroups(cred).GroupMembers("CheckDNS")
|
||||
|
||||
groupsEnv, groupsSet := os.LookupEnv("REQUIRED_GROUPS")
|
||||
groups := gonexapp.ResolveRequiredGroups(groupsEnv, groupsSet, nil)
|
||||
ttl := gonexapp.ParseCacheSeconds(os.Getenv("REQUIRED_GROUPS_CACHE_SECONDS"), gonexapp.DefaultCacheSeconds)
|
||||
handler := gonexapp.AccessGate{Cred: cred, Groups: groups, CacheTTL: ttl}.Wrap(inner)
|
||||
```
|
||||
|
||||
Each ExApp chooses its own preference keys; this library does not hardcode product-specific names.
|
||||
|
||||
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, and OCS terminology.
|
||||
See [CONTEXT.md](./CONTEXT.md) for AppAPI credentials, Requesting user, Recipient, ExApp preference, Notification, OCS, Required Groups, Users and 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
|
||||
|
||||
- **go-nc-files** — WebDAV, Working Folder, Saved Default, Visit resolution
|
||||
- Workspace ADR 0013 — extraction from CheckDNS
|
||||
- Workspace ADR `docs/adr/go-nc-exapp/0001-required-groups-access-gate.md` — Access Gate decisions
|
||||
- Workspace ADR `docs/adr/go-nc-exapp/0002-users-and-groups-as-requesting-user.md` — directory OCS as Requesting user
|
||||
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
package gonexapp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AccessGate enforces Required Groups for the Requesting user on ExApp HTTP traffic.
|
||||
type AccessGate struct {
|
||||
Cred Credentials
|
||||
Groups []string
|
||||
CacheTTL time.Duration // 0 disables cache
|
||||
ExtraSkipPaths []string
|
||||
Client *http.Client
|
||||
OCS OCSClient
|
||||
Now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
cache map[string]cacheEntry
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
until time.Time
|
||||
}
|
||||
|
||||
// CheckResult is the outcome of AccessGate.Check.
|
||||
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
|
||||
)
|
||||
|
||||
// Wrap returns a handler that applies the Access Gate before next.
|
||||
func (g AccessGate) Wrap(next http.Handler) http.Handler {
|
||||
gate := g.normalized()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch gate.Check(r) {
|
||||
case CheckAllowed:
|
||||
next.ServeHTTP(w, r)
|
||||
case CheckUnauthorized:
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
case CheckUnavailable:
|
||||
http.Error(w, "service unavailable", http.StatusServiceUnavailable)
|
||||
default:
|
||||
gate.writeDenied(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Check reports whether r may proceed under Required Groups.
|
||||
func (g *AccessGate) Check(r *http.Request) CheckResult {
|
||||
if g.cache == nil {
|
||||
g.cache = make(map[string]cacheEntry)
|
||||
}
|
||||
if g.Now == nil {
|
||||
g.Now = time.Now
|
||||
}
|
||||
if g.shouldSkip(r.URL.Path) || len(g.Groups) == 0 {
|
||||
return CheckAllowed
|
||||
}
|
||||
user, err := UserFromRequest(r)
|
||||
if err != nil || user == "" {
|
||||
return CheckUnauthorized
|
||||
}
|
||||
ok, err := g.memberOfRequired(user)
|
||||
if err != nil {
|
||||
return CheckUnavailable
|
||||
}
|
||||
if ok {
|
||||
return CheckAllowed
|
||||
}
|
||||
return CheckDenied
|
||||
}
|
||||
|
||||
func (g *AccessGate) memberOfRequired(userID string) (bool, error) {
|
||||
key := g.cacheKey(userID)
|
||||
if g.CacheTTL > 0 {
|
||||
if g.cachedAllowed(key) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
groups, err := g.fetchUserGroups(userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, need := range g.Groups {
|
||||
for _, have := range groups {
|
||||
if have == need {
|
||||
if g.CacheTTL > 0 {
|
||||
g.storeAllowed(key)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
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) {
|
||||
return Groups{Cred: g.Cred, Client: g.Client, OCS: g.OCS}.UserGroups(userID)
|
||||
}
|
||||
|
||||
func (g *AccessGate) cachedAllowed(key string) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
ent, ok := g.cache[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if g.Now().After(ent.until) {
|
||||
delete(g.cache, key)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (g *AccessGate) storeAllowed(key string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.cache[key] = cacheEntry{until: g.Now().Add(g.CacheTTL)}
|
||||
}
|
||||
|
||||
func (g AccessGate) normalized() *AccessGate {
|
||||
out := g
|
||||
if out.cache == nil {
|
||||
out.cache = make(map[string]cacheEntry)
|
||||
}
|
||||
if out.Now == nil {
|
||||
out.Now = time.Now
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
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.Header().Set("Content-Security-Policy", deniedCSP)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(deniedHTML)
|
||||
return
|
||||
}
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
}
|
||||
|
||||
func acceptsHTML(accept string) bool {
|
||||
return strings.Contains(strings.ToLower(accept), "text/html")
|
||||
}
|
||||
|
||||
func (g *AccessGate) shouldSkip(path string) bool {
|
||||
path = normalizeGatePath(path)
|
||||
if isTopMenuScriptPath(path) {
|
||||
return true
|
||||
}
|
||||
for _, p := range defaultSkipPaths {
|
||||
if path == p {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, p := range g.ExtraSkipPaths {
|
||||
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>
|
||||
<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>
|
||||
`)
|
||||
@@ -0,0 +1,413 @@
|
||||
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 := 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 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 := 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)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
// Package gonexapp provides AppAPI credentials, OCS JSON calls, and ExApp user
|
||||
// preferences for Nextcloud ExApp Services.
|
||||
// Package gonexapp provides AppAPI credentials, OCS JSON calls, ExApp user
|
||||
// preferences, and an optional Required Groups Access Gate for Nextcloud ExApp Services.
|
||||
package gonexapp
|
||||
|
||||
@@ -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,95 @@
|
||||
package gonexapp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// Groups reads Users and Groups from Provisioning OCS.
|
||||
type Groups struct {
|
||||
Cred Credentials
|
||||
Client *http.Client
|
||||
OCS OCSClient
|
||||
}
|
||||
|
||||
// NewGroups returns a directory reader using cred for AppAPI auth.
|
||||
func NewGroups(cred Credentials) Groups {
|
||||
return Groups{
|
||||
Cred: cred,
|
||||
OCS: OCSClient{Cred: cred},
|
||||
}
|
||||
}
|
||||
|
||||
func (g Groups) ocsClient() OCSClient {
|
||||
c := g.OCS
|
||||
if c.Cred.BaseURL == "" {
|
||||
c.Cred = g.Cred
|
||||
}
|
||||
if c.Client == nil {
|
||||
c.Client = g.Client
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// UserGroups returns the Nextcloud group ids of userID, calling OCS as that user.
|
||||
func (g Groups) UserGroups(userID string) ([]string, error) {
|
||||
ocs := g.ocsClient()
|
||||
ocs.Cred = ocs.Cred.WithUser(userID)
|
||||
path := "cloud/users/" + url.PathEscape(userID) + "/groups"
|
||||
raw, err := ocs.Call(http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeUserGroups(raw)
|
||||
}
|
||||
|
||||
// GroupMembers returns the user ids in groupID, calling OCS as the Requesting user.
|
||||
func (g Groups) GroupMembers(groupID string) ([]string, error) {
|
||||
ocs := g.ocsClient()
|
||||
path := "cloud/groups/" + url.PathEscape(groupID)
|
||||
raw, err := ocs.Call(http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeGroupMembers(raw)
|
||||
}
|
||||
|
||||
func decodeGroupMembers(raw []byte) ([]string, error) {
|
||||
var parsed struct {
|
||||
OCS struct {
|
||||
Data struct {
|
||||
Users []string `json:"users"`
|
||||
} `json:"data"`
|
||||
} `json:"ocs"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("group members decode: %w", err)
|
||||
}
|
||||
return parsed.OCS.Data.Users, nil
|
||||
}
|
||||
|
||||
// ListGroups returns instance group ids, calling OCS as the Requesting user.
|
||||
func (g Groups) ListGroups() ([]string, error) {
|
||||
ocs := g.ocsClient()
|
||||
raw, err := ocs.Call(http.MethodGet, "cloud/groups", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeUserGroups(raw)
|
||||
}
|
||||
|
||||
func decodeUserGroups(raw []byte) ([]string, error) {
|
||||
var parsed struct {
|
||||
OCS struct {
|
||||
Data struct {
|
||||
Groups []string `json:"groups"`
|
||||
} `json:"data"`
|
||||
} `json:"ocs"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("user groups decode: %w", err)
|
||||
}
|
||||
return parsed.OCS.Data.Groups, nil
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
package gonexapp_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.neitzel.de/konrad/go-nc-exapp"
|
||||
)
|
||||
|
||||
func groupsAuthUser(r *http.Request) string {
|
||||
raw := r.Header.Get("AUTHORIZATION-APP-API")
|
||||
decoded, err := base64.StdEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
parts := strings.SplitN(string(decoded), ":", 2)
|
||||
if len(parts) < 1 {
|
||||
return ""
|
||||
}
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
func TestGroupsUserGroups(t *testing.T) {
|
||||
var gotMethod, gotPath, gotUser string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod = r.Method
|
||||
gotPath = r.URL.Path
|
||||
gotUser = groupsAuthUser(r)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"ocs": map[string]any{"data": map[string]any{"groups": []string{"dns-ops", "users"}}},
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cred := gonexapp.Credentials{
|
||||
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "admin",
|
||||
}
|
||||
g := gonexapp.NewGroups(cred)
|
||||
g.Client = srv.Client()
|
||||
g.OCS.Client = srv.Client()
|
||||
|
||||
got, err := g.UserGroups("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotMethod != http.MethodGet {
|
||||
t.Fatalf("method=%q", gotMethod)
|
||||
}
|
||||
if !strings.Contains(gotPath, "/cloud/users/alice/groups") {
|
||||
t.Fatalf("path=%q", gotPath)
|
||||
}
|
||||
if gotUser != "alice" {
|
||||
t.Fatalf("auth user=%q want alice", gotUser)
|
||||
}
|
||||
if len(got) != 2 || got[0] != "dns-ops" || got[1] != "users" {
|
||||
t.Fatalf("got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupsGroupMembers(t *testing.T) {
|
||||
var gotMethod, gotPath, gotUser string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod = r.Method
|
||||
gotPath = r.URL.Path
|
||||
gotUser = groupsAuthUser(r)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"ocs": map[string]any{"data": map[string]any{"users": []string{"alice", "bob"}}},
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cred := gonexapp.Credentials{
|
||||
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "admin",
|
||||
}
|
||||
g := gonexapp.NewGroups(cred)
|
||||
g.Client = srv.Client()
|
||||
g.OCS.Client = srv.Client()
|
||||
|
||||
got, err := g.GroupMembers("CheckDNS")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotMethod != http.MethodGet {
|
||||
t.Fatalf("method=%q", gotMethod)
|
||||
}
|
||||
if !strings.Contains(gotPath, "/cloud/groups/CheckDNS") {
|
||||
t.Fatalf("path=%q", gotPath)
|
||||
}
|
||||
if gotUser != "admin" {
|
||||
t.Fatalf("auth user=%q want admin", gotUser)
|
||||
}
|
||||
if len(got) != 2 || got[0] != "alice" || got[1] != "bob" {
|
||||
t.Fatalf("got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupsListGroups(t *testing.T) {
|
||||
var gotMethod, gotPath, gotUser string
|
||||
var gotQuery map[string][]string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod = r.Method
|
||||
gotPath = r.URL.Path
|
||||
gotUser = groupsAuthUser(r)
|
||||
gotQuery = r.URL.Query()
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"ocs": map[string]any{"data": map[string]any{"groups": []string{"CheckDNS", "users"}}},
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cred := gonexapp.Credentials{
|
||||
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "admin",
|
||||
}
|
||||
g := gonexapp.NewGroups(cred)
|
||||
g.Client = srv.Client()
|
||||
g.OCS.Client = srv.Client()
|
||||
|
||||
got, err := g.ListGroups()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotMethod != http.MethodGet {
|
||||
t.Fatalf("method=%q", gotMethod)
|
||||
}
|
||||
if gotPath != "/ocs/v2.php/cloud/groups" {
|
||||
if !strings.Contains(gotPath, "/cloud/groups") || strings.Contains(gotPath, "/cloud/groups/") {
|
||||
t.Fatalf("path=%q", gotPath)
|
||||
}
|
||||
}
|
||||
if gotUser != "admin" {
|
||||
t.Fatalf("auth user=%q want admin", gotUser)
|
||||
}
|
||||
if _, ok := gotQuery["search"]; ok {
|
||||
t.Fatalf("unexpected search query: %v", gotQuery["search"])
|
||||
}
|
||||
if _, ok := gotQuery["limit"]; ok {
|
||||
t.Fatalf("unexpected limit query: %v", gotQuery["limit"])
|
||||
}
|
||||
if _, ok := gotQuery["offset"]; ok {
|
||||
t.Fatalf("unexpected offset query: %v", gotQuery["offset"])
|
||||
}
|
||||
if len(got) != 2 || got[0] != "CheckDNS" || got[1] != "users" {
|
||||
t.Fatalf("got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupsGroupMembersForbidden(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cred := gonexapp.Credentials{
|
||||
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "alice",
|
||||
}
|
||||
g := gonexapp.NewGroups(cred)
|
||||
g.Client = srv.Client()
|
||||
g.OCS.Client = srv.Client()
|
||||
|
||||
_, err := g.GroupMembers("CheckDNS")
|
||||
if err == nil || !strings.Contains(err.Error(), "403") {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupsListGroupsForbidden(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cred := gonexapp.Credentials{
|
||||
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "alice",
|
||||
}
|
||||
g := gonexapp.NewGroups(cred)
|
||||
g.Client = srv.Client()
|
||||
g.OCS.Client = srv.Client()
|
||||
|
||||
_, err := g.ListGroups()
|
||||
if err == nil || !strings.Contains(err.Error(), "403") {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package gonexapp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Notification is one Nextcloud bell for a single Recipient.
|
||||
type Notification struct {
|
||||
Subject string
|
||||
Message string
|
||||
Link string
|
||||
SubjectParams map[string]any
|
||||
MessageParams map[string]any
|
||||
}
|
||||
|
||||
// AppAPINotifications creates Notifications via AppAPI OCS.
|
||||
type AppAPINotifications struct {
|
||||
Cred Credentials
|
||||
Client *http.Client
|
||||
OCS OCSClient
|
||||
}
|
||||
|
||||
// NewAppAPINotifications returns a sender using cred for AppAPI auth.
|
||||
func NewAppAPINotifications(cred Credentials) AppAPINotifications {
|
||||
return AppAPINotifications{
|
||||
Cred: cred,
|
||||
OCS: OCSClient{Cred: cred},
|
||||
}
|
||||
}
|
||||
|
||||
func (n AppAPINotifications) ocsClient() OCSClient {
|
||||
c := n.OCS
|
||||
if c.Cred.BaseURL == "" {
|
||||
c.Cred = n.Cred
|
||||
}
|
||||
if c.Client == nil {
|
||||
c.Client = n.Client
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Send creates a Notification for the Requesting user on the Credentials.
|
||||
func (n AppAPINotifications) Send(notif Notification) error {
|
||||
if n.Cred.UserID == "" {
|
||||
return fmt.Errorf("notification recipient user id is required")
|
||||
}
|
||||
return n.send(n.Cred.UserID, notif)
|
||||
}
|
||||
|
||||
// SendTo creates a Notification for userID, impersonating that Recipient.
|
||||
func (n AppAPINotifications) SendTo(userID string, notif Notification) error {
|
||||
return n.send(userID, notif)
|
||||
}
|
||||
|
||||
func (n AppAPINotifications) send(userID string, notif Notification) error {
|
||||
if userID == "" {
|
||||
return fmt.Errorf("notification recipient user id is required")
|
||||
}
|
||||
if notif.Subject == "" {
|
||||
return fmt.Errorf("notification subject is required")
|
||||
}
|
||||
ocs := n.ocsClient()
|
||||
ocs.Cred = ocs.Cred.WithUser(userID)
|
||||
richSubjectParams := notif.SubjectParams
|
||||
if richSubjectParams == nil {
|
||||
richSubjectParams = map[string]any{}
|
||||
}
|
||||
subjectParams := map[string]any{
|
||||
"rich_subject": notif.Subject,
|
||||
"rich_subject_params": richSubjectParams,
|
||||
}
|
||||
if notif.Message != "" {
|
||||
richMessageParams := notif.MessageParams
|
||||
if richMessageParams == nil {
|
||||
richMessageParams = map[string]any{}
|
||||
}
|
||||
subjectParams["rich_message"] = notif.Message
|
||||
subjectParams["rich_message_params"] = richMessageParams
|
||||
}
|
||||
if notif.Link != "" {
|
||||
subjectParams["link"] = notif.Link
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"params": map[string]any{
|
||||
"object": "app_api",
|
||||
"object_id": "app_api_id",
|
||||
"subject_type": "app_api_ex_app",
|
||||
"subject_params": subjectParams,
|
||||
},
|
||||
})
|
||||
_, err := ocs.Call(http.MethodPost, "apps/app_api/api/v1/notification", body)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package gonexapp_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.neitzel.de/konrad/go-nc-exapp"
|
||||
)
|
||||
|
||||
func authUserFromRequest(r *http.Request) string {
|
||||
raw := r.Header.Get("AUTHORIZATION-APP-API")
|
||||
decoded, err := base64.StdEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
parts := strings.SplitN(string(decoded), ":", 2)
|
||||
if len(parts) < 1 {
|
||||
return ""
|
||||
}
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
func TestAppAPINotificationsSendPostsForCredentialsUser(t *testing.T) {
|
||||
var (
|
||||
gotMethod string
|
||||
gotPath string
|
||||
gotUser string
|
||||
gotBody []byte
|
||||
)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod = r.Method
|
||||
gotPath = r.URL.Path
|
||||
gotUser = authUserFromRequest(r)
|
||||
gotBody, _ = io.ReadAll(r.Body)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ocs": map[string]any{"data": map[string]any{}}})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cred := gonexapp.Credentials{
|
||||
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "alice",
|
||||
}
|
||||
api := gonexapp.NewAppAPINotifications(cred)
|
||||
api.Client = srv.Client()
|
||||
api.OCS.Client = srv.Client()
|
||||
|
||||
err := api.Send(gonexapp.Notification{Subject: "Hello"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Fatalf("method=%q", gotMethod)
|
||||
}
|
||||
if !strings.Contains(gotPath, "apps/app_api/api/v1/notification") {
|
||||
t.Fatalf("path=%q", gotPath)
|
||||
}
|
||||
if gotUser != "alice" {
|
||||
t.Fatalf("auth user=%q", gotUser)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Params struct {
|
||||
Object string `json:"object"`
|
||||
ObjectID string `json:"object_id"`
|
||||
SubjectType string `json:"subject_type"`
|
||||
SubjectParams struct {
|
||||
RichSubject string `json:"rich_subject"`
|
||||
RichSubjectParams map[string]any `json:"rich_subject_params"`
|
||||
} `json:"subject_params"`
|
||||
} `json:"params"`
|
||||
}
|
||||
if err := json.Unmarshal(gotBody, &payload); err != nil {
|
||||
t.Fatalf("body: %v\n%s", err, gotBody)
|
||||
}
|
||||
if payload.Params.Object != "app_api" {
|
||||
t.Fatalf("object=%q", payload.Params.Object)
|
||||
}
|
||||
if payload.Params.ObjectID != "app_api_id" {
|
||||
t.Fatalf("object_id=%q", payload.Params.ObjectID)
|
||||
}
|
||||
if payload.Params.SubjectType != "app_api_ex_app" {
|
||||
t.Fatalf("subject_type=%q", payload.Params.SubjectType)
|
||||
}
|
||||
if payload.Params.SubjectParams.RichSubject != "Hello" {
|
||||
t.Fatalf("rich_subject=%q", payload.Params.SubjectParams.RichSubject)
|
||||
}
|
||||
if payload.Params.SubjectParams.RichSubjectParams == nil {
|
||||
t.Fatal("rich_subject_params is null")
|
||||
}
|
||||
if len(payload.Params.SubjectParams.RichSubjectParams) != 0 {
|
||||
t.Fatalf("rich_subject_params=%#v", payload.Params.SubjectParams.RichSubjectParams)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppAPINotificationsSendToImpersonatesGivenUser(t *testing.T) {
|
||||
var gotUser string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotUser = authUserFromRequest(r)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ocs": map[string]any{"data": map[string]any{}}})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cred := gonexapp.Credentials{
|
||||
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "alice",
|
||||
}
|
||||
api := gonexapp.NewAppAPINotifications(cred)
|
||||
api.Client = srv.Client()
|
||||
api.OCS.Client = srv.Client()
|
||||
|
||||
err := api.SendTo("bob", gonexapp.Notification{Subject: "Hello"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotUser != "bob" {
|
||||
t.Fatalf("auth user=%q, want bob", gotUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppAPINotificationsRejectsEmptySubject(t *testing.T) {
|
||||
called := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ocs": map[string]any{"data": map[string]any{}}})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cred := gonexapp.Credentials{
|
||||
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "alice",
|
||||
}
|
||||
api := gonexapp.NewAppAPINotifications(cred)
|
||||
api.Client = srv.Client()
|
||||
api.OCS.Client = srv.Client()
|
||||
|
||||
if err := api.Send(gonexapp.Notification{}); err == nil {
|
||||
t.Fatal("Send: expected error for empty Subject")
|
||||
}
|
||||
if err := api.SendTo("bob", gonexapp.Notification{Message: "no subject"}); err == nil {
|
||||
t.Fatal("SendTo: expected error for empty Subject")
|
||||
}
|
||||
if called {
|
||||
t.Fatal("OCS was called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppAPINotificationsSendRejectsEmptyUserID(t *testing.T) {
|
||||
called := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ocs": map[string]any{"data": map[string]any{}}})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cred := gonexapp.Credentials{
|
||||
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s",
|
||||
}
|
||||
api := gonexapp.NewAppAPINotifications(cred)
|
||||
api.Client = srv.Client()
|
||||
api.OCS.Client = srv.Client()
|
||||
|
||||
if err := api.Send(gonexapp.Notification{Subject: "Hello"}); err == nil {
|
||||
t.Fatal("expected error for empty UserID")
|
||||
}
|
||||
if called {
|
||||
t.Fatal("OCS was called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppAPINotificationsSendToRejectsEmptyUserID(t *testing.T) {
|
||||
called := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ocs": map[string]any{"data": map[string]any{}}})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cred := gonexapp.Credentials{
|
||||
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "alice",
|
||||
}
|
||||
api := gonexapp.NewAppAPINotifications(cred)
|
||||
api.Client = srv.Client()
|
||||
api.OCS.Client = srv.Client()
|
||||
|
||||
if err := api.SendTo("", gonexapp.Notification{Subject: "Hello"}); err == nil {
|
||||
t.Fatal("expected error for empty userID")
|
||||
}
|
||||
if called {
|
||||
t.Fatal("OCS was called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppAPINotificationsSendIncludesOptionalMessageAndLink(t *testing.T) {
|
||||
var gotBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotBody, _ = io.ReadAll(r.Body)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ocs": map[string]any{"data": map[string]any{}}})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cred := gonexapp.Credentials{
|
||||
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "alice",
|
||||
}
|
||||
api := gonexapp.NewAppAPINotifications(cred)
|
||||
api.Client = srv.Client()
|
||||
api.OCS.Client = srv.Client()
|
||||
|
||||
err := api.Send(gonexapp.Notification{
|
||||
Subject: "Hello",
|
||||
Message: "Details here",
|
||||
Link: "https://cloud.example/apps/checkdns",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Params struct {
|
||||
SubjectParams struct {
|
||||
RichMessage string `json:"rich_message"`
|
||||
RichMessageParams map[string]any `json:"rich_message_params"`
|
||||
Link string `json:"link"`
|
||||
} `json:"subject_params"`
|
||||
} `json:"params"`
|
||||
}
|
||||
if err := json.Unmarshal(gotBody, &payload); err != nil {
|
||||
t.Fatalf("body: %v\n%s", err, gotBody)
|
||||
}
|
||||
if payload.Params.SubjectParams.RichMessage != "Details here" {
|
||||
t.Fatalf("rich_message=%q", payload.Params.SubjectParams.RichMessage)
|
||||
}
|
||||
if payload.Params.SubjectParams.RichMessageParams == nil {
|
||||
t.Fatal("rich_message_params is null")
|
||||
}
|
||||
if payload.Params.SubjectParams.Link != "https://cloud.example/apps/checkdns" {
|
||||
t.Fatalf("link=%q", payload.Params.SubjectParams.Link)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppAPINotificationsSendSurfacesOCSStatus(t *testing.T) {
|
||||
for _, code := range []int{http.StatusBadRequest, http.StatusForbidden} {
|
||||
t.Run(http.StatusText(code), func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "nope", code)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cred := gonexapp.Credentials{
|
||||
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "alice",
|
||||
}
|
||||
api := gonexapp.NewAppAPINotifications(cred)
|
||||
api.Client = srv.Client()
|
||||
api.OCS.Client = srv.Client()
|
||||
|
||||
err := api.Send(gonexapp.Notification{Subject: "Hello"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
want := fmt.Sprintf("%d", code)
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error %q missing status %s", err, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package gonexapp
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EnvRequiredGroups is the conventional deploy env name for Required Groups.
|
||||
const EnvRequiredGroups = "REQUIRED_GROUPS"
|
||||
|
||||
// EnvRequiredGroupsCacheSeconds is the conventional deploy env name for Access Gate cache TTL.
|
||||
const EnvRequiredGroupsCacheSeconds = "REQUIRED_GROUPS_CACHE_SECONDS"
|
||||
|
||||
// DefaultCacheSeconds is used when REQUIRED_GROUPS_CACHE_SECONDS is unset or invalid.
|
||||
const DefaultCacheSeconds = 60
|
||||
|
||||
// ParseRequiredGroups splits a comma-separated Required Groups env value.
|
||||
func ParseRequiredGroups(s string) []string {
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ResolveRequiredGroups applies env override rules: unset uses codeDefault;
|
||||
// set (including empty) replaces the default.
|
||||
func ResolveRequiredGroups(envValue string, envSet bool, codeDefault []string) []string {
|
||||
if !envSet {
|
||||
return append([]string(nil), codeDefault...)
|
||||
}
|
||||
return ParseRequiredGroups(envValue)
|
||||
}
|
||||
|
||||
// ParseCacheSeconds parses REQUIRED_GROUPS_CACHE_SECONDS. Unset or invalid → defaultSec seconds; "0" → no cache.
|
||||
func ParseCacheSeconds(s string, defaultSec int) time.Duration {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return time.Duration(defaultSec) * time.Second
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil || n < 0 {
|
||||
return time.Duration(defaultSec) * time.Second
|
||||
}
|
||||
return time.Duration(n) * time.Second
|
||||
}
|
||||
@@ -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