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>
This commit is contained in:
tajniak81
2026-08-30 22:43:07 +02:00
co-authored by Claude Opus 5
parent a49d48f659
commit a7cab50e06
15 changed files with 1919 additions and 21 deletions
+5 -4
View File
@@ -35,7 +35,7 @@ internal/
├── pb/client.go # PocketBase superuser client (runtime-retargetable)
└── plugins/ # plugin system — see plugins/README.md
├── plugin.go manager.go external.go doc.go
└── builtin/ # built-in connectors: toyota, ankersolix, greencell
└── builtin/ # built-in connectors: toyota, ankersolix, greencell, apprise
panel/ # Vue 3 + Tailwind panel source
scripts/ # Node/Python maintenance scripts
bin/api-server.exe # prebuilt binary the deployment runs
@@ -296,10 +296,11 @@ state and global config persist to PocketBase, in the `app_settings` singleton
the same place the per-org and per-user layers of the cascade live.
See **[`internal/plugins/README.md`](internal/plugins/README.md)** for the full
guide. Three built-in connectors ship today — **Toyota Connected** (`toyota`,
guide. Four built-in connectors ship today — **Toyota Connected** (`toyota`,
read-only MyToyota vehicle data), the **Anker Solix** V1 EV charger
(`anker-solix`) and the **Greencell** HabuDen EV charger (`greencell`) and any
number of external HTTP plugins can be registered at runtime with no rebuild.
(`anker-solix`), the **Greencell** HabuDen EV charger (`greencell`) and
**Apprise** (`apprise`, notifications) — and any number of external HTTP plugins
can be registered at runtime with no rebuild.
### Integrations & charging control
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#2563eb" />
<title>DriverVault · API Server</title>
<script type="module" crossorigin src="/assets/index-CvY2-bIX.js"></script>
<script type="module" crossorigin src="/assets/index-CvNOI6h4.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C26jpIre.css">
</head>
<body>
+24 -10
View File
@@ -51,6 +51,7 @@ Descriptor{
Provider: "ACME Corp", // human label
Version: "1.0.0",
Kind: plugins.KindBuiltin, // or KindExternal
Category: plugins.CategoryAPIsExternal, // which panel tab it lands under
Capabilities: []plugins.Capability{
{ID: "widgets.list", Method: "GET", Endpoint: "/widgets", Description: "List widgets."},
},
@@ -63,6 +64,14 @@ Descriptor{
}
```
`Category` groups the plugin under a tab in the panel's Plugins card — one of
`CategoryVehicles`, `CategoryChargers`, `CategoryNotifications`,
`CategoryAPIsExternal`, `CategoryDrivesExternal` or `CategoryDrivesLocal`. An
empty or unrecognised value is shown under *Other APIs*, so a plugin never
disappears. Adding a **new** category means adding the constant in
[`plugin.go`](plugin.go), the tab order in `panel/src/components/PluginsCard.vue`,
and its label in all three `panel/src/i18n/*.json`.
`ConfigField.Type` is `"text"`, `"password"`, or `"number"` (form input hint).
Set **`Secret: true`** for credentials — the server never echoes them back in
clear; the panel shows a mask (`••••••••`), and on save a field left at the mask
@@ -167,10 +176,11 @@ go build -o bin/api-server.exe ./cmd/server
Restart the server. The plugin appears in the panel's **Plugins** card,
**disabled** by default.
> DriverVault ships three built-in connectors today — `toyota` (Toyota Connected /
> MyToyota, read-only vehicle data), `anker-solix` (Anker Solix V1 EV charger) and
> DriverVault ships four built-in connectors today — `toyota` (Toyota Connected /
> MyToyota, read-only vehicle data), `anker-solix` (Anker Solix V1 EV charger),
> `greencell` (Greencell HabuDen EV charger, read over the owner's MQTT broker
> rather than a cloud API) — all blank-imported from `builtin/builtin.go`. The
> rather than a cloud API) and `apprise` (notifications, through an apprise-api
> gateway the operator runs) — all blank-imported from `builtin/builtin.go`. The
> **external** kind below needs no rebuild and is the easier place to start a new
> one.
@@ -322,13 +332,17 @@ The contract is shaped for these; see [`doc.go`](doc.go):
exist (`Manager.InvokeWith` / `InvokeBatchWith`, driven by the integration routes
and `internal/api/vehicleproviders.go`); what is missing is the generic route.
- **Resilience** — retry/backoff, circuit breaker, per-plugin latency/error metrics.
- **Per-tenant credentials _for arbitrary plugins_** — the built-in connectors
already have them, through the hand-written `/api/integrations/toyota`,
`/api/integrations/anker-solix` and `/api/integrations/greencell` routes and
their **superadmin → org admin → user** config cascade. Each is a near-copy of
the last, which is the argument for the generic version: per-org/per-user
config keyed off `ConfigFields`, so a newly registered plugin gets the same
treatment without new endpoints.
- **Per-tenant credentials _for arbitrary plugins_** — the three per-user
connectors already have them, through the hand-written
`/api/integrations/toyota`, `/api/integrations/anker-solix` and
`/api/integrations/greencell` routes and their **superadmin → org admin →
user** config cascade. Each is a near-copy of the last, which is the argument
for the generic version: per-org/per-user config keyed off `ConfigFields`, so a
newly registered plugin gets the same treatment without new endpoints.
`apprise` deliberately sits outside that cascade — the notification gateway is
infrastructure the operator runs, not an account a driver owns — so its config
is global only, and `baseUrl` is `Required` because nothing further down can
supply it.
- **Audit logging** of plugin access. (Charger *control* commands are already
audited to the `control_audit` collection; this is the wider plugin case.)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,822 @@
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)
}
}
@@ -2,8 +2,8 @@
// register them with the plugin registry. Import this package once (from the api
// package) to make all built-in connectors available.
//
// Built-in connectors that ship today: toyota, ankersolix and greencell
// (imported below).
// Built-in connectors that ship today: toyota, ankersolix, greencell and
// apprise (imported below).
// Add another under internal/plugins/builtin/<name>/ and blank-import it here, e.g.
//
// import _ "drivervault/apiserver/internal/plugins/builtin/acme"
@@ -13,6 +13,8 @@
package builtin
import (
// apprise — Apprise notification gateway, reached through apprise-api.
_ "drivervault/apiserver/internal/plugins/builtin/apprise"
// anker-solix — Anker Solix V1 Smart EV Charger read-only cloud data.
_ "drivervault/apiserver/internal/plugins/builtin/ankersolix"
// greencell — Greencell HabuDen EV charger, read over the owner's MQTT broker.
@@ -0,0 +1,50 @@
package builtin
import (
"testing"
"drivervault/apiserver/internal/plugins"
)
// The blank imports above are the whole content of this package, and forgetting
// one is a silent failure: the connector compiles, its tests pass, and it simply
// never appears in the panel. This pins the list.
func TestEveryBuiltinIsRegistered(t *testing.T) {
// List needs no store: it reports the registered factories, and the records
// it would merge in come from a Manager that has loaded.
views := plugins.NewManager(nil).List()
seen := map[string]plugins.View{}
for _, v := range views {
seen[v.Name] = v
}
want := map[string]string{
"anker-solix": plugins.CategoryChargers,
"apprise": plugins.CategoryNotifications,
"greencell": plugins.CategoryChargers,
"toyota": plugins.CategoryVehicles,
}
for name, category := range want {
v, ok := seen[name]
if !ok {
t.Errorf("%q is not registered — is it blank-imported in builtin.go?", name)
continue
}
if v.Kind != plugins.KindBuiltin {
t.Errorf("%q kind = %q, want builtin", name, v.Kind)
}
if v.Category != category {
t.Errorf("%q category = %q, want %q", name, v.Category, category)
}
if v.Provider == "" || v.Version == "" {
t.Errorf("%q should name a provider and a version, got %q / %q", name, v.Provider, v.Version)
}
if len(v.Capabilities) == 0 {
t.Errorf("%q advertises no capabilities", name)
}
}
if len(seen) != len(want) {
t.Errorf("registered builtins = %d, expected %d — update this test with the new connector", len(seen), len(want))
}
}
+1
View File
@@ -94,6 +94,7 @@ func (c *Capability) UnmarshalJSON(b []byte) error {
const (
CategoryVehicles = "vehicles" // car manufacturers' connected-car services
CategoryChargers = "chargers" // EV chargers and charging hardware
CategoryNotifications = "notifications" // notification gateways and message delivery
CategoryAPIsExternal = "apis-external" // remote HTTP APIs (external plugins)
CategoryDrivesExternal = "drives-external" // remote file stores (FTP/SFTP)
CategoryDrivesLocal = "drives-local" // drives on the host machine
@@ -19,7 +19,7 @@ const tab = ref(""); // selected category tab; empty falls back to the first one
// Tab order, mirroring the Category* constants in internal/plugins/plugin.go. A
// plugin whose category is empty or unknown to this panel lands under the
// external-APIs tab rather than disappearing.
const CATEGORIES = ["vehicles", "chargers", "apis-external", "drives-external", "drives-local"];
const CATEGORIES = ["vehicles", "chargers", "notifications", "apis-external", "drives-external", "drives-local"];
const categoryOf = (p) => (CATEGORIES.includes(p.category) ? p.category : "apis-external");
// Only categories that actually have a plugin get a tab — a fresh install with
+1
View File
@@ -137,6 +137,7 @@
"categories": {
"vehicles": "Bilproducenter",
"chargers": "EV-ladere",
"notifications": "Notifikationer",
"apis-external": "Øvrige API'er",
"drives-external": "Eksterne drev",
"drives-local": "Lokale drev"
+1
View File
@@ -137,6 +137,7 @@
"categories": {
"vehicles": "Car manufacturers",
"chargers": "EV chargers",
"notifications": "Notifications",
"apis-external": "Other APIs",
"drives-external": "Remote drives",
"drives-local": "Local drives"
+1
View File
@@ -137,6 +137,7 @@
"categories": {
"vehicles": "Producenci samochodów",
"chargers": "Ładowarki EV",
"notifications": "Powiadomienia",
"apis-external": "Pozostałe API",
"drives-external": "Dyski zdalne",
"drives-local": "Dyski lokalne"
+4 -1
View File
@@ -62,7 +62,10 @@ The Web and Phone apps are at feature parity.
- **Integrations** — per-user connectors under a superadmin → org-admin → user
cascade. Built-in today: **Toyota Connected** (read-only vehicle data), the
**Anker Solix** V1 EV charger and the **Greencell** HabuDen wallbox (read over
the owner's own MQTT broker — no Greencell cloud is involved).
the owner's own MQTT broker — no Greencell cloud is involved). **Apprise**
joins them as a server-wide connector rather than a per-user one: it hands a
message to an Apprise gateway the operator runs, which fans it out to any of
the 100+ services Apprise speaks.
- **Cars from the manufacturer's own service** — import a car straight off a
connected account (MyToyota today), choosing what to pull in, and read everything
that service knows about it from a dedicated first tab on the car. Generic over
+1 -1
View File
@@ -87,7 +87,7 @@ No screen code changes are needed to add a language.
Both apps are complete in all three languages. The one place the wording is
deliberately not translated is proper nouns: protocol and product names (OCPP,
CSMS, MQTT, MQTTS, TLS, Toyota Connected, MyToyota, Anker Solix, Greencell,
HabuDen, Lexus) read the same in every file, as do the units — and so does the
HabuDen, Lexus, Apprise) read the same in every file, as do the units — and so does the
Greencell device command `QUERY`, which is a literal the charger listens for.
- **API Server panel** — UI chrome, cards, login, status, and the API section