Files
DriverVault/API Server/internal/plugins/store_pb_test.go
T
tajniak81andClaude Opus 5 660af5736a Plugins: create the settings collection instead of waiting for it forever
01a8fec fixed the advice that led operators into this, but advice is not a
guard: a stack still running PB_BOOTSTRAP=false gets no app_settings
collection on upgrade, and the plugin panel sits at 503 while the retry
loop reads a collection that does not exist.

The fix is not to soften the reading. A missing collection stays "not
ready" rather than "no plugins configured", because the alternative lets
the first save write a fresh document over settings the server merely
failed to find - the failure this whole line of work exists to prevent.
Instead the server now fixes the cause: on a missing collection it creates
that collection and reads again.

Three pieces:

bootstrap.EnsureCollection creates one named collection from the desired
schema if absent, and nothing else. Deliberately narrower than Run - no
field reconcile elsewhere, no super-admin - so it is safe to call on a
deployment that turned the full bootstrap off. It creates the collection
the server cannot start without, not the schema the operator declined.

The store tells a missing collection apart from an outage. A 404 from a
list means the collection itself is gone: an existing but empty one answers
200 with no items. That is tagged errNoCollection, which wraps errNotReady
so every write is still refused, and IsMissingCollection narrows it. The
distinction matters because the remedies are opposites - creating
collections against a flaky database is exactly the wrong reflex, and a
test pins that an outage does not trigger it.

loadPlugins acts on the tag once, then re-reads. Failing to create is
reported as the original read error rather than the repair's, so the log
names the real problem.

Six tests: the tag and its negative in internal/plugins, and three in
internal/api against a fake PocketBase covering the collection being
created exactly once, an existing collection not being recreated, and an
outage creating nothing.

Docs from 01a8fec are corrected in the same pass - they said the panel
would answer 503 forever, which is no longer true. They now say what still
depends on the bootstrap (every other collection and field) and what does
not (app_settings alone).

go build, go vet and go test ./... pass; compose files still parse. Not
verified: no Docker CLI here, so the repair has not been exercised against
a real PocketBase, only the fake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:07:18 +02:00

255 lines
7.9 KiB
Go

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)
}
}
// The legacy file is imported into PocketBase exactly once.
func TestPBStoreLegacyImport(t *testing.T) {
f := &fakePB{}
store := newPBStore(t, f)
path := t.TempDir() + "/plugins.json"
if err := writeTestFile(path, `{"test-a":{"enabled":true,"config":{"user":"me"}}}`); err != nil {
t.Fatal(err)
}
migrated, err := MigrateLegacyFile(context.Background(), store, path)
if err != nil || !migrated {
t.Fatalf("import: migrated=%v err=%v", migrated, err)
}
m := loadedManager(t, store)
if v, _ := m.Get("test-a"); !v.Enabled || v.Config["user"] != "me" {
t.Fatalf("imported settings wrong: enabled=%v config=%v", v.Enabled, v.Config)
}
// Second boot: the store now has a document, so nothing is re-imported.
migrated, err = MigrateLegacyFile(context.Background(), store, path)
if err != nil || migrated {
t.Fatalf("second import: migrated=%v err=%v", migrated, err)
}
f.mu.Lock()
creates := f.creates
f.mu.Unlock()
if creates != 1 {
t.Fatalf("expected one create, got %d", creates)
}
}