Files
tajniak81andClaude Opus 5 a7cab50e06 Apprise: a gateway to hand a message to, not a hundred protocols to carry
Apprise is a Python library that speaks 100+ notification services behind one URL
grammar — mailto://, tgram://, ntfy://, discord://. None of that is portable to a
server that takes no dependencies, and none of it needs to be: caronc/apprise-api
wraps the library in HTTP and is meant to run as a container beside us. So the
connector carries no notification protocols of its own. It posts a body to an
endpoint the operator runs and lets Apprise fan it out, which is also why adding
a service later costs nothing here.

Targets are addressed one of two ways and configKey is the switch. Stateful means
the URLs live on the Apprise server under a key, narrowed by a tag expression, and
recipients are then edited there — no credential for any downstream service is
ever held in DriverVault. Stateless means the URLs travel with the request, from a
secret config field, which is simpler for one destination and worse for ten. A
call that names its own key or urls takes that destination alone rather than
merging with the configured one: honouring a caller's URLs while still falling
back to the configured key would deliver the message somewhere nobody asked for.

baseUrl is Required, which no other connector's address is. Toyota, Anker and
Greencell leave everything blank at the global layer because the superadmin → org
→ user cascade exists to fill it in, and a blank there means "let the user
choose". There is no cascade behind this one — a notification gateway is
infrastructure the operator runs, not an account a driver owns — so nothing
further down can supply the address, and a blank is simply a plugin that cannot
work. Better to fail at enable than at the first notification nobody sees.

Three limits are choices rather than gaps. /add and /del are not implemented: the
Apprise config belongs to the operator, we post to it, and a connector that can
delete a notification config has a wider blast radius than one that can only send
through it. privacy=1 is forced on /json/urls rather than offered as a parameter,
so a target listing reads mailto://user:****@host and downstream tokens stay on
the Apprise side of the wire. Attachments are remote URLs the Apprise server
fetches; multipart upload is the API's own path for files and not ours.

Health follows the rule Greencell set. A reachable server whose config holds
nothing to notify is degraded, not down: the half we address works and the missing
half is the operator's config. Two cases earn their own line — a config key set
against a server running with stateful mode disabled can never resolve, and /status
answers 417 rather than 500 when Apprise finds a problem with itself, so that is a
parsed answer and not a transport failure. A proxy that strips our Accept header
gets the same codes back as plain text, which is read rather than called
unreadable; an HTML error page from something that is not Apprise is not, and a
test pins the difference.

Notifications needed a category of their own, and that is the one change outside
the plugin: the constant, the tab order in PluginsCard.vue, and the label in all
three panel languages. The cost is now written down in the plugins README beside
the Descriptor example, since the previous five categories predate anyone having
to add a sixth.

The plugin's tests run against an apprise-api stand-in built from that project's
views.py — both notify paths, the override rules, 204-as-empty against
424-as-failure, and every health branch. builtin_test.go is the other half: the
blank-import list in builtin.go is a silent failure mode, since a connector left
out of it compiles, passes its own tests, and never appears in the panel. What is
not covered is a live instance; there is no Docker on this machine, so the wire
contract comes from reading upstream's source rather than from running it, and a
smoke test against a real deployment is still worth doing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 22:43:07 +02:00

823 lines
27 KiB
Go

