Author SHA1 Message Date
Konrad NeitzelandCursor 9e2005cfb3 Raise the module Go version to 1.27.0.
Match the Workspace pin so language and stdlib features through 1.27 are allowed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 17:16:16 +02:00
Konrad NeitzelandCursor fc2f9f63c2 Add AppAPI Notifications and Users and Groups reads.
ExApps can Send/SendTo a bell for one Recipient and read group membership and directory OCS as the Requesting user, with Access Gate using UserGroups.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 14:44:42 +02:00
Konrad Neitzel 9c1c2f4310 Merge branch 'feature/top-menu-admin-required' 2026-08-28 12:43:08 +02:00
8 changed files with 674 additions and 43 deletions
+16 -4
View File
@@ -1,6 +1,6 @@
# go-nc-exapp # go-nc-exapp
Shared Go Library for Nextcloud ExApp Services: AppAPI authentication, OCS calls, per-user ExApp preferences, and optional Required Groups gating. 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 ## Language
@@ -9,23 +9,35 @@ The ExApp's shared secret and Nextcloud base URL, plus optional per-request user
_Avoid_: API key (generic), session token _Avoid_: API key (generic), session token
**Requesting user**: **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 _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**: **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. 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 _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**: **OCS**:
Nextcloud's legacy HTTP API surface under `/ocs/v2.php/…`. This Library requests JSON responses (`format=json`) for machine-readable bodies. 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 _Avoid_: assuming XML responses, REST-only Nextcloud APIs for ExApp prefs
**Required Groups**: **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. 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 _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**: **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 and top-menu script URLs under `/js/` stay ungated so Denied UI can load in the Nextcloud shell. 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 _Avoid_: Nextcloud middleware, HaRP ACL, admin bypass, gating the top-menu bootstrap script
**Top Menu visibility**: **Top Menu visibility**:
+11 -2
View File
@@ -1,6 +1,6 @@
# go-nc-exapp # go-nc-exapp
Shared Go library for Nextcloud ExApp Services: AppAPI authentication, OCS JSON calls, per-user ExApp preferences, and an optional Required Groups Access Gate. 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` (package `gonexapp`). Import: `gitea.neitzel.de/konrad/go-nc-exapp` (package `gonexapp`).
@@ -14,6 +14,8 @@ Import: `gitea.neitzel.de/konrad/go-nc-exapp` (package `gonexapp`).
- **UserFromRequest** — extract the requesting user from inbound AppAPI-proxied requests - **UserFromRequest** — extract the requesting user from inbound AppAPI-proxied requests
- **OCSClient** — authenticated OCS calls that always append `format=json` - **OCSClient** — authenticated OCS calls that always append `format=json`
- **AppAPIPreferences** — parameterized get/set of a string ExApp preference (caller supplies app id and key) - **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` - **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) - **Top Menu visibility** — `TopMenuAdminRequired` helper for deploy env `TOP_MENU_ADMIN_REQUIRED` (`0` / `1` for AppAPI top-menu OCS)
@@ -24,6 +26,9 @@ Import: `gitea.neitzel.de/konrad/go-nc-exapp` (package `gonexapp`).
- Top-menu, script, and iframe UI registration - Top-menu, script, and iframe UI registration
- WebDAV and file storage (see **go-nc-files**) - WebDAV and file storage (see **go-nc-files**)
- **Visit** folder resolution (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 ## Usage
@@ -40,6 +45,9 @@ cred := gonexapp.Credentials{
prefs := gonexapp.NewAppAPIPreferences(cred, "myexapp", "savedDefault") prefs := gonexapp.NewAppAPIPreferences(cred, "myexapp", "savedDefault")
value, err := prefs.Get() 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") groupsEnv, groupsSet := os.LookupEnv("REQUIRED_GROUPS")
groups := gonexapp.ResolveRequiredGroups(groupsEnv, groupsSet, nil) groups := gonexapp.ResolveRequiredGroups(groupsEnv, groupsSet, nil)
ttl := gonexapp.ParseCacheSeconds(os.Getenv("REQUIRED_GROUPS_CACHE_SECONDS"), gonexapp.DefaultCacheSeconds) ttl := gonexapp.ParseCacheSeconds(os.Getenv("REQUIRED_GROUPS_CACHE_SECONDS"), gonexapp.DefaultCacheSeconds)
@@ -78,7 +86,7 @@ Runnable package examples: `go test -run Example`.
## Domain language ## Domain language
See [CONTEXT.md](./CONTEXT.md) for AppAPI credentials, Requesting user, ExApp preference, OCS, Required Groups, Access Gate, and Top Menu visibility 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 ## Testing
@@ -89,3 +97,4 @@ Unit tests use `httptest` fake OCS servers. No live Nextcloud is required for Li
- **go-nc-files** — WebDAV, Working Folder, Saved Default, Visit resolution - **go-nc-files** — WebDAV, Working Folder, Saved Default, Visit resolution
- Workspace ADR 0013 — extraction from CheckDNS - 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/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
+1 -36
View File
@@ -1,10 +1,7 @@
package gonexapp package gonexapp
import ( import (
"encoding/json"
"fmt"
"net/http" "net/http"
"net/url"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -113,39 +110,7 @@ func (g *AccessGate) cacheKey(userID string) string {
} }
func (g *AccessGate) fetchUserGroups(userID string) ([]string, error) { func (g *AccessGate) fetchUserGroups(userID string) ([]string, error) {
ocs := g.ocsClient(userID) return Groups{Cred: g.Cred, Client: g.Client, OCS: g.OCS}.UserGroups(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)
}
func (g *AccessGate) ocsClient(userID string) OCSClient {
c := g.OCS
if c.Cred.BaseURL == "" {
c.Cred = g.Cred
}
c.Cred = c.Cred.WithUser(userID)
if c.Client == nil {
c.Client = g.Client
}
return c
}
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
} }
func (g *AccessGate) cachedAllowed(key string) bool { func (g *AccessGate) cachedAllowed(key string) bool {
+1 -1
View File
@@ -1,3 +1,3 @@
module gitea.neitzel.de/konrad/go-nc-exapp module gitea.neitzel.de/konrad/go-nc-exapp
go 1.26.4 go 1.27.0
+95
View File
@@ -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
View File
@@ -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)
}
}
+95
View File
@@ -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
}
+268
View File
@@ -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)
}
})
}
}