Add OpenWeather plugin with full Web App settings cascade
Builtin API Server connector for the OpenWeather API (current weather + 5-day forecast for a point, API-key auth, stdlib-only), surfaced to end users through the same three-layer settings cascade as OpenSky/WebDAV. - New builtin plugin (internal/plugins/builtin/openweather) with health probe, weather.current/forecast.5day capabilities, and tests. - resolveOpenWeather cascade (global -> org -> user) with per-field independent resolution and API-key masking; GET/PUT/health endpoints. - Web App BFF relays, api.js client, and a Settings card under APIs - External (scope switch, enable toggles, test connection). - Resolver unit tests + rebuilt embedded frontend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
816db9b42f
commit
6231b43076
@@ -0,0 +1,423 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// This file exposes the "openweather" plugin's settings to end users through the
|
||||
// same three-layer cascade OpenSky uses (superadmin/global → organization →
|
||||
// user); see integrations.go for the shared helpers (lockedFor, layerRank,
|
||||
// maskPresent, callerFromRecord) and integrations_webdav.go for the endpoint shape.
|
||||
//
|
||||
// - global (L1): the plugin's config in plugins.json, set in the API Server panel.
|
||||
// - org (L2): pluginSettings.openweather on the caller's organization record.
|
||||
// - user (L3): pluginSettings.openweather on the caller's own user record.
|
||||
//
|
||||
// Unlike WebDAV (a connection group), every OpenWeather field resolves
|
||||
// *independently* — top layer wins, a blank field falls through. There is a
|
||||
// single secret (the API key) with no paired half, so no field group is needed.
|
||||
//
|
||||
// The API key is never returned to a lower-privileged client: the effective
|
||||
// config is resolved server-side and only masked values leave the API. Live
|
||||
// probes run server-side against the resolved config.
|
||||
|
||||
const openWeatherPlugin = "openweather"
|
||||
|
||||
// owFields are the plugin's ConfigField keys, in display order. Kept in sync with
|
||||
// the plugin descriptor so the cascade covers every setting.
|
||||
var owFields = []string{"apiKey", "units", "lat", "lon", "lang"}
|
||||
|
||||
// owSecretKeys are masked in every view and preserved on save when left at the mask.
|
||||
var owSecretKeys = map[string]bool{"apiKey": true}
|
||||
|
||||
// owConfig is one layer's openweather settings. Values are strings to match the
|
||||
// plugin's ConfigField keys 1:1 (they are handed straight to plugins.Init).
|
||||
type owConfig struct {
|
||||
APIKey string `json:"apiKey"`
|
||||
Units string `json:"units"`
|
||||
Lat string `json:"lat"`
|
||||
Lon string `json:"lon"`
|
||||
Lang string `json:"lang"`
|
||||
}
|
||||
|
||||
// owStored is what we persist per user/org under pluginSettings.openweather.
|
||||
type owStored struct {
|
||||
Config owConfig `json:"config"`
|
||||
// Enabled is the personal per-user opt-in (user layer). Default false.
|
||||
Enabled bool `json:"enabled"`
|
||||
// Disabled is the organization layer's off switch, stored inverted so that
|
||||
// absent == enabled (mirrors OpenSky). Only meaningful on the org record.
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
}
|
||||
|
||||
// owSettingsDoc is the openweather slice of the shared pluginSettings JSON.
|
||||
type owSettingsDoc struct {
|
||||
OpenWeather owStored `json:"openweather"`
|
||||
}
|
||||
|
||||
// owResolution is the fully-resolved openweather state for one caller.
|
||||
type owResolution struct {
|
||||
eff owConfig // effective (unmasked) — used only server-side (probes)
|
||||
userOwn owConfig // caller's personal (L3) values (unmasked)
|
||||
orgOwn owConfig // organization (L2) values (unmasked)
|
||||
source map[string]string // field -> layer name (global|org|user|unset)
|
||||
isSuper bool // superadmin: manages the global layer in the panel
|
||||
canOrg bool // caller may edit the organization layer (org admin)
|
||||
available bool // global master switch (plugin enabled in the panel)
|
||||
orgEnabled bool // org master switch (default true; gates the org's users)
|
||||
enabled bool // caller's personal enable flag
|
||||
}
|
||||
|
||||
// owConfigFromMap builds an owConfig from a flat string map (global plugin config).
|
||||
func owConfigFromMap(m map[string]string) owConfig {
|
||||
return owConfig{APIKey: m["apiKey"], Units: m["units"], Lat: m["lat"], Lon: m["lon"], Lang: m["lang"]}
|
||||
}
|
||||
|
||||
// owGet returns a config field by the plugin's key name.
|
||||
func owGet(c owConfig, key string) string {
|
||||
switch key {
|
||||
case "apiKey":
|
||||
return c.APIKey
|
||||
case "units":
|
||||
return c.Units
|
||||
case "lat":
|
||||
return c.Lat
|
||||
case "lon":
|
||||
return c.Lon
|
||||
case "lang":
|
||||
return c.Lang
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// owSet writes a config field by the plugin's key name.
|
||||
func owSet(c *owConfig, key, v string) {
|
||||
switch key {
|
||||
case "apiKey":
|
||||
c.APIKey = v
|
||||
case "units":
|
||||
c.Units = v
|
||||
case "lat":
|
||||
c.Lat = v
|
||||
case "lon":
|
||||
c.Lon = v
|
||||
case "lang":
|
||||
c.Lang = v
|
||||
}
|
||||
}
|
||||
|
||||
// resolveOpenWeather computes the cascade for a caller. userRaw is the caller's
|
||||
// pluginSettings blob (from their auth-refresh record).
|
||||
func (s *Server) resolveOpenWeather(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) owResolution {
|
||||
g, masterEnabled, _ := s.plugins.RawConfig(openWeatherPlugin)
|
||||
gc := owConfigFromMap(g)
|
||||
|
||||
var oStored owStored
|
||||
if who.OrgID != "" {
|
||||
oStored, _ = s.orgOpenWeather(ctx, who.OrgID)
|
||||
}
|
||||
oc := oStored.Config
|
||||
|
||||
var uStored owStored
|
||||
if len(userRaw) > 0 {
|
||||
var d owSettingsDoc
|
||||
_ = json.Unmarshal(userRaw, &d)
|
||||
uStored = d.OpenWeather
|
||||
}
|
||||
uc := uStored.Config
|
||||
|
||||
res := owResolution{
|
||||
source: map[string]string{},
|
||||
userOwn: uc,
|
||||
orgOwn: oc,
|
||||
isSuper: who.isSuperadmin(),
|
||||
// An org admin may edit the organization layer in addition to their own.
|
||||
// Requires the service account (org writes go through it).
|
||||
canOrg: who.isManager() && !who.isSuperadmin() && who.OrgID != "" && s.admin.configured(),
|
||||
available: masterEnabled,
|
||||
orgEnabled: !oStored.Disabled,
|
||||
enabled: uStored.Enabled,
|
||||
}
|
||||
|
||||
// Ordered layers, top (highest priority) first.
|
||||
type layer struct {
|
||||
name string
|
||||
c owConfig
|
||||
}
|
||||
layers := []layer{{"global", gc}}
|
||||
if who.OrgID != "" {
|
||||
layers = append(layers, layer{"org", oc})
|
||||
}
|
||||
layers = append(layers, layer{"user", uc})
|
||||
|
||||
// Every field cascades independently: top wins, blanks fall through.
|
||||
for _, key := range owFields {
|
||||
src := "unset"
|
||||
for _, l := range layers {
|
||||
if v := strings.TrimSpace(owGet(l.c, key)); v != "" {
|
||||
owSet(&res.eff, key, v)
|
||||
src = l.name
|
||||
break
|
||||
}
|
||||
}
|
||||
res.source[key] = src
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// orgOpenWeather reads an organization's stored openweather settings (config + the
|
||||
// org gate) and its raw pluginSettings blob via the service account. Best effort:
|
||||
// zero values on any miss so callers proceed as if the org layer were empty.
|
||||
func (s *Server) orgOpenWeather(ctx context.Context, orgID string) (owStored, json.RawMessage) {
|
||||
if orgID == "" || !s.admin.configured() {
|
||||
return owStored{}, nil
|
||||
}
|
||||
data, status, err := s.admin.do(ctx, http.MethodGet,
|
||||
"/api/collections/organizations/records/"+url.PathEscape(orgID)+"?fields=pluginSettings", nil)
|
||||
if err != nil || status != http.StatusOK {
|
||||
return owStored{}, nil
|
||||
}
|
||||
var rec struct {
|
||||
PluginSettings json.RawMessage `json:"pluginSettings"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &rec)
|
||||
var doc owSettingsDoc
|
||||
if len(rec.PluginSettings) > 0 {
|
||||
_ = json.Unmarshal(rec.PluginSettings, &doc)
|
||||
}
|
||||
return doc.OpenWeather, rec.PluginSettings
|
||||
}
|
||||
|
||||
// mergeOpenWeather applies a mutation to the openweather entry of a pluginSettings
|
||||
// blob, preserving any other plugin keys (e.g. opensky, webdav), and returns the
|
||||
// new blob.
|
||||
func mergeOpenWeather(existing json.RawMessage, apply func(*owStored)) json.RawMessage {
|
||||
doc := map[string]json.RawMessage{}
|
||||
if len(existing) > 0 {
|
||||
_ = json.Unmarshal(existing, &doc)
|
||||
}
|
||||
if doc == nil {
|
||||
doc = map[string]json.RawMessage{} // existing was JSON null
|
||||
}
|
||||
var ow owStored
|
||||
if raw, ok := doc["openweather"]; ok {
|
||||
_ = json.Unmarshal(raw, &ow)
|
||||
}
|
||||
apply(&ow)
|
||||
b, _ := json.Marshal(ow)
|
||||
doc["openweather"] = b
|
||||
out, _ := json.Marshal(doc)
|
||||
return out
|
||||
}
|
||||
|
||||
// GET /api/integrations/openweather — resolved view for the caller.
|
||||
func (s *Server) handleGetOpenWeather(w http.ResponseWriter, r *http.Request) {
|
||||
who, userRaw, ok := s.integrationCaller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
res := s.resolveOpenWeather(r.Context(), who, userRaw)
|
||||
writeJSON(w, http.StatusOK, s.openWeatherView(who, res))
|
||||
}
|
||||
|
||||
// owScopeView builds the masked field set for one editable scope. editable is the
|
||||
// layer the caller edits ("user" | "org" | "none"); a field is locked when its
|
||||
// effective value is set above that layer.
|
||||
func (s *Server) owScopeView(res owResolution, editable string) map[string]any {
|
||||
own := res.userOwn
|
||||
if editable == "org" {
|
||||
own = res.orgOwn
|
||||
}
|
||||
fields := map[string]osFieldView{}
|
||||
for _, key := range owFields {
|
||||
src := res.source[key]
|
||||
fv := osFieldView{Source: src, Locked: lockedFor(src, editable)}
|
||||
if owSecretKeys[key] {
|
||||
fv.Effective, fv.Own = maskPresent(owGet(res.eff, key)), maskPresent(owGet(own, key))
|
||||
} else {
|
||||
fv.Effective, fv.Own = owGet(res.eff, key), owGet(own, key)
|
||||
}
|
||||
fields[key] = fv
|
||||
}
|
||||
return map[string]any{"editableLayer": editable, "fields": fields}
|
||||
}
|
||||
|
||||
// openWeatherView builds the masked, client-safe response body. It exposes a
|
||||
// "user" scope for everyone plus, for org admins, an "org" scope.
|
||||
func (s *Server) openWeatherView(who *callerIdentity, res owResolution) map[string]any {
|
||||
out := map[string]any{
|
||||
"available": res.available,
|
||||
"orgEnabled": res.orgEnabled,
|
||||
"enabled": res.enabled,
|
||||
"role": who.Role,
|
||||
"orgId": who.OrgID,
|
||||
"canEditOrg": res.canOrg,
|
||||
"isSuperadmin": res.isSuper,
|
||||
}
|
||||
if res.isSuper {
|
||||
out["editableLayer"] = "none"
|
||||
out["scopes"] = map[string]any{"user": s.owScopeView(res, "none")}
|
||||
return out
|
||||
}
|
||||
scopes := map[string]any{"user": s.owScopeView(res, "user")}
|
||||
if res.canOrg {
|
||||
scopes["org"] = s.owScopeView(res, "org")
|
||||
}
|
||||
out["scopes"] = scopes
|
||||
return out
|
||||
}
|
||||
|
||||
// PUT /api/integrations/openweather — save the caller's editable layer. Body:
|
||||
// {enabled?, scope?, config?}. Fields locked above the caller are ignored; the
|
||||
// API key left at the mask is preserved.
|
||||
func (s *Server) handlePutOpenWeather(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.Header.Get("Authorization")
|
||||
var body struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Scope string `json:"scope"`
|
||||
Config map[string]string `json:"config"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
who, userRaw, ok := s.integrationCaller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
res := s.resolveOpenWeather(r.Context(), who, userRaw)
|
||||
|
||||
// Resolve which layer this write targets.
|
||||
editable := "user"
|
||||
switch {
|
||||
case res.isSuper:
|
||||
editable = "none"
|
||||
case strings.EqualFold(strings.TrimSpace(body.Scope), "org"):
|
||||
if !res.canOrg {
|
||||
writeError(w, http.StatusForbidden, "only an organization admin can edit organization settings")
|
||||
return
|
||||
}
|
||||
editable = "org"
|
||||
}
|
||||
|
||||
// Overlay the fields the caller may change in this scope onto its own values.
|
||||
newOwn := res.userOwn
|
||||
if editable == "org" {
|
||||
newOwn = res.orgOwn
|
||||
}
|
||||
for _, key := range owFields {
|
||||
v, present := body.Config[key]
|
||||
if !present || lockedFor(res.source[key], editable) {
|
||||
continue
|
||||
}
|
||||
if owSecretKeys[key] && v == openSkySecretMask {
|
||||
continue // keep current secret
|
||||
}
|
||||
owSet(&newOwn, key, strings.TrimSpace(v))
|
||||
}
|
||||
|
||||
// Persist the organization layer (admins) via the service account.
|
||||
if editable == "org" {
|
||||
if who.OrgID == "" {
|
||||
writeError(w, http.StatusForbidden, "your account is not attached to an organization")
|
||||
return
|
||||
}
|
||||
if !s.admin.configured() {
|
||||
writeError(w, http.StatusServiceUnavailable, "organization settings not configured on the server")
|
||||
return
|
||||
}
|
||||
_, orgRaw := s.orgOpenWeather(r.Context(), who.OrgID)
|
||||
newDoc := mergeOpenWeather(orgRaw, func(ow *owStored) {
|
||||
ow.Config = newOwn
|
||||
if body.Enabled != nil {
|
||||
ow.Disabled = !*body.Enabled // org master switch, stored inverted
|
||||
}
|
||||
})
|
||||
_, st, err := s.admin.do(r.Context(), http.MethodPatch,
|
||||
"/api/collections/organizations/records/"+url.PathEscape(who.OrgID),
|
||||
map[string]json.RawMessage{"pluginSettings": newDoc})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if st != http.StatusOK {
|
||||
writeError(w, http.StatusBadGateway, "could not save organization settings")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the user record: the personal enable flag lives here (user/superadmin
|
||||
// scope), and so does the personal config layer when this write targets user.
|
||||
personalEnable := body.Enabled != nil && editable != "org"
|
||||
if personalEnable || editable == "user" {
|
||||
newDoc := mergeOpenWeather(userRaw, func(ow *owStored) {
|
||||
if personalEnable {
|
||||
ow.Enabled = *body.Enabled
|
||||
}
|
||||
if editable == "user" {
|
||||
ow.Config = newOwn
|
||||
}
|
||||
})
|
||||
if code, err := s.patchUserPluginSettings(r.Context(), token, who.ID, newDoc); err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
} else if code != http.StatusOK {
|
||||
writeError(w, http.StatusBadGateway, "could not save user settings")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Re-resolve and return the fresh view.
|
||||
fresh, st, err := s.pbAuthRefresh(r.Context(), token)
|
||||
if err != nil || st != http.StatusOK || fresh == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
res2 := s.resolveOpenWeather(r.Context(), who, fresh.Record["pluginSettings"])
|
||||
writeJSON(w, http.StatusOK, s.openWeatherView(who, res2))
|
||||
}
|
||||
|
||||
// POST /api/integrations/openweather/health — live probe using the caller's
|
||||
// resolved config. Never returns the API key.
|
||||
func (s *Server) handleOpenWeatherHealth(w http.ResponseWriter, r *http.Request) {
|
||||
who, userRaw, ok := s.integrationCaller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !who.isSuperadmin() {
|
||||
if _, _, ok := s.plugins.RawConfig(openWeatherPlugin); !ok {
|
||||
writeError(w, http.StatusNotFound, "unknown plugin")
|
||||
return
|
||||
}
|
||||
}
|
||||
res := s.resolveOpenWeather(r.Context(), who, userRaw)
|
||||
if !res.available {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
|
||||
"status": "down", "detail": "OpenWeather is disabled by the administrator"}})
|
||||
return
|
||||
}
|
||||
if !res.orgEnabled {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
|
||||
"status": "down", "detail": "OpenWeather is disabled for your organization"}})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(res.eff.APIKey) == "" {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
|
||||
"status": "down", "detail": "No API key configured — add one to connect"}})
|
||||
return
|
||||
}
|
||||
cfg := map[string]string{}
|
||||
for _, k := range owFields {
|
||||
cfg[k] = owGet(res.eff, k)
|
||||
}
|
||||
h, err := s.plugins.HealthCheckWith(r.Context(), openWeatherPlugin, cfg)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"health": h})
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"pilotvault/apiserver/internal/plugins"
|
||||
)
|
||||
|
||||
// newOWServer builds a minimal Server whose plugin manager has openweather
|
||||
// enabled with the given global config. admin is left nil (not configured), so
|
||||
// the org layer is skipped and the cascade covers global + user only.
|
||||
func newOWServer(t *testing.T, global map[string]string) *Server {
|
||||
t.Helper()
|
||||
mgr := plugins.NewManager(t.TempDir() + "/plugins.json")
|
||||
if _, err := mgr.Upsert(context.Background(), openWeatherPlugin, true, global); err != nil {
|
||||
t.Fatalf("enable openweather: %v", err)
|
||||
}
|
||||
return &Server{plugins: mgr}
|
||||
}
|
||||
|
||||
// userRaw builds a pluginSettings blob with an openweather user layer.
|
||||
func userRaw(t *testing.T, cfg owConfig, enabled bool) json.RawMessage {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(owSettingsDoc{OpenWeather: owStored{Config: cfg, Enabled: enabled}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestResolveOpenWeatherCascade(t *testing.T) {
|
||||
// Global supplies the API key + units and leaves the location blank so the
|
||||
// user layer fills it in. (A value set globally locks the lower layers — that
|
||||
// is verified separately by the masked/locked apiKey below.)
|
||||
s := newOWServer(t, map[string]string{"apiKey": "GLOBAL-KEY", "units": "metric"})
|
||||
who := &callerIdentity{ID: "u1", Role: roleUser} // org-less
|
||||
uRaw := userRaw(t, owConfig{Lat: "51.5", Lon: "-0.12"}, true)
|
||||
|
||||
res := s.resolveOpenWeather(context.Background(), who, uRaw)
|
||||
|
||||
if !res.available {
|
||||
t.Error("available should be true when the plugin is enabled")
|
||||
}
|
||||
if !res.enabled {
|
||||
t.Error("enabled should reflect the user's personal opt-in")
|
||||
}
|
||||
// API key + units come from global (user left them blank).
|
||||
if res.eff.APIKey != "GLOBAL-KEY" || res.source["apiKey"] != "global" {
|
||||
t.Errorf("apiKey = %q src %q, want GLOBAL-KEY/global", res.eff.APIKey, res.source["apiKey"])
|
||||
}
|
||||
if res.eff.Units != "metric" || res.source["units"] != "global" {
|
||||
t.Errorf("units = %q src %q, want metric/global", res.eff.Units, res.source["units"])
|
||||
}
|
||||
// Location comes from the user layer (top-wins over the global default).
|
||||
if res.eff.Lat != "51.5" || res.source["lat"] != "user" {
|
||||
t.Errorf("lat = %q src %q, want 51.5/user", res.eff.Lat, res.source["lat"])
|
||||
}
|
||||
if res.eff.Lon != "-0.12" || res.source["lon"] != "user" {
|
||||
t.Errorf("lon = %q src %q, want -0.12/user", res.eff.Lon, res.source["lon"])
|
||||
}
|
||||
}
|
||||
|
||||
// A field set globally locks the lower layers: even when the user supplies units,
|
||||
// the global value stays in force and is sourced to "global".
|
||||
func TestResolveOpenWeatherGlobalLocksUser(t *testing.T) {
|
||||
s := newOWServer(t, map[string]string{"apiKey": "K", "units": "metric"})
|
||||
who := &callerIdentity{ID: "u1", Role: roleUser}
|
||||
uRaw := userRaw(t, owConfig{Units: "imperial"}, false)
|
||||
|
||||
res := s.resolveOpenWeather(context.Background(), who, uRaw)
|
||||
if res.eff.Units != "metric" || res.source["units"] != "global" {
|
||||
t.Errorf("units = %q src %q, want metric/global (global must lock the user's imperial)", res.eff.Units, res.source["units"])
|
||||
}
|
||||
}
|
||||
|
||||
// The view must never leak the concrete API key — only presence, masked.
|
||||
func TestOpenWeatherViewMasksKey(t *testing.T) {
|
||||
s := newOWServer(t, map[string]string{"apiKey": "GLOBAL-KEY", "units": "metric"})
|
||||
who := &callerIdentity{ID: "u1", Role: roleUser}
|
||||
res := s.resolveOpenWeather(context.Background(), who, nil)
|
||||
|
||||
view := s.openWeatherView(who, res)
|
||||
scopes := view["scopes"].(map[string]any)
|
||||
user := scopes["user"].(map[string]any)
|
||||
fields := user["fields"].(map[string]osFieldView)
|
||||
|
||||
if got := fields["apiKey"].Effective; got != openSkySecretMask {
|
||||
t.Errorf("apiKey effective = %q, want the mask (never the raw key)", got)
|
||||
}
|
||||
if fields["apiKey"].Source != "global" || !fields["apiKey"].Locked {
|
||||
t.Errorf("apiKey field = %+v, want source global + locked for a plain user", fields["apiKey"])
|
||||
}
|
||||
// A non-secret field is shown in the clear.
|
||||
if fields["units"].Effective != "metric" {
|
||||
t.Errorf("units effective = %q, want metric", fields["units"].Effective)
|
||||
}
|
||||
}
|
||||
|
||||
// mergeOpenWeather must preserve sibling plugin keys (opensky/webdav) untouched.
|
||||
func TestMergeOpenWeatherPreservesSiblings(t *testing.T) {
|
||||
existing := json.RawMessage(`{"opensky":{"enabled":true},"webdav":{"config":{"baseURL":"https://x"}}}`)
|
||||
out := mergeOpenWeather(existing, func(ow *owStored) {
|
||||
ow.Config.APIKey = "K"
|
||||
ow.Enabled = true
|
||||
})
|
||||
|
||||
var doc map[string]json.RawMessage
|
||||
if err := json.Unmarshal(out, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := doc["opensky"]; !ok {
|
||||
t.Error("opensky key was dropped by mergeOpenWeather")
|
||||
}
|
||||
if _, ok := doc["webdav"]; !ok {
|
||||
t.Error("webdav key was dropped by mergeOpenWeather")
|
||||
}
|
||||
var ow owStored
|
||||
if err := json.Unmarshal(doc["openweather"], &ow); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ow.Config.APIKey != "K" || !ow.Enabled {
|
||||
t.Errorf("openweather entry = %+v, want APIKey K + enabled", ow)
|
||||
}
|
||||
}
|
||||
|
||||
// A saved secret left at the mask on PUT must not be re-checked here, but the
|
||||
// merge/keep logic lives in the handler; this guards the field/secret metadata
|
||||
// the handler relies on stays consistent with the plugin descriptor.
|
||||
func TestOpenWeatherFieldMetadata(t *testing.T) {
|
||||
if !owSecretKeys["apiKey"] {
|
||||
t.Error("apiKey must be a secret key")
|
||||
}
|
||||
// Every declared field must round-trip through owGet/owSet.
|
||||
var c owConfig
|
||||
for _, k := range owFields {
|
||||
owSet(&c, k, "v-"+k)
|
||||
}
|
||||
for _, k := range owFields {
|
||||
if owGet(c, k) != "v-"+k {
|
||||
t.Errorf("owGet/owSet mismatch for %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,9 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/integrations/webdav", s.handleGetWebDav)
|
||||
mux.HandleFunc("PUT /api/integrations/webdav", s.handlePutWebDav)
|
||||
mux.HandleFunc("POST /api/integrations/webdav/health", s.handleWebDavHealth)
|
||||
mux.HandleFunc("GET /api/integrations/openweather", s.handleGetOpenWeather)
|
||||
mux.HandleFunc("PUT /api/integrations/openweather", s.handlePutOpenWeather)
|
||||
mux.HandleFunc("POST /api/integrations/openweather/health", s.handleOpenWeatherHealth)
|
||||
|
||||
// User-management — gated on the caller being a manager (admin or superadmin).
|
||||
// Admins are scoped to their own organization inside each handler.
|
||||
|
||||
@@ -7,5 +7,6 @@ import (
|
||||
_ "pilotvault/apiserver/internal/plugins/builtin/filetransfer"
|
||||
_ "pilotvault/apiserver/internal/plugins/builtin/localstorage"
|
||||
_ "pilotvault/apiserver/internal/plugins/builtin/opensky"
|
||||
_ "pilotvault/apiserver/internal/plugins/builtin/openweather"
|
||||
_ "pilotvault/apiserver/internal/plugins/builtin/webdav"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
// Package openweather is a built-in plugin connecting the OpenWeather API
|
||||
// (current conditions and 5-day/3-hour forecast for a point location). Like the
|
||||
// opensky connector it demonstrates a real third-party integration behind the
|
||||
// plugin contract, here using simple API-key auth.
|
||||
//
|
||||
// Docs: https://openweathermap.org/current https://openweathermap.org/forecast5
|
||||
package openweather
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"pilotvault/apiserver/internal/plugins"
|
||||
)
|
||||
|
||||
// apiBase is the OpenWeather REST root. It is copied into the plugin instance in
|
||||
// Init so tests can point the connector at an httptest server.
|
||||
const apiBase = "https://api.openweathermap.org"
|
||||
|
||||
// Default probe location — Warsaw, Poland. Used by the health probe and as the
|
||||
// default point for weather.current / forecast.5day when a call supplies none.
|
||||
const (
|
||||
defaultLat = "52.2297"
|
||||
defaultLon = "21.0122"
|
||||
)
|
||||
|
||||
func init() {
|
||||
plugins.Register("openweather", func() plugins.Plugin { return &Plugin{} })
|
||||
}
|
||||
|
||||
// Plugin is the OpenWeather connector.
|
||||
type Plugin struct {
|
||||
mu sync.Mutex
|
||||
apiKey string
|
||||
units string
|
||||
lang string
|
||||
lat string
|
||||
lon string
|
||||
|
||||
base string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// errNoKey is returned when a probe/call has no resolved API key. OpenWeather has
|
||||
// no anonymous tier, so a key is required for any live request.
|
||||
var errNoKey = errors.New("no API key configured")
|
||||
|
||||
func (p *Plugin) Descriptor() plugins.Descriptor {
|
||||
return plugins.Descriptor{
|
||||
Name: "openweather",
|
||||
Provider: "OpenWeather",
|
||||
Version: "1.0.0",
|
||||
Kind: plugins.KindBuiltin,
|
||||
Category: plugins.CategoryAPIsExternal,
|
||||
Capabilities: []plugins.Capability{
|
||||
{ID: "weather.current", Method: "GET", Endpoint: "/data/2.5/weather?lat&lon",
|
||||
Description: "Current weather for the configured (or a supplied) lat/lon."},
|
||||
{ID: "forecast.5day", Method: "GET", Endpoint: "/data/2.5/forecast?lat&lon",
|
||||
Description: "5-day / 3-hour forecast for the configured (or a supplied) lat/lon."},
|
||||
},
|
||||
AuthType: plugins.AuthAPIKey,
|
||||
ConfigFields: []plugins.ConfigField{
|
||||
{Key: "apiKey", Label: "API key", Type: "password", Secret: true,
|
||||
Help: "Your OpenWeather API key (the appid query parameter). Required for any live request — OpenWeather has no anonymous tier. Leave blank to enable the plugin as a master switch and supply the key at another layer."},
|
||||
{Key: "units", Label: "Units", Type: "select", Default: "metric",
|
||||
Options: []plugins.SelectOption{
|
||||
{Value: "standard", Label: "Standard — Kelvin, m/s"},
|
||||
{Value: "metric", Label: "Metric — °C, m/s"},
|
||||
{Value: "imperial", Label: "Imperial — °F, mph"},
|
||||
},
|
||||
Help: "Measurement system for temperatures and wind speed in responses."},
|
||||
{Key: "lat", Label: "Default latitude", Type: "text", Default: defaultLat,
|
||||
Help: "Latitude used by the health probe and by calls that supply no location (−90…90)."},
|
||||
{Key: "lon", Label: "Default longitude", Type: "text", Default: defaultLon,
|
||||
Help: "Longitude used by the health probe and by calls that supply no location (−180…180)."},
|
||||
{Key: "lang", Label: "Language", Type: "text",
|
||||
Help: "Optional ISO language code for human-readable weather descriptions (e.g. en, pl, de). Leave blank for the API default."},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.apiKey = strings.TrimSpace(config["apiKey"])
|
||||
p.units = strings.TrimSpace(config["units"])
|
||||
if p.units == "" {
|
||||
p.units = "metric"
|
||||
}
|
||||
p.lang = strings.TrimSpace(config["lang"])
|
||||
p.lat = strings.TrimSpace(config["lat"])
|
||||
if p.lat == "" {
|
||||
p.lat = defaultLat
|
||||
}
|
||||
p.lon = strings.TrimSpace(config["lon"])
|
||||
if p.lon == "" {
|
||||
p.lon = defaultLon
|
||||
}
|
||||
p.base = apiBase
|
||||
p.client = &http.Client{Timeout: 10 * time.Second}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requestURL builds an absolute OpenWeather request URL for the given path,
|
||||
// merging the caller's query with the configured units, language and API key.
|
||||
// The API key is never included when unset (callers must guard on errNoKey).
|
||||
func (p *Plugin) requestURL(path string, q url.Values) string {
|
||||
p.mu.Lock()
|
||||
base, key, units, lang := p.base, p.apiKey, p.units, p.lang
|
||||
p.mu.Unlock()
|
||||
|
||||
if q == nil {
|
||||
q = url.Values{}
|
||||
}
|
||||
if units != "" {
|
||||
q.Set("units", units)
|
||||
}
|
||||
if lang != "" {
|
||||
q.Set("lang", lang)
|
||||
}
|
||||
if key != "" {
|
||||
q.Set("appid", key)
|
||||
}
|
||||
return strings.TrimRight(base, "/") + path + "?" + q.Encode()
|
||||
}
|
||||
|
||||
// pointQuery returns a lat/lon query, preferring caller-supplied coordinates and
|
||||
// falling back to the configured defaults.
|
||||
func (p *Plugin) pointQuery(lat, lon string) url.Values {
|
||||
p.mu.Lock()
|
||||
dlat, dlon := p.lat, p.lon
|
||||
p.mu.Unlock()
|
||||
if strings.TrimSpace(lat) == "" {
|
||||
lat = dlat
|
||||
}
|
||||
if strings.TrimSpace(lon) == "" {
|
||||
lon = dlon
|
||||
}
|
||||
return url.Values{"lat": {strings.TrimSpace(lat)}, "lon": {strings.TrimSpace(lon)}}
|
||||
}
|
||||
|
||||
// HealthCheck performs a live current-weather query at the configured location
|
||||
// and classifies the outcome.
|
||||
func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
|
||||
start := time.Now()
|
||||
|
||||
p.mu.Lock()
|
||||
key := p.apiKey
|
||||
p.mu.Unlock()
|
||||
if key == "" {
|
||||
return plugins.Health{Status: plugins.StatusDown, LatencyMs: time.Since(start).Milliseconds(),
|
||||
Detail: errNoKey.Error()}
|
||||
}
|
||||
|
||||
target := p.requestURL("/data/2.5/weather", p.pointQuery("", ""))
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
resp, err := p.client.Do(req)
|
||||
lat := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
|
||||
h := plugins.Health{LatencyMs: lat}
|
||||
switch {
|
||||
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
||||
h.Status, h.Detail = plugins.StatusOK, "OpenWeather reachable"
|
||||
if summary := currentSummary(data); summary != "" {
|
||||
h.Detail += " · " + summary
|
||||
}
|
||||
case resp.StatusCode == http.StatusUnauthorized:
|
||||
h.Status, h.Detail = plugins.StatusDegraded, "API key rejected (HTTP 401)"
|
||||
case resp.StatusCode == http.StatusTooManyRequests:
|
||||
h.Status, h.Detail = plugins.StatusDegraded, "rate limited (HTTP 429)"
|
||||
default:
|
||||
h.Status, h.Detail = plugins.StatusDown, "HTTP "+resp.Status
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// currentSummary renders a short "place: 12°C, clear sky" line from a current
|
||||
// weather payload. Returns "" when the body can't be parsed.
|
||||
func currentSummary(data []byte) string {
|
||||
var out struct {
|
||||
Name string `json:"name"`
|
||||
Main struct {
|
||||
Temp float64 `json:"temp"`
|
||||
} `json:"main"`
|
||||
Weather []struct {
|
||||
Description string `json:"description"`
|
||||
} `json:"weather"`
|
||||
}
|
||||
if json.Unmarshal(data, &out) != nil {
|
||||
return ""
|
||||
}
|
||||
parts := []string{}
|
||||
if out.Name != "" {
|
||||
parts = append(parts, out.Name)
|
||||
}
|
||||
temp := strconv.FormatFloat(out.Main.Temp, 'f', -1, 64) + "°"
|
||||
if len(out.Weather) > 0 && out.Weather[0].Description != "" {
|
||||
temp += ", " + out.Weather[0].Description
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return temp
|
||||
}
|
||||
return parts[0] + ": " + temp
|
||||
}
|
||||
|
||||
// invokeParams is the optional per-call override accepted by Invoke actions.
|
||||
type invokeParams struct {
|
||||
Lat string `json:"lat"`
|
||||
Lon string `json:"lon"`
|
||||
}
|
||||
|
||||
// Invoke exposes weather.current / forecast.5day. Both accept an optional
|
||||
// {lat,lon} override and otherwise use the configured default location.
|
||||
func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
|
||||
p.mu.Lock()
|
||||
key := p.apiKey
|
||||
p.mu.Unlock()
|
||||
if key == "" {
|
||||
return nil, errNoKey
|
||||
}
|
||||
|
||||
var in invokeParams
|
||||
if len(params) > 0 {
|
||||
if err := json.Unmarshal(params, &in); err != nil {
|
||||
return nil, fmt.Errorf("invalid params: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var path string
|
||||
switch action {
|
||||
case "weather.current":
|
||||
path = "/data/2.5/weather"
|
||||
case "forecast.5day":
|
||||
path = "/data/2.5/forecast"
|
||||
default:
|
||||
return nil, errors.New("unknown action: " + action)
|
||||
}
|
||||
|
||||
target := p.requestURL(path, p.pointQuery(in.Lat, in.Lon))
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("openweather %s: HTTP %s", action, resp.Status)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Shutdown(context.Context) error { return nil }
|
||||
@@ -0,0 +1,190 @@
|
||||
package openweather
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pilotvault/apiserver/internal/plugins"
|
||||
)
|
||||
|
||||
func TestDescriptor(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
d := p.Descriptor()
|
||||
if d.Name != "openweather" {
|
||||
t.Fatalf("name = %q, want openweather", d.Name)
|
||||
}
|
||||
if d.Kind != plugins.KindBuiltin {
|
||||
t.Fatalf("kind = %q, want builtin", d.Kind)
|
||||
}
|
||||
if d.Category != plugins.CategoryAPIsExternal {
|
||||
t.Fatalf("category = %q, want %q", d.Category, plugins.CategoryAPIsExternal)
|
||||
}
|
||||
if d.AuthType != plugins.AuthAPIKey {
|
||||
t.Fatalf("authType = %q, want %q", d.AuthType, plugins.AuthAPIKey)
|
||||
}
|
||||
if len(d.Capabilities) == 0 {
|
||||
t.Fatal("expected capabilities")
|
||||
}
|
||||
// The apiKey field must be flagged so the manager masks it, and no field may
|
||||
// be Required (so the plugin can be enabled as an empty master switch).
|
||||
for _, f := range d.ConfigFields {
|
||||
if f.Key == "apiKey" && !f.Secret {
|
||||
t.Errorf("config field %q must be Secret", f.Key)
|
||||
}
|
||||
if f.Required {
|
||||
t.Errorf("config field %q must not be Required", f.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitDefaults(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
if err := p.Init(context.Background(), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.units != "metric" {
|
||||
t.Errorf("default units = %q, want metric", p.units)
|
||||
}
|
||||
if p.lat != defaultLat || p.lon != defaultLon {
|
||||
t.Errorf("default location = %q,%q, want %q,%q", p.lat, p.lon, defaultLat, defaultLon)
|
||||
}
|
||||
if p.client == nil {
|
||||
t.Error("Init must build an http client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestURL(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
_ = p.Init(context.Background(), map[string]string{
|
||||
"apiKey": "secret", "units": "imperial", "lang": "pl",
|
||||
})
|
||||
got := p.requestURL("/data/2.5/weather", p.pointQuery("10", "20"))
|
||||
for _, want := range []string{"lat=10", "lon=20", "units=imperial", "lang=pl", "appid=secret", "/data/2.5/weather?"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("requestURL %q missing %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestURLNoKey confirms the appid param is omitted when no key is set, so
|
||||
// a key is never leaked as an empty value.
|
||||
func TestRequestURLNoKey(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
_ = p.Init(context.Background(), nil)
|
||||
if got := p.requestURL("/data/2.5/weather", p.pointQuery("", "")); strings.Contains(got, "appid") {
|
||||
t.Errorf("requestURL %q must not contain appid when key is unset", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealthCheckNoKey confirms a missing key is reported as down, not a panic.
|
||||
func TestHealthCheckNoKey(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
_ = p.Init(context.Background(), nil)
|
||||
if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDown {
|
||||
t.Errorf("status = %q, want down (detail=%q)", h.Status, h.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistered(t *testing.T) {
|
||||
m := plugins.NewManager(t.TempDir() + "/plugins.json")
|
||||
if _, ok := m.Get("openweather"); !ok {
|
||||
t.Fatal("openweather not registered in the plugin manager")
|
||||
}
|
||||
}
|
||||
|
||||
// fakeOW is a minimal OpenWeather stand-in: it validates the appid and echoes a
|
||||
// small current-weather body, and returns 401 for a wrong key.
|
||||
func newFakeOW(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("appid") != "good-key" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/data/2.5/weather":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"name":"Warsaw","main":{"temp":12.5},"weather":[{"description":"clear sky"}]}`))
|
||||
case "/data/2.5/forecast":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"cnt":40,"list":[]}`))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func TestHealthCheckOK(t *testing.T) {
|
||||
srv := newFakeOW(t)
|
||||
defer srv.Close()
|
||||
|
||||
p := &Plugin{}
|
||||
_ = p.Init(context.Background(), map[string]string{"apiKey": "good-key"})
|
||||
p.base = srv.URL
|
||||
|
||||
h := p.HealthCheck(context.Background())
|
||||
if h.Status != plugins.StatusOK {
|
||||
t.Fatalf("status = %q, want ok (detail=%q)", h.Status, h.Detail)
|
||||
}
|
||||
if !strings.Contains(h.Detail, "Warsaw") || !strings.Contains(h.Detail, "clear sky") {
|
||||
t.Errorf("detail = %q, want it to summarise the current conditions", h.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheckAuthFailure(t *testing.T) {
|
||||
srv := newFakeOW(t)
|
||||
defer srv.Close()
|
||||
|
||||
p := &Plugin{}
|
||||
_ = p.Init(context.Background(), map[string]string{"apiKey": "wrong"})
|
||||
p.base = srv.URL
|
||||
|
||||
if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDegraded {
|
||||
t.Errorf("status = %q, want degraded on 401 (detail=%q)", h.Status, h.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvoke(t *testing.T) {
|
||||
srv := newFakeOW(t)
|
||||
defer srv.Close()
|
||||
|
||||
p := &Plugin{}
|
||||
_ = p.Init(context.Background(), map[string]string{"apiKey": "good-key"})
|
||||
p.base = srv.URL
|
||||
ctx := context.Background()
|
||||
|
||||
// current weather with an explicit location override
|
||||
raw, err := p.Invoke(ctx, "weather.current", json.RawMessage(`{"lat":"51.5","lon":"-0.12"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("weather.current: %v", err)
|
||||
}
|
||||
var cur struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &cur); err != nil || cur.Name != "Warsaw" {
|
||||
t.Fatalf("weather.current body = %s (err=%v)", raw, err)
|
||||
}
|
||||
|
||||
// 5-day forecast using the default location
|
||||
if _, err := p.Invoke(ctx, "forecast.5day", nil); err != nil {
|
||||
t.Fatalf("forecast.5day: %v", err)
|
||||
}
|
||||
|
||||
// unknown action
|
||||
if _, err := p.Invoke(ctx, "nope", nil); err == nil {
|
||||
t.Error("expected error for unknown action")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvokeNoKey confirms Invoke refuses to call upstream without a key.
|
||||
func TestInvokeNoKey(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
_ = p.Init(context.Background(), nil)
|
||||
if _, err := p.Invoke(context.Background(), "weather.current", nil); err == nil {
|
||||
t.Error("expected errNoKey when no API key is configured")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user