The panel health probe tests the global plugin config only. When the plugin is enabled as a master switch with no global API key (users supply their own), the probe reported a hard "down", which read as broken. Return "degraded" with an explanatory detail instead — a keyless master switch is a valid setup, nothing is failing. The Web App user-facing path is unchanged: a caller whose effective key is empty still gets "down" with an actionable "add one to connect" message before the plugin is called. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
266 lines
8.5 KiB
Go
266 lines
8.5 KiB
Go
package openweather
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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)
|
|
}
|
|
// Units must be blank-able so the value can fall through the settings
|
|
// cascade: a blank "" option and no Default, otherwise the global layer
|
|
// always sets it and locks organizations/users out.
|
|
if f.Key == "units" {
|
|
if f.Default != "" {
|
|
t.Errorf("units field must not carry a Default (got %q) — it would lock lower cascade layers", f.Default)
|
|
}
|
|
hasBlank := false
|
|
for _, o := range f.Options {
|
|
if o.Value == "" {
|
|
hasBlank = true
|
|
}
|
|
}
|
|
if !hasBlank {
|
|
t.Error("units field must offer a blank \"\" (Not set) option so it can fall through the cascade")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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.callsPerMinute != defaultCallsPerMinute {
|
|
t.Errorf("default callsPerMinute = %d, want %d", p.callsPerMinute, defaultCallsPerMinute)
|
|
}
|
|
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 degraded (a valid
|
|
// master-switch config, not a hard failure), not a panic.
|
|
func TestHealthCheckNoKey(t *testing.T) {
|
|
p := &Plugin{}
|
|
_ = p.Init(context.Background(), nil)
|
|
if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDegraded {
|
|
t.Errorf("status = %q, want degraded (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")
|
|
}
|
|
}
|
|
|
|
// TestUsageCounter confirms each upstream call is counted and surfaced on Health,
|
|
// bucketed per API key against the configured per-minute limit.
|
|
func TestUsageCounter(t *testing.T) {
|
|
srv := newFakeOW(t)
|
|
defer srv.Close()
|
|
|
|
p := &Plugin{}
|
|
// A key unique to this test so its counter is isolated from other tests that
|
|
// share the process-wide usage store. The fake returns 401 for a non-"good-key",
|
|
// but a call that reaches the server is still counted (only transport errors are not).
|
|
_ = p.Init(context.Background(), map[string]string{"apiKey": "usage-key-" + t.Name(), "callsPerMinute": "42"})
|
|
p.base = srv.URL
|
|
ctx := context.Background()
|
|
|
|
h := p.HealthCheck(ctx)
|
|
if h.Usage == nil {
|
|
t.Fatal("expected Usage on the health result")
|
|
}
|
|
if h.Usage.MinuteLimit != 42 {
|
|
t.Errorf("MinuteLimit = %d, want the configured 42", h.Usage.MinuteLimit)
|
|
}
|
|
if h.Usage.MinuteUsed != 1 || h.Usage.DayUsed != 1 {
|
|
t.Errorf("after 1 probe usage = %d/min %d/day, want 1/1", h.Usage.MinuteUsed, h.Usage.DayUsed)
|
|
}
|
|
|
|
// A second probe and an Invoke both add to the same bucket.
|
|
_ = p.HealthCheck(ctx)
|
|
_, _ = p.Invoke(ctx, "weather.current", nil)
|
|
h3 := p.HealthCheck(ctx)
|
|
if h3.Usage.MinuteUsed != 4 || h3.Usage.DayUsed != 4 {
|
|
t.Errorf("after 4 calls usage = %d/min %d/day, want 4/4", h3.Usage.MinuteUsed, h3.Usage.DayUsed)
|
|
}
|
|
}
|
|
|
|
// TestUsageRollover confirms the minute bucket resets when the clock moves on.
|
|
func TestUsageRollover(t *testing.T) {
|
|
u := &callUsage{}
|
|
base := time.Date(2026, 7, 14, 10, 30, 0, 0, time.UTC)
|
|
u.record(base)
|
|
u.record(base.Add(30 * time.Second)) // same minute
|
|
if m, d := u.snapshot(base.Add(45 * time.Second)); m != 2 || d != 2 {
|
|
t.Fatalf("same-minute snapshot = %d/%d, want 2/2", m, d)
|
|
}
|
|
// Next minute: minute count resets, day count persists.
|
|
if m, d := u.snapshot(base.Add(90 * time.Second)); m != 0 || d != 2 {
|
|
t.Errorf("next-minute snapshot = %d/%d, want 0/2", m, d)
|
|
}
|
|
// Next day: both reset.
|
|
if m, d := u.snapshot(base.Add(25 * time.Hour)); m != 0 || d != 0 {
|
|
t.Errorf("next-day snapshot = %d/%d, want 0/0", m, d)
|
|
}
|
|
}
|