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) } } }