Add a weather card to the Web App Overview, wired like the OpenSky live-map
overlay and backed by the OpenWeather plugin.
- API Server: GET /api/integrations/openweather/current resolves the
caller's cascade, gates it (master/org/personal opt-in/key), and returns
trimmed current conditions for the configured or a supplied ?lat=&lon=
point; off/keyless returns {unavailable, detail} so the card degrades.
- BFF relay (forwards the location override) + route; api.js client.
- Dashboard: a Weather card stacked above Schedule showing an emoji
condition, temperature in the resolved unit, description, location, and a
feels-like/wind/humidity/cloud grid. Location follows the same cascade as
the map (drone -> phone -> browser -> default) without prompting for geo;
refreshes every 10 min while on Overview.
- validLatLon test; rebuilt embedded frontend.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
161 lines
5.8 KiB
Go
161 lines
5.8 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)
|
|
}
|
|
}
|
|
|
|
func TestValidLatLon(t *testing.T) {
|
|
ok := [][2]string{{"0", "0"}, {"52.2297", "21.0122"}, {"-90", "180"}, {"90", "-180"}}
|
|
for _, c := range ok {
|
|
if !validLatLon(c[0], c[1]) {
|
|
t.Errorf("validLatLon(%q,%q) = false, want true", c[0], c[1])
|
|
}
|
|
}
|
|
bad := [][2]string{{"", ""}, {"91", "0"}, {"0", "181"}, {"-91", "0"}, {"abc", "0"}, {"0", "x"}}
|
|
for _, c := range bad {
|
|
if validLatLon(c[0], c[1]) {
|
|
t.Errorf("validLatLon(%q,%q) = true, want false", c[0], c[1])
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|