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>
146 lines
5.4 KiB
Go
146 lines
5.4 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|