package pb import ( "context" "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "sync" "testing" "time" ) // PocketBase hides a record it will not let you read behind a 404 rather than a // 401. A superuser call that goes out with no token therefore comes back // "not found", and the client's 401 retry never fires — so these tests pin down // that the client signs in *before* its first request whenever it holds no // token: after a startup where the up-front Authenticate failed, and after a // Reconfigure onto new credentials. const ( fakeSvcToken = "svc-token" fakeRecordID = "rec1" ) // fakePB stands in for PocketBase: it serves one record, but only to a caller // carrying the superuser token, and records how it was signed into. type fakePB struct { mu sync.Mutex // Credentials that will be accepted. Everything else gets a 400, as // PocketBase gives for a bad identity/password. email string password string // When set, tokens are minted as JWTs carrying this lifetime in their exp // claim, the way PocketBase issues them. Zero keeps the opaque test token. jwtTTL time.Duration authCalls int lastIdentity string issued string // the token most recently handed out guestAttempts int // record reads that arrived without the superuser token } func (f *fakePB) handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("POST /api/collections/_superusers/auth-with-password", func(w http.ResponseWriter, r *http.Request) { var body struct{ Identity, Password string } _ = json.NewDecoder(r.Body).Decode(&body) f.mu.Lock() f.authCalls++ f.lastIdentity = body.Identity ok := body.Identity == f.email && body.Password == f.password f.mu.Unlock() if !ok { writeTestJSON(w, 400, map[string]any{"message": "Failed to authenticate."}) return } token := fakeSvcToken if f.jwtTTL > 0 { token = testJWT(time.Now().Add(f.jwtTTL)) } f.mu.Lock() f.issued = token f.mu.Unlock() writeTestJSON(w, 200, map[string]any{"token": token}) }) mux.HandleFunc("GET /api/collections/users/records/{id}", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() live := f.issued f.mu.Unlock() if got := r.Header.Get("Authorization"); got == "" || got != live { f.mu.Lock() f.guestAttempts++ f.mu.Unlock() writeTestJSON(w, 404, map[string]any{ "data": map[string]any{}, "message": "The requested resource wasn't found.", "status": 404, }) return } writeTestJSON(w, 200, map[string]any{"id": r.PathValue("id"), "email": "driver@test.local"}) }) return mux } func writeTestJSON(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(body) } func newFakePB(t *testing.T, f *fakePB) string { t.Helper() srv := httptest.NewServer(f.handler()) t.Cleanup(srv.Close) return srv.URL } func (f *fakePB) counts() (auth, guest int) { f.mu.Lock() defer f.mu.Unlock() return f.authCalls, f.guestAttempts } // A client that never managed to authenticate at startup must sign in on its // first call rather than sending it as a guest and reporting PocketBase's 404. func TestGetOneAuthenticatesWhenNoTokenCached(t *testing.T) { f := &fakePB{email: "admin@test.local", password: "pw"} c := New(newFakePB(t, f), f.email, f.password) var rec struct{ ID, Email string } if err := c.GetOne(context.Background(), "users", fakeRecordID, &rec); err != nil { t.Fatalf("GetOne: %v", err) } if rec.ID != fakeRecordID { t.Fatalf("record id = %q, want %q", rec.ID, fakeRecordID) } auth, guest := f.counts() if guest != 0 { t.Errorf("%d record read(s) went out unauthenticated, want 0", guest) } if auth != 1 { t.Errorf("authenticated %d time(s), want 1", auth) } } // The token is cached: a second call reuses it instead of signing in again. func TestGetOneReusesCachedToken(t *testing.T) { f := &fakePB{email: "admin@test.local", password: "pw"} c := New(newFakePB(t, f), f.email, f.password) for i := 0; i < 3; i++ { if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil { t.Fatalf("GetOne #%d: %v", i, err) } } if auth, _ := f.counts(); auth != 1 { t.Errorf("authenticated %d time(s), want 1", auth) } } // Retargeting from the panel clears the cached token, so the next call has to // sign in again — under the new credentials. func TestReconfigureReauthenticates(t *testing.T) { f := &fakePB{email: "admin@test.local", password: "pw"} url := newFakePB(t, f) c := New(url, f.email, f.password) if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil { t.Fatalf("GetOne before reconfigure: %v", err) } f.mu.Lock() f.email, f.password = "new@test.local", "pw2" f.mu.Unlock() c.Reconfigure(url, "new@test.local", "pw2") if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil { t.Fatalf("GetOne after reconfigure: %v", err) } auth, guest := f.counts() if guest != 0 { t.Errorf("%d record read(s) went out unauthenticated, want 0", guest) } if auth != 2 { t.Errorf("authenticated %d time(s), want 2", auth) } f.mu.Lock() defer f.mu.Unlock() if f.lastIdentity != "new@test.local" { t.Errorf("signed in as %q, want the reconfigured account", f.lastIdentity) } } // Bad credentials must surface as the authentication failure they are, not as // PocketBase's 404 for a record the client was never allowed to see. func TestGetOneReportsAuthFailureNotNotFound(t *testing.T) { f := &fakePB{email: "admin@test.local", password: "pw"} c := New(newFakePB(t, f), "admin@test.local", "wrong") err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})) if err == nil { t.Fatal("GetOne succeeded with bad credentials") } if apiErr, ok := err.(*APIError); ok && apiErr.Status == http.StatusNotFound { t.Fatalf("got PocketBase's 404 instead of an auth failure: %v", err) } } // With no service account configured there is nothing to sign in with, so the // call goes out as before — the endpoints that need superuser access answer 503 // on their own rather than relying on this layer. func TestNoServiceAccountSkipsAuthentication(t *testing.T) { f := &fakePB{email: "admin@test.local", password: "pw"} c := New(newFakePB(t, f), "", "") if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err == nil { t.Fatal("GetOne succeeded without a service account") } if auth, _ := f.counts(); auth != 0 { t.Errorf("attempted %d sign-in(s) with no credentials, want 0", auth) } } // testJWT builds a token shaped like PocketBase's: three base64url segments, // the middle one carrying the exp claim. Only that claim is ever read, so the // header and signature are filler. func testJWT(exp time.Time) string { enc := func(v any) string { b, _ := json.Marshal(v) return base64.RawURLEncoding.EncodeToString(b) } return enc(map[string]string{"alg": "HS256", "typ": "JWT"}) + "." + enc(map[string]int64{"exp": exp.Unix()}) + ".sig" } func TestJWTExpiry(t *testing.T) { want := time.Now().Add(time.Hour).Truncate(time.Second) if got := jwtExpiry(testJWT(want)); !got.Equal(want) { t.Errorf("jwtExpiry = %v, want %v", got, want) } // An opaque (non-JWT) token has no readable expiry, and must not be mistaken // for one that expired at the zero time — tokenLive takes it at face value. for _, tok := range []string{"", "opaque", "a.b", "a.!!.c", "a." + base64.RawURLEncoding.EncodeToString([]byte("{}")) + ".c"} { if got := jwtExpiry(tok); !got.IsZero() { t.Errorf("jwtExpiry(%q) = %v, want zero", tok, got) } } } // The failure this whole mechanism exists for: a superuser token that has run // out. PocketBase does not answer 401 for one — it ignores the header and serves // the request as a guest, so the 401 retry never fires and the client would go // on presenting a dead token forever. The expiry check has to catch it first. func TestExpiredTokenIsRenewedBeforeUse(t *testing.T) { f := &fakePB{email: "admin@test.local", password: "pw", jwtTTL: time.Hour} c := New(newFakePB(t, f), f.email, f.password) if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil { t.Fatalf("GetOne: %v", err) } // Age the cached token past its expiry, as an uptime longer than the token's // lifetime would. c.mu.Lock() c.tokenExp = time.Now().Add(-time.Minute) c.mu.Unlock() var rec struct{ ID string } if err := c.GetOne(context.Background(), "users", fakeRecordID, &rec); err != nil { t.Fatalf("GetOne with an expired token: %v", err) } if rec.ID != fakeRecordID { t.Fatalf("record id = %q, want %q", rec.ID, fakeRecordID) } auth, guest := f.counts() if guest != 0 { t.Errorf("%d record read(s) went out on a dead token, want 0", guest) } if auth != 2 { t.Errorf("authenticated %d time(s), want 2 (startup + renewal)", auth) } } // A token close enough to its expiry that it could lapse mid-flight is renewed // rather than spent, so a call cannot land just after the token dies. func TestTokenNearingExpiryIsRenewed(t *testing.T) { f := &fakePB{email: "admin@test.local", password: "pw", jwtTTL: time.Hour} c := New(newFakePB(t, f), f.email, f.password) if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil { t.Fatalf("GetOne: %v", err) } c.mu.Lock() c.tokenExp = time.Now().Add(tokenSkew / 2) c.mu.Unlock() if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil { t.Fatalf("GetOne near expiry: %v", err) } if auth, _ := f.counts(); auth != 2 { t.Errorf("authenticated %d time(s), want 2 (startup + renewal)", auth) } } // A token with a real lifetime still ahead of it is reused — the expiry check // must not turn every call into a fresh sign-in. func TestLiveJWTIsReused(t *testing.T) { f := &fakePB{email: "admin@test.local", password: "pw", jwtTTL: time.Hour} c := New(newFakePB(t, f), f.email, f.password) for i := 0; i < 3; i++ { if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil { t.Fatalf("GetOne #%d: %v", i, err) } } if auth, _ := f.counts(); auth != 1 { t.Errorf("authenticated %d time(s), want 1", auth) } }