package plugins import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "sync" "testing" "drivervault/apiserver/internal/pb" ) // fakePB is the slice of PocketBase the settings store touches: superuser auth, // and list/create/update on one collection. It holds a single record so the // singleton behaviour can be asserted directly. type fakePB struct { mu sync.Mutex recordID string // "" while no record exists settings json.RawMessage // the stored pluginSettings value listErr bool // make list calls fail (database unreachable) creates int updates int missingCol bool // answer list with a 404, as if bootstrap had not run } func (f *fakePB) server(t *testing.T) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() switch { case strings.HasSuffix(r.URL.Path, "/auth-with-password"): writeTestJSON(w, http.StatusOK, map[string]any{"token": "test-token"}) case r.Method == http.MethodGet && r.URL.Path == "/api/collections/app_settings/records": if f.listErr { http.Error(w, "connection refused", http.StatusBadGateway) return } if f.missingCol { http.Error(w, `{"message":"Missing collection context."}`, http.StatusNotFound) return } items := []any{} if f.recordID != "" { items = append(items, map[string]any{ "id": f.recordID, "key": globalSettingsKey, "pluginSettings": f.settings, }) } writeTestJSON(w, http.StatusOK, map[string]any{ "page": 1, "perPage": 1, "totalItems": len(items), "totalPages": 1, "items": items, }) case r.Method == http.MethodPost && r.URL.Path == "/api/collections/app_settings/records": var body struct { Key string `json:"key"` PluginSettings json.RawMessage `json:"pluginSettings"` } _ = json.NewDecoder(r.Body).Decode(&body) if body.Key != globalSettingsKey { t.Errorf("create used key %q, want %q", body.Key, globalSettingsKey) } f.creates++ f.recordID = "rec1" f.settings = body.PluginSettings writeTestJSON(w, http.StatusOK, map[string]any{"id": f.recordID}) case r.Method == http.MethodPatch && strings.HasPrefix(r.URL.Path, "/api/collections/app_settings/records/"): id := strings.TrimPrefix(r.URL.Path, "/api/collections/app_settings/records/") if id != f.recordID { http.Error(w, `{"message":"not found"}`, http.StatusNotFound) return } var body struct { PluginSettings json.RawMessage `json:"pluginSettings"` } _ = json.NewDecoder(r.Body).Decode(&body) f.updates++ f.settings = body.PluginSettings writeTestJSON(w, http.StatusOK, map[string]any{"id": id}) default: t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) http.Error(w, "unexpected", http.StatusInternalServerError) } })) t.Cleanup(srv.Close) return srv } func writeTestJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func newPBStore(t *testing.T, f *fakePB) Store { t.Helper() srv := f.server(t) return NewPocketBaseStore(pb.New(srv.URL, "admin@test.local", "pw"), "app_settings") } // The first save creates the singleton; later saves update it in place. func TestPBStoreCreatesThenUpdatesOneRecord(t *testing.T) { f := &fakePB{} m := loadedManager(t, newPBStore(t, f)) ctx := context.Background() if _, err := m.Upsert(ctx, "test-a", true, map[string]string{"user": "me"}); err != nil { t.Fatalf("first save: %v", err) } if _, err := m.Upsert(ctx, "test-b", true, nil); err != nil { t.Fatalf("second save: %v", err) } f.mu.Lock() creates, updates := f.creates, f.updates f.mu.Unlock() if creates != 1 { t.Fatalf("expected exactly one record to be created, got %d", creates) } if updates != 1 { t.Fatalf("expected the second save to update, got %d updates", updates) } } // Settings written through PocketBase must come back on the next boot. func TestPBStoreRoundTrip(t *testing.T) { f := &fakePB{} store := newPBStore(t, f) m := loadedManager(t, store) if _, err := m.Upsert(context.Background(), "test-a", true, map[string]string{"user": "me"}); err != nil { t.Fatal(err) } // A fresh Manager on a fresh store, as after a restart. m2 := loadedManager(t, newPBStore(t, f)) v, ok := m2.Get("test-a") if !ok || !v.Enabled || v.Config["user"] != "me" { t.Fatalf("settings did not survive: ok=%v enabled=%v config=%v", ok, v.Enabled, v.Config) } } // A fresh install (no record yet) loads cleanly as "nothing configured". func TestPBStoreEmptyIsNotAnError(t *testing.T) { m := loadedManager(t, newPBStore(t, &fakePB{})) for _, v := range m.List() { if v.Enabled { t.Fatalf("%s should not be enabled on a fresh install", v.Name) } } } // An unreachable database must not read as "no plugins configured", and must // not let anything be written over settings that were never read. func TestPBStoreUnreachableStaysUnloaded(t *testing.T) { f := &fakePB{recordID: "rec1", settings: json.RawMessage(`{"test-a":{"enabled":true}}`), listErr: true} store := newPBStore(t, f) m := NewManager(store) err := m.Load(context.Background()) if !IsNotReady(err) { t.Fatalf("expected not-ready, got %v", err) } if m.Ready() { t.Fatal("manager must not be ready") } if _, err := m.Upsert(context.Background(), "test-a", false, nil); !IsNotReady(err) { t.Fatalf("Upsert should refuse, got %v", err) } // The database comes back; the real settings load and are intact. f.mu.Lock() f.listErr = false f.mu.Unlock() if err := m.Load(context.Background()); err != nil { t.Fatalf("reload after recovery: %v", err) } if v, _ := m.Get("test-a"); !v.Enabled { t.Fatal("settings lost across the outage") } } // A missing collection (bootstrap has not run) is "not ready", never "empty" — // otherwise the first save would write a fresh document over nothing. It is also // tagged separately, because retrying alone can never resolve it. func TestPBStoreMissingCollectionIsNotReady(t *testing.T) { m := NewManager(newPBStore(t, &fakePB{missingCol: true})) err := m.Load(context.Background()) if !IsNotReady(err) { t.Fatalf("expected not-ready for a missing collection, got %v", err) } if !IsMissingCollection(err) { t.Fatalf("a missing collection must be distinguishable, got %v", err) } if m.Ready() { t.Fatal("manager must not be ready without its collection") } } // A plain outage must NOT look like a missing collection: creating the // collection is not the remedy for a database that is merely unreachable. func TestPBStoreOutageIsNotAMissingCollection(t *testing.T) { m := NewManager(newPBStore(t, &fakePB{listErr: true})) err := m.Load(context.Background()) if !IsNotReady(err) { t.Fatalf("expected not-ready, got %v", err) } if IsMissingCollection(err) { t.Fatalf("an outage must not be reported as a missing collection: %v", err) } }