Files
DriverVault/API Server/internal/pb/client_test.go
T
tajniak81andClaude Opus 5 d4b9d0870e The server notices its own token has run out
Signing in worked and everything after it did not: the login screen took the
password, PocketBase handed back the record and the token, and then /api/me
answered "The requested resource wasn't found." The three symptoms underneath
looked unrelated — a 404 on the profile, an organization whose name came back
empty, a user list with nobody in it — and they are one thing. The API Server's
superuser token had expired.

PocketBase does not say so. A record call carrying a token it no longer accepts
is not refused; the header is ignored and the request is served as a guest, and
the collection rules answer in the transport's place. The organizations
collection is superuser-only, so it 403s. A user record is guarded by a view
rule, so it 404s — hidden rather than denied. The users list is rule-filtered,
so it comes back 200 with an empty array. Not one of those is a 401, and a 401
was the only thing that made this client sign in again.

So the token was acquired once at startup and then kept for the life of the
container, and the retry meant to renew it could never fire. Uptime longer than
the token's lifetime was all it took. Nothing had to change for it to break,
which is why it broke on a stack nobody had touched.

The client now reads the exp claim PocketBase stamps into the token and renews
before spending it, a minute early so a call cannot land just after it lapses.
A token with no readable expiry is still taken at face value, and the 401 retry
stays where it is as the backstop for the case this does not cover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 11:52:39 +02:00

308 lines
10 KiB
Go

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