package apprise
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"drivervault/apiserver/internal/plugins"
)
// ---- descriptor & config -----------------------------------------------------
func TestDescriptor(t *testing.T) {
d := (&Plugin{}).Descriptor()
if d.Name != "apprise" {
t.Fatalf("name = %q, want apprise", d.Name)
}
if d.Kind != plugins.KindBuiltin {
t.Fatalf("kind = %q, want builtin", d.Kind)
}
if d.Category != plugins.CategoryNotifications {
t.Fatalf("category = %q, want %q", d.Category, plugins.CategoryNotifications)
}
actions := map[string]bool{}
for _, c := range d.Capabilities {
actions[c.ID] = true
}
for _, want := range []string{"notify", "targets", "services", "status"} {
if !actions[want] {
t.Errorf("capability %q should be advertised", want)
}
}
fields := map[string]plugins.ConfigField{}
for _, f := range d.ConfigFields {
fields[f.Key] = f
}
for _, k := range []string{"baseUrl", "configKey", "urls", "tag", "format", "username", "password", "timeout"} {
if _, ok := fields[k]; !ok {
t.Errorf("config field %q should be present", k)
}
}
// There is no per-user cascade behind this connector: without an address it
// cannot work at all, so the panel must refuse to enable it blank.
if !fields["baseUrl"].Required {
t.Error("baseUrl must be required — nothing else can supply it")
}
// Apprise URLs embed the credentials of every service they address.
if !fields["urls"].Secret || !fields["password"].Secret {
t.Error("urls and password must both be marked secret")
}
if fields["configKey"].Required || fields["urls"].Required {
t.Error("the destination is either a key or URLs; neither may be required on its own")
}
if got := fields["format"].Type; got != "select" {
t.Errorf("format type = %q, want select", got)
}
if got := fields["tag"].Default; got != tagAll {
t.Errorf("tag default = %q, want %q", got, tagAll)
}
}
func TestNormalizeBaseURL(t *testing.T) {
cases := []struct {
in, want string
wantErr bool
}{
{in: "", want: ""},
{in: "http://apprise:8000", want: "http://apprise:8000"},
{in: " http://apprise:8000/ ", want: "http://apprise:8000"},
{in: "https://apprise.example.com", want: "https://apprise.example.com"},
// A bare host is the common shape on an internal network.
{in: "10.2.1.10:8000", want: "http://10.2.1.10:8000"},
// A reverse proxy may publish the API under a path prefix.
{in: "https://home.example.com/apprise/", want: "https://home.example.com/apprise"},
{in: "ftp://apprise", wantErr: true},
{in: "http://", wantErr: true},
}
for _, c := range cases {
got, err := normalizeBaseURL(c.in)
if c.wantErr {
if err == nil {
t.Errorf("normalizeBaseURL(%q) = %q, want an error", c.in, got)
}
continue
}
if err != nil {
t.Errorf("normalizeBaseURL(%q): %v", c.in, err)
continue
}
if got != c.want {
t.Errorf("normalizeBaseURL(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestInitRejectsUnusableConfigKey(t *testing.T) {
p := &Plugin{}
err := p.Init(context.Background(), map[string]string{
"baseUrl": "http://apprise:8000", "configKey": "not a key",
})
if err == nil {
t.Fatal("a config key with a space should be rejected at Init")
}
if !strings.Contains(err.Error(), "not usable") {
t.Errorf("error should say what is wrong with the key, got: %v", err)
}
}
func TestParseTimeoutClamps(t *testing.T) {
cases := map[string]time.Duration{
"": defaultTimeout,
"abc": defaultTimeout,
"0": defaultTimeout,
"-5": defaultTimeout,
"30": 30 * time.Second,
"9000": maxTimeout,
}
for in, want := range cases {
if got := parseTimeout(in); got != want {
t.Errorf("parseTimeout(%q) = %v, want %v", in, got, want)
}
}
}
func TestCountURLs(t *testing.T) {
cases := map[string]int{
"": 0,
" ": 0,
"ntfy://topic": 1,
"mailto://a@b, ntfy://t": 2,
"a://one b://two c://three": 3,
}
for in, want := range cases {
if got := countURLs(in); got != want {
t.Errorf("countURLs(%q) = %d, want %d", in, got, want)
}
}
}
// ---- test server -------------------------------------------------------------
// call records one request the fake Apprise server received.
type call struct {
method string
path string
query string
accept string
ctype string
user string
pass string
hasAuth bool
payload map[string]any
}
// fakeApprise is an apprise-api stand-in: it records what it was asked and
// answers from the handler the test installs.
type fakeApprise struct {
t *testing.T
srv *httptest.Server
calls []call
handler func(w http.ResponseWriter, r *http.Request, c call)
}
func newFake(t *testing.T, handler func(w http.ResponseWriter, r *http.Request, c call)) *fakeApprise {
t.Helper()
f := &fakeApprise{t: t, handler: handler}
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c := call{
method: r.Method, path: r.URL.Path, query: r.URL.RawQuery,
accept: r.Header.Get("Accept"), ctype: r.Header.Get("Content-Type"),
}
c.user, c.pass, c.hasAuth = r.BasicAuth()
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&c.payload)
}
f.calls = append(f.calls, c)
f.handler(w, r, c)
}))
t.Cleanup(f.srv.Close)
return f
}
func (f *fakeApprise) last() call {
f.t.Helper()
if len(f.calls) == 0 {
f.t.Fatal("the plugin made no request")
}
return f.calls[len(f.calls)-1]
}
// writeJSON answers with a status and body, the way apprise-api does for a JSON
// request.
func writeJSON(w http.ResponseWriter, status int, body string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}
// statusOK is the /status body of a healthy default apprise-api deployment.
const statusOK = `{"config_lock":false,"attach_lock":false,"stateful_enabled":true,
"max_attachments":6,"attach_size":200,
"status":{"persistent_storage":true,"can_write_config":true,"can_write_attach":true,"details":["OK"]}}`
// newPlugin builds a plugin pointed at the fake server.
func newPlugin(t *testing.T, f *fakeApprise, cfg map[string]string) *Plugin {
t.Helper()
if cfg == nil {
cfg = map[string]string{}
}
cfg["baseUrl"] = f.srv.URL
p := &Plugin{}
if err := p.Init(context.Background(), cfg); err != nil {
t.Fatalf("Init: %v", err)
}
return p
}
func invoke(t *testing.T, p *Plugin, action string, params string, into any) error {
t.Helper()
var raw json.RawMessage
if params != "" {
raw = json.RawMessage(params)
}
out, err := p.Invoke(context.Background(), action, raw)
if err != nil {
return err
}
if into != nil {
if err := json.Unmarshal(out, into); err != nil {
t.Fatalf("decoding the %s result: %v", action, err)
}
}
return nil
}
// ---- notify ------------------------------------------------------------------
func TestNotifyStateful(t *testing.T) {
f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
writeJSON(w, http.StatusOK, `{"error":null,"details":[["INFO","2026-08-30 10:00:00,000","Sent Telegram notification."]]}`)
})
p := newPlugin(t, f, map[string]string{"configKey": "drivervault", "tag": "ops", "format": "markdown"})
var res NotifyResult
if err := invoke(t, p, "notify", `{"body":"Charge complete","title":"Kia EV6","type":"success"}`, &res); err != nil {
t.Fatalf("notify: %v", err)
}
c := f.last()
if c.method != http.MethodPost || c.path != "/notify/drivervault" {
t.Fatalf("request = %s %s, want POST /notify/drivervault", c.method, c.path)
}
// Both headers are what make apprise-api answer in JSON instead of HTML.
if !strings.Contains(c.accept, "application/json") || !strings.Contains(c.ctype, "application/json") {
t.Errorf("Accept = %q, Content-Type = %q; both should be application/json", c.accept, c.ctype)
}
if c.hasAuth {
t.Error("no Basic auth is configured, so none should be sent")
}
want := map[string]any{
"body": "Charge complete", "title": "Kia EV6",
"type": "success", "format": "markdown", "tag": "ops",
}
for k, v := range want {
if c.payload[k] != v {
t.Errorf("payload[%q] = %v, want %v", k, c.payload[k], v)
}
}
if _, sent := c.payload["urls"]; sent {
t.Error("a stateful call must not carry URLs")
}
if !res.OK || res.Mode != "stateful" || res.Key != "drivervault" || res.Tag != "ops" {
t.Fatalf("result = %+v", res)
}
if len(res.Log) != 1 || res.Log[0].Level != "INFO" || !strings.Contains(res.Log[0].Message, "Telegram") {
t.Errorf("delivery log = %+v", res.Log)
}
if res.SentAt.IsZero() {
t.Error("a delivered notification should be stamped")
}
}
func TestNotifyStatelessUsesConfiguredURLs(t *testing.T) {
f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
writeJSON(w, http.StatusOK, `{"error":null,"details":[]}`)
})
p := newPlugin(t, f, map[string]string{"urls": "ntfy://topic, mailto://user:pass@host"})
var res NotifyResult
if err := invoke(t, p, "notify", `{"body":"Service due"}`, &res); err != nil {
t.Fatalf("notify: %v", err)
}
c := f.last()
if c.path != "/notify" {
t.Fatalf("path = %q, want /notify (the stateless endpoint)", c.path)
}
if c.payload["urls"] != "ntfy://topic, mailto://user:pass@host" {
t.Errorf("urls = %v, want the configured list", c.payload["urls"])
}
if _, sent := c.payload["tag"]; sent {
t.Error("a tag means nothing to a stateless call and should not be sent")
}
// Defaults fill in for what the caller left out.
if c.payload["type"] != typeInfo || c.payload["format"] != formatText {
t.Errorf("defaults = type %v / format %v, want info / text", c.payload["type"], c.payload["format"])
}
if res.Mode != "stateless" || res.Targets != 2 {
t.Errorf("result = %+v, want stateless with 2 targets", res)
}
if res.Key != "" {
t.Errorf("a stateless result should name no key, got %q", res.Key)
}
}
// A call that names its own URLs must not be sent to the configured key as well
// — the caller chose the destination.
func TestNotifyCallerURLsOverrideConfiguredKey(t *testing.T) {
f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
writeJSON(w, http.StatusOK, `{"error":null,"details":[]}`)
})
p := newPlugin(t, f, map[string]string{"configKey": "drivervault"})
var res NotifyResult
if err := invoke(t, p, "notify", `{"body":"one-off","urls":"ntfy://alerts"}`, &res); err != nil {
t.Fatalf("notify: %v", err)
}
if c := f.last(); c.path != "/notify" || c.payload["urls"] != "ntfy://alerts" {
t.Fatalf("request = %s with urls %v, want the stateless endpoint with the caller's URL", c.path, c.payload["urls"])
}
if res.Mode != "stateless" {
t.Errorf("mode = %q, want stateless", res.Mode)
}
}
func TestNotifyKeyParamOverridesConfiguredKey(t *testing.T) {
f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
writeJSON(w, http.StatusOK, `{"error":null,"details":[]}`)
})
p := newPlugin(t, f, map[string]string{"configKey": "drivervault", "urls": "ntfy://ignored"})
if err := invoke(t, p, "notify", `{"body":"hi","key":"fleet","tag":"night"}`, nil); err != nil {
t.Fatalf("notify: %v", err)
}
c := f.last()
if c.path != "/notify/fleet" {
t.Fatalf("path = %q, want /notify/fleet", c.path)
}
if c.payload["tag"] != "night" {
t.Errorf("tag = %v, want the per-call night", c.payload["tag"])
}
if _, sent := c.payload["urls"]; sent {
t.Error("the configured URLs must not ride along with a keyed call")
}
}
func TestNotifyAttachAcceptsStringOrList(t *testing.T) {
f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
writeJSON(w, http.StatusOK, `{"error":null,"details":[]}`)
})
p := newPlugin(t, f, map[string]string{"urls": "ntfy://t"})
if err := invoke(t, p, "notify", `{"body":"b","attach":"https://example.com/a.png"}`, nil); err != nil {
t.Fatalf("notify with one attachment: %v", err)
}
if got := f.last().payload["attach"]; len(got.([]any)) != 1 {
t.Errorf("attach = %v, want a one-element list", got)
}
if err := invoke(t, p, "notify", `{"body":"b","attach":["https://example.com/a.png","https://example.com/b.pdf"]}`, nil); err != nil {
t.Fatalf("notify with two attachments: %v", err)
}
if got := f.last().payload["attach"]; len(got.([]any)) != 2 {
t.Errorf("attach = %v, want a two-element list", got)
}
// An empty string is no attachment at all, not an empty one.
if err := invoke(t, p, "notify", `{"body":"b","attach":""}`, nil); err != nil {
t.Fatalf("notify with an empty attachment: %v", err)
}
if _, sent := f.last().payload["attach"]; sent {
t.Error("an empty attach should not be sent")
}
}
func TestNotifyValidation(t *testing.T) {
f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
writeJSON(w, http.StatusOK, `{"error":null}`)
})
p := newPlugin(t, f, map[string]string{"urls": "ntfy://t"})
cases := map[string]string{
"empty body": `{"body":" "}`,
"bad type": `{"body":"b","type":"urgent"}`,
"bad key": `{"body":"b","key":"has space"}`,
"bad params": `{"body":`,
"bad attach": `{"body":"b","attach":{"url":"x"}}`,
}
for name, params := range cases {
if err := invoke(t, p, "notify", params, nil); err == nil {
t.Errorf("%s should have been rejected", name)
}
}
if len(f.calls) != 0 {
t.Errorf("nothing invalid should reach the server, got %d call(s)", len(f.calls))
}
// An unrecognised format is not worth failing a notification over: Apprise
// converts formats anyway, so it falls back to the configured one.
if err := invoke(t, p, "notify", `{"body":"b","format":"yaml"}`, nil); err != nil {
t.Fatalf("an odd format should fall back, not fail: %v", err)
}
if got := f.last().payload["format"]; got != formatText {
t.Errorf("format = %v, want the configured %q", got, formatText)
}
}
func TestNotifyReportsDeliveryFailure(t *testing.T) {
f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
writeJSON(w, statusFailedDependency, `{"error":"One or more notification could not be sent",
"details":[["WARNING","2026-08-30 10:00:00,000","Failed to send Discord notification: 401"]]}`)
})
p := newPlugin(t, f, map[string]string{"configKey": "drivervault"})
err := invoke(t, p, "notify", `{"body":"b"}`, nil)
if err == nil {
t.Fatal("a 424 means nothing was delivered and must surface as an error")
}
// The log line is the only place Apprise says which service refused.
if !strings.Contains(err.Error(), "Discord") || !strings.Contains(err.Error(), "401") {
t.Errorf("error should carry the delivery log, got: %v", err)
}
}
func TestNotifyUnknownKeyIsExplained(t *testing.T) {
f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
w.WriteHeader(http.StatusNoContent)
})
p := newPlugin(t, f, map[string]string{"configKey": "missing"})
err := invoke(t, p, "notify", `{"body":"b"}`, nil)
if err == nil {
t.Fatal("a 204 means the key holds nothing and must not read as success")
}
if !strings.Contains(err.Error(), "missing") {
t.Errorf("error should name the key, got: %v", err)
}
}
func TestBasicAuthIsSentWhenConfigured(t *testing.T) {
f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
writeJSON(w, http.StatusOK, `{"error":null}`)
})
p := newPlugin(t, f, map[string]string{"urls": "ntfy://t", "username": "ops", "password": "s3cret"})
if err := invoke(t, p, "notify", `{"body":"b"}`, nil); err != nil {
t.Fatalf("notify: %v", err)
}
c := f.last()
if !c.hasAuth || c.user != "ops" || c.pass != "s3cret" {
t.Errorf("Basic auth = %q/%q (present %v), want ops/s3cret", c.user, c.pass, c.hasAuth)
}
}
// ---- targets -----------------------------------------------------------------
const targetsBody = `{"tags":["ops","night"],"urls":[
{"id":"a1","service_name":"Telegram","enabled":true,"url":"tgram://****/1234","tags":["ops"]},
{"id":"b2","service_name":"E-Mail","enabled":true,"url":"mailto://user:****@host","tags":["ops","night"]}]}`
func TestTargetsForcesPrivacy(t *testing.T) {
f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
writeJSON(w, http.StatusOK, targetsBody)
})
p := newPlugin(t, f, map[string]string{"configKey": "drivervault"})
var res TargetsResult
if err := invoke(t, p, "targets", "", &res); err != nil {
t.Fatalf("targets: %v", err)
}
c := f.last()
if c.path != "/json/urls/drivervault" {
t.Fatalf("path = %q, want /json/urls/drivervault", c.path)
}
// privacy=1 is what keeps downstream tokens on the Apprise side.
if !strings.Contains(c.query, "privacy=1") {
t.Errorf("query = %q, want privacy=1", c.query)
}
if !strings.Contains(c.query, "tag="+tagAll) {
t.Errorf("query = %q, want the default tag=%s", c.query, tagAll)
}
if res.Count != 2 || len(res.Targets) != 2 {
t.Fatalf("count = %d, want 2", res.Count)
}
if res.Targets[0].ServiceName != "Telegram" || res.Targets[0].ID != "a1" {
t.Errorf("first target = %+v", res.Targets[0])
}
if len(res.Tags) != 2 {
t.Errorf("tags = %v, want both", res.Tags)
}
}
func TestTargetsEmptyKeyIsNotAFailure(t *testing.T) {
f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
w.WriteHeader(http.StatusNoContent)
})
p := newPlugin(t, f, map[string]string{"configKey": "fresh"})
var res TargetsResult
if err := invoke(t, p, "targets", "", &res); err != nil {
t.Fatalf("a key with no configuration is an empty list, not an error: %v", err)
}
if res.Count != 0 || res.Targets == nil {
t.Errorf("result = %+v, want an empty (non-null) target list", res)
}
}
func TestTargetsNeedsAKey(t *testing.T) {
f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
writeJSON(w, http.StatusOK, targetsBody)
})
p := newPlugin(t, f, map[string]string{"urls": "ntfy://t"})
if err := invoke(t, p, "targets", "", nil); err == nil {
t.Fatal("targets without a key anywhere should be refused")
}
if len(f.calls) != 0 {
t.Error("the refusal should not reach the server")
}
// A key given per call is enough.
if err := invoke(t, p, "targets", `{"key":"fleet","tag":"night"}`, nil); err != nil {
t.Fatalf("targets with a per-call key: %v", err)
}
if c := f.last(); c.path != "/json/urls/fleet" || !strings.Contains(c.query, "tag=night") {
t.Errorf("request = %s?%s, want the per-call key and tag", c.path, c.query)
}
}
// ---- services ----------------------------------------------------------------
const detailsBody = `{"version":"1.9.4","asset":{},"schemas":[
{"service_name":"Telegram","service_url":"https://telegram.org/","setup_url":"https://github.com/caronc/apprise/wiki/Notify_telegram",
"category":"native","attachment_support":true,"protocols":null,"secure_protocols":["tgram"],
"details":{"templates":["{schema}://{bot_token}"]}},
{"service_name":"Ntfy","service_url":"https://ntfy.sh/","setup_url":"",
"category":"native","attachment_support":true,"protocols":["ntfy"],"secure_protocols":["ntfys"],
"details":{"templates":["{schema}://{topic}"]}}]}`
func TestServices(t *testing.T) {
f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
writeJSON(w, http.StatusOK, detailsBody)
})
p := newPlugin(t, f, nil)
var res ServicesResult
if err := invoke(t, p, "services", "", &res); err != nil {
t.Fatalf("services: %v", err)
}
if c := f.last(); c.path != "/details" || c.query != "" {
t.Fatalf("request = %s?%s, want /details with no query", c.path, c.query)
}
if res.Version != "1.9.4" || res.Count != 2 {
t.Fatalf("result = version %q, count %d", res.Version, res.Count)
}
tg := res.Services[0]
if tg.Name != "Telegram" || !tg.AttachmentSupport || len(tg.SecureProtocols) != 1 {
t.Errorf("Telegram = %+v", tg)
}
if tg.Protocols != nil {
t.Errorf("a null protocol list should stay empty, got %v", tg.Protocols)
}
if tg.Enabled != nil {
t.Error("enabled is only reported when disabled services were asked for")
}
// The catalogue's per-service templates are dropped: they are hundreds of KiB
// and nothing here consumes them.
out, _ := json.Marshal(res)
if strings.Contains(string(out), "templates") {
t.Error("the raw service templates should not be passed through")
}
if err := invoke(t, p, "services", `{"all":true}`, nil); err != nil {
t.Fatalf("services all: %v", err)
}
if c := f.last(); !strings.Contains(c.query, "all=yes") {
t.Errorf("query = %q, want all=yes", c.query)
}
}
// ---- status ------------------------------------------------------------------
func TestStatusJSON(t *testing.T) {
f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
writeJSON(w, http.StatusOK, statusOK)
})
p := newPlugin(t, f, nil)
var st Status
if err := invoke(t, p, "status", "", &st); err != nil {
t.Fatalf("status: %v", err)
}
if !st.OK || !st.StatefulEnabled || !st.CanWriteConfig || st.MaxAttachments != 6 {
t.Fatalf("status = %+v", st)
}
if len(st.Details) != 1 || st.Details[0] != "OK" {
t.Errorf("details = %v, want [OK]", st.Details)
}
}
// Apprise answers 417 — not 500 — when it finds a problem with itself, and the
// body still says which. That is an answer, not a transport failure.
func TestStatusProblemIsStillParsed(t *testing.T) {
f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
writeJSON(w, statusExpectationFail, `{"config_lock":false,"attach_lock":false,"stateful_enabled":true,
"max_attachments":6,"attach_size":200,
"status":{"persistent_storage":false,"can_write_config":false,"can_write_attach":true,
"details":["CONFIG_PERMISSION_ISSUE"]}}`)
})
p := newPlugin(t, f, nil)
var st Status
if err := invoke(t, p, "status", "", &st); err != nil {
t.Fatalf("a 417 should still parse: %v", err)
}
if st.OK {
t.Error("a permission issue is not OK")
}
if len(st.Details) != 1 || st.Details[0] != "CONFIG_PERMISSION_ISSUE" {
t.Errorf("details = %v", st.Details)
}
}
// A proxy that strips the Accept header, or an older release, answers /status in
// plain text with the same codes.
func TestStatusPlainTextFallback(t *testing.T) {
f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte("OK"))
})
p := newPlugin(t, f, nil)
var st Status
if err := invoke(t, p, "status", "", &st); err != nil {
t.Fatalf("plain-text status should be read, not rejected: %v", err)
}
if !st.OK || len(st.Details) != 1 || st.Details[0] != "OK" {
t.Errorf("status = %+v", st)
}
}
// An HTML error page from something that is not Apprise must not be mistaken for
// a health line.
func TestStatusRejectsNonAppriseBody(t *testing.T) {
f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte("<html><body>Not Apprise</body></html>"))
})
p := newPlugin(t, f, nil)
if err := invoke(t, p, "status", "", nil); err == nil {
t.Fatal("an HTML page should not pass as a status")
}
}
// ---- health ------------------------------------------------------------------
func TestHealthCheckOKWithConfigKey(t *testing.T) {
f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
if strings.HasPrefix(c.path, "/json/urls/") {
writeJSON(w, http.StatusOK, targetsBody)
return
}
writeJSON(w, http.StatusOK, statusOK)
})
p := newPlugin(t, f, map[string]string{"configKey": "drivervault"})
h := p.HealthCheck(context.Background())
if h.Status != plugins.StatusOK {
t.Fatalf("status = %q (%s), want ok", h.Status, h.Detail)
}
if !strings.Contains(h.Detail, "2 target") {
t.Errorf("detail should count the targets, got %q", h.Detail)
}
}
// A reachable server whose config holds nothing to notify is degraded, not down:
// the half we address works, and the missing half is the operator's config.
func TestHealthCheckDegradedWhenKeyIsEmpty(t *testing.T) {
f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
if strings.HasPrefix(c.path, "/json/urls/") {
w.WriteHeader(http.StatusNoContent)
return
}
writeJSON(w, http.StatusOK, statusOK)
})
p := newPlugin(t, f, map[string]string{"configKey": "drivervault"})
h := p.HealthCheck(context.Background())
if h.Status != plugins.StatusDegraded {
t.Fatalf("status = %q, want degraded", h.Status)
}
if !strings.Contains(h.Detail, "no notification URLs") {
t.Errorf("detail = %q", h.Detail)
}
}
func TestHealthCheckDegradedWithNoDestination(t *testing.T) {
f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
writeJSON(w, http.StatusOK, statusOK)
})
p := newPlugin(t, f, nil)
h := p.HealthCheck(context.Background())
if h.Status != plugins.StatusDegraded {
t.Fatalf("status = %q, want degraded", h.Status)
}
if !strings.Contains(h.Detail, "nothing to notify") {
t.Errorf("detail = %q", h.Detail)
}
if len(f.calls) != 1 {
t.Errorf("with nothing configured there is no key to read, so one call is enough; got %d", len(f.calls))
}
}
// A config key against a server that has stateful mode switched off can never
// resolve, and saying so is more useful than an empty target list.
func TestHealthCheckDegradedWhenStatefulDisabled(t *testing.T) {
f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
writeJSON(w, http.StatusOK, strings.Replace(statusOK, `"stateful_enabled":true`, `"stateful_enabled":false`, 1))
})
p := newPlugin(t, f, map[string]string{"configKey": "drivervault"})
h := p.HealthCheck(context.Background())
if h.Status != plugins.StatusDegraded {
t.Fatalf("status = %q, want degraded", h.Status)
}
if !strings.Contains(h.Detail, "stateful config disabled") {
t.Errorf("detail = %q", h.Detail)
}
}
func TestHealthCheckOKStateless(t *testing.T) {
f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
writeJSON(w, http.StatusOK, statusOK)
})
p := newPlugin(t, f, map[string]string{"urls": "ntfy://a, ntfy://b, ntfy://c"})
h := p.HealthCheck(context.Background())
if h.Status != plugins.StatusOK {
t.Fatalf("status = %q (%s), want ok", h.Status, h.Detail)
}
if !strings.Contains(h.Detail, "3 configured URL") {
t.Errorf("detail = %q", h.Detail)
}
}
func TestHealthCheckDownWhenUnreachable(t *testing.T) {
f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {})
p := newPlugin(t, f, nil)
f.srv.Close() // the address stops answering
h := p.HealthCheck(context.Background())
if h.Status != plugins.StatusDown {
t.Fatalf("status = %q, want down", h.Status)
}
}
func TestHealthCheckWithoutBaseURLIsDown(t *testing.T) {
p := &Plugin{}
if err := p.Init(context.Background(), map[string]string{}); err != nil {
t.Fatalf("Init with no address should not fail, it should read as unhealthy: %v", err)
}
h := p.HealthCheck(context.Background())
if h.Status != plugins.StatusDown {
t.Fatalf("status = %q, want down", h.Status)
}
if !strings.Contains(h.Detail, "base URL") {
t.Errorf("detail should say what is missing, got %q", h.Detail)
}
}
// ---- contract ----------------------------------------------------------------
func TestUnknownAction(t *testing.T) {
p := &Plugin{}
if _, err := p.Invoke(context.Background(), "explode", nil); err == nil {
t.Fatal("an unknown action should be refused")
}
}
func TestShutdownIsSafe(t *testing.T) {
if err := (&Plugin{}).Shutdown(context.Background()); err != nil {
t.Fatalf("Shutdown: %v", err)
}
}
// The manager builds an instance and may never reach Init before a probe; that
// must not panic on a nil client.
func TestUninitializedPluginDoesNotPanic(t *testing.T) {
p := &Plugin{}
if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDown {
t.Fatalf("status = %q, want down", h.Status)
}
}
func TestRegisteredWithTheRegistry(t *testing.T) {
// Register panics on a duplicate, so a second registration under the same
// name would already have failed at init. This pins the name itself.
if got := (&Plugin{}).Descriptor().Name; got != "apprise" {
t.Fatalf("registered name = %q", got)
}
}