Files
DriverVault/API Server/internal/pb/client_test.go
T
tajniak81andClaude Opus 5 fa569c4030 Sign in before the first call, not after a 401 that never comes
Logging in failed on both the Web App and the Phone App with PocketBase's
{"data":{},"message":"The requested resource wasn't found.","status":404}.
The login itself succeeded; the profile fetch right after it — GET /api/me
— was what 404ed, and the web app renders the relayed body on the login
form, so it read as a rejected sign-in.

PocketBase answers a record read it will not allow with 404 rather than
401: it hides the record instead of refusing the credentials. The pb
client only re-authenticated on a 401, so with no cached token the first
request went out carrying no Authorization header at all, came back 404,
and the retry never fired. Nothing ever tried again — the server kept
404ing long after PocketBase was healthy.

The cache is empty in exactly the two cases that matter: a startup where
the up-front Authenticate failed because PocketBase wasn't up yet, which
main.go treats as non-fatal on purpose so a superadmin can still log in
and fix the connection; and a Reconfigure from the panel, which clears
the token so the new credentials get used.

So acquire the token before the first attempt rather than hoping for a
401 to prompt it, across all four superuser paths. Bad credentials now
surface as the authentication failure they are instead of masquerading
as a missing record. With no service account configured there is nothing
to acquire and the call proceeds as before, since the endpoints that need
superuser access already answer 503 on their own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 10:33:33 +02:00

193 lines
6.0 KiB
Go

package pb
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
)
// 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
authCalls int
lastIdentity string
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
}
writeTestJSON(w, 200, map[string]any{"token": fakeSvcToken})
})
mux.HandleFunc("GET /api/collections/users/records/{id}", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != fakeSvcToken {
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)
}
}