mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 05:29:45 +00:00
7cbeb99c7d
The focused auth batch panicked before reaching request enforcement because these two controls relied on another test to initialise the global session store. Initialise real temporary persistence in each fixture and own its shutdown, without weakening the invalid-credential assertions or changing runtime bootstrap. Change-source: pulse-maintainer
223 lines
6.8 KiB
Go
223 lines
6.8 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
)
|
|
|
|
func TestSnapshotLocalAuthCredentialsLockedDoesNotReenterConfigLock(t *testing.T) {
|
|
cfg := &config.Config{AuthUser: "admin", AuthPass: "hash"}
|
|
|
|
// Hold the same outer read lock as checkAuth, then queue a writer exactly as
|
|
// API-token validation does. Go's RWMutex blocks any new reader behind that
|
|
// writer, so a recursive RLock in the snapshot would never return.
|
|
config.Mu.RLock()
|
|
outerLocked := true
|
|
defer func() {
|
|
if outerLocked {
|
|
config.Mu.RUnlock()
|
|
}
|
|
}()
|
|
|
|
writerAttempting := make(chan struct{})
|
|
writerAcquired := make(chan struct{})
|
|
writerDone := make(chan struct{})
|
|
go func() {
|
|
close(writerAttempting)
|
|
config.Mu.Lock()
|
|
close(writerAcquired)
|
|
config.Mu.Unlock()
|
|
close(writerDone)
|
|
}()
|
|
<-writerAttempting
|
|
runtime.Gosched()
|
|
|
|
snapshotDone := make(chan [2]string, 1)
|
|
go func() {
|
|
user, pass := snapshotLocalAuthCredentialsLocked(cfg)
|
|
snapshotDone <- [2]string{user, pass}
|
|
}()
|
|
|
|
select {
|
|
case got := <-snapshotDone:
|
|
if got != [2]string{"admin", "hash"} {
|
|
t.Fatalf("credential snapshot = %#v, want admin/hash", got)
|
|
}
|
|
case <-writerAcquired:
|
|
t.Fatal("writer acquired config.Mu while the outer read lock was held")
|
|
case <-time.After(250 * time.Millisecond):
|
|
config.Mu.RUnlock()
|
|
outerLocked = false
|
|
<-writerDone
|
|
t.Fatal("credential snapshot blocked behind a pending config writer; recursive RLock deadlock regressed")
|
|
}
|
|
|
|
config.Mu.RUnlock()
|
|
outerLocked = false
|
|
select {
|
|
case <-writerDone:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("queued config writer did not complete after releasing outer read lock")
|
|
}
|
|
}
|
|
|
|
func TestCheckAuth_APIOnlyModeRequiresToken(t *testing.T) {
|
|
record, err := config.NewAPITokenRecord("token-required-123.12345678", "api", []string{config.ScopeMonitoringRead})
|
|
if err != nil {
|
|
t.Fatalf("NewAPITokenRecord: %v", err)
|
|
}
|
|
cfg := &config.Config{
|
|
APITokens: []config.APITokenRecord{*record},
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
|
|
rr := httptest.NewRecorder()
|
|
|
|
if CheckAuth(cfg, rr, req) {
|
|
t.Fatalf("expected CheckAuth to fail without token in API-only mode")
|
|
}
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d", rr.Code)
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "API token required") {
|
|
t.Fatalf("expected API token required message, got %q", rr.Body.String())
|
|
}
|
|
if rr.Header().Get("WWW-Authenticate") == "" {
|
|
t.Fatalf("expected WWW-Authenticate header to be set")
|
|
}
|
|
}
|
|
|
|
func TestCheckAuth_APIOnlyModeRejectsInvalidToken(t *testing.T) {
|
|
record, err := config.NewAPITokenRecord("token-valid-123.12345678", "api", []string{config.ScopeMonitoringRead})
|
|
if err != nil {
|
|
t.Fatalf("NewAPITokenRecord: %v", err)
|
|
}
|
|
cfg := &config.Config{
|
|
APITokens: []config.APITokenRecord{*record},
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
|
|
req.Header.Set("X-API-Token", "token-invalid-123.12345678")
|
|
rr := httptest.NewRecorder()
|
|
|
|
if CheckAuth(cfg, rr, req) {
|
|
t.Fatalf("expected CheckAuth to fail with invalid token")
|
|
}
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestCheckAuth_APIOnlyModeAcceptsQueryToken(t *testing.T) {
|
|
rawToken := "token-query-123.12345678"
|
|
record, err := config.NewAPITokenRecord(rawToken, "api", []string{config.ScopeMonitoringRead})
|
|
if err != nil {
|
|
t.Fatalf("NewAPITokenRecord: %v", err)
|
|
}
|
|
cfg := &config.Config{
|
|
APITokens: []config.APITokenRecord{*record},
|
|
}
|
|
|
|
// Query-string tokens are rejected on regular HTTP to prevent URL-based leakage.
|
|
req := httptest.NewRequest(http.MethodGet, "/api/test?token="+rawToken, nil)
|
|
rr := httptest.NewRecorder()
|
|
|
|
if CheckAuth(cfg, rr, req) {
|
|
t.Fatalf("expected CheckAuth to reject query token on regular HTTP request")
|
|
}
|
|
|
|
// Query-string tokens are accepted on WebSocket upgrade requests.
|
|
wsReq := httptest.NewRequest(http.MethodGet, "/api/test?token="+rawToken, nil)
|
|
wsReq.Header.Set("Upgrade", "websocket")
|
|
wsReq.Header.Set("Connection", "Upgrade")
|
|
wsRR := httptest.NewRecorder()
|
|
|
|
if !CheckAuth(cfg, wsRR, wsReq) {
|
|
t.Fatalf("expected CheckAuth to succeed with query token on WebSocket upgrade")
|
|
}
|
|
if wsRR.Header().Get("X-Auth-Method") != "api_token" {
|
|
t.Fatalf("expected X-Auth-Method api_token, got %q", wsRR.Header().Get("X-Auth-Method"))
|
|
}
|
|
}
|
|
|
|
func TestCheckAuth_AcceptsBearerToken(t *testing.T) {
|
|
rawToken := "token-bearer-123.12345678"
|
|
record, err := config.NewAPITokenRecord(rawToken, "api", []string{config.ScopeMonitoringRead})
|
|
if err != nil {
|
|
t.Fatalf("NewAPITokenRecord: %v", err)
|
|
}
|
|
cfg := &config.Config{
|
|
APITokens: []config.APITokenRecord{*record},
|
|
AuthUser: "admin",
|
|
AuthPass: "$2a$10$dummy",
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
|
|
req.Header.Set("Authorization", "Bearer "+rawToken)
|
|
rr := httptest.NewRecorder()
|
|
|
|
if !CheckAuth(cfg, rr, req) {
|
|
t.Fatalf("expected CheckAuth to succeed with bearer token")
|
|
}
|
|
if rr.Header().Get("X-Auth-Method") != "api_token" {
|
|
t.Fatalf("expected X-Auth-Method api_token, got %q", rr.Header().Get("X-Auth-Method"))
|
|
}
|
|
}
|
|
|
|
func TestCheckAuth_InvalidBearerDoesNotFallBackToSession(t *testing.T) {
|
|
InitSessionStore(t.TempDir())
|
|
t.Cleanup(GetSessionStore().Shutdown)
|
|
|
|
record, err := config.NewAPITokenRecord("token-valid-123.12345678", "api", []string{config.ScopeMonitoringRead})
|
|
if err != nil {
|
|
t.Fatalf("NewAPITokenRecord: %v", err)
|
|
}
|
|
cfg := &config.Config{
|
|
APITokens: []config.APITokenRecord{*record},
|
|
AuthUser: "admin",
|
|
AuthPass: "$2a$10$dummy",
|
|
}
|
|
|
|
sessionToken := generateSessionToken()
|
|
GetSessionStore().CreateSession(sessionToken, time.Hour, "agent", "127.0.0.1", "alice")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
|
|
req.Header.Set("Authorization", "Bearer token-invalid-123.12345678")
|
|
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken})
|
|
rr := httptest.NewRecorder()
|
|
|
|
if CheckAuth(cfg, rr, req) {
|
|
t.Fatalf("expected CheckAuth to reject invalid bearer token even with a valid session")
|
|
}
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d", rr.Code)
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "Invalid API token") {
|
|
t.Fatalf("expected invalid token response, got %q", rr.Body.String())
|
|
}
|
|
if rr.Header().Get("X-Auth-Method") == "session" {
|
|
t.Fatalf("did not expect session auth fallback for invalid bearer token")
|
|
}
|
|
}
|
|
|
|
func TestCheckAuth_NilConfigFailsClosed(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
|
|
rr := httptest.NewRecorder()
|
|
|
|
if CheckAuth(nil, rr, req) {
|
|
t.Fatalf("expected CheckAuth to fail when config is nil")
|
|
}
|
|
if rr.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("expected 503, got %d", rr.Code)
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "Authentication unavailable") {
|
|
t.Fatalf("expected authentication unavailable message, got %q", rr.Body.String())
|
|
}
|
|
}
|