package server import ( "io" "log/slog" "net/http" "net/http/httptest" "sync/atomic" "testing" "time" ) const testUserHeader = "X-Uopi-User" func quietLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } // echoUser is a terminal handler that reports the resolved identity header so a // test can assert what basicAuth stamped. func echoUser() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = io.WriteString(w, r.Header.Get(testUserHeader)) }) } func basicReq(t *testing.T, user, pass string, setHeader string) *http.Request { t.Helper() r := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) if user != "" || pass != "" { r.SetBasicAuth(user, pass) } if setHeader != "" { r.Header.Set(testUserHeader, setHeader) // a spoof attempt } return r } // authFn that accepts only alice/secret. func aliceAuth(calls *int32) func(string, string) error { return func(user, pass string) error { atomic.AddInt32(calls, 1) if user == "alice" && pass == "secret" { return nil } return pamErr{} } } type pamErr struct{} func (pamErr) Error() string { return "bad credentials" } func TestBasicAuthChallengesWithoutCredentials(t *testing.T) { cache := newCredCache(time.Minute) h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", true, cache, quietLogger(), echoUser()) rec := httptest.NewRecorder() h.ServeHTTP(rec, basicReq(t, "", "", "")) if rec.Code != http.StatusUnauthorized { t.Fatalf("want 401, got %d", rec.Code) } if got := rec.Header().Get("WWW-Authenticate"); got == "" { t.Fatalf("missing WWW-Authenticate challenge header") } } func TestBasicAuthValidCredentialsStampUser(t *testing.T) { cache := newCredCache(time.Minute) h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", true, cache, quietLogger(), echoUser()) rec := httptest.NewRecorder() // Client also tries to spoof the identity header; it must be ignored. h.ServeHTTP(rec, basicReq(t, "alice", "secret", "root")) if rec.Code != http.StatusOK { t.Fatalf("want 200, got %d", rec.Code) } if got := rec.Body.String(); got != "alice" { t.Fatalf("want resolved user alice, got %q", got) } } func TestBasicAuthInvalidCredentialsRejected(t *testing.T) { cache := newCredCache(time.Minute) h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", true, cache, quietLogger(), echoUser()) rec := httptest.NewRecorder() h.ServeHTTP(rec, basicReq(t, "alice", "wrong", "")) if rec.Code != http.StatusUnauthorized { t.Fatalf("want 401, got %d", rec.Code) } } func TestBasicAuthBestEffortFallsThrough(t *testing.T) { cache := newCredCache(time.Minute) // challenge=false (WebSocket path): no creds → pass through unauthenticated. h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", false, cache, quietLogger(), echoUser()) rec := httptest.NewRecorder() h.ServeHTTP(rec, basicReq(t, "", "", "")) if rec.Code != http.StatusOK { t.Fatalf("want 200 pass-through, got %d", rec.Code) } if got := rec.Body.String(); got != "" { t.Fatalf("want empty identity, got %q", got) } } func TestBasicAuthCachesSuccess(t *testing.T) { var calls int32 cache := newCredCache(time.Minute) h := basicAuth(aliceAuth(&calls), testUserHeader, "uopi", true, cache, quietLogger(), echoUser()) for i := 0; i < 3; i++ { rec := httptest.NewRecorder() h.ServeHTTP(rec, basicReq(t, "alice", "secret", "")) if rec.Code != http.StatusOK { t.Fatalf("req %d: want 200, got %d", i, rec.Code) } } if got := atomic.LoadInt32(&calls); got != 1 { t.Fatalf("want authFn called once (cached), got %d", got) } }