Pulse/internal/api/router_auth_additional_test.go
2026-03-18 16:06:30 +00:00

294 lines
9.9 KiB
Go

package api
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
)
func newAuthRouter(t *testing.T) *Router {
t.Helper()
hashed, err := auth.HashPassword("currentpassword")
if err != nil {
t.Fatalf("hash password: %v", err)
}
return &Router{
config: &config.Config{
AuthUser: "admin",
AuthPass: hashed,
ConfigPath: t.TempDir(),
},
}
}
func TestHandleChangePassword_InvalidJSON(t *testing.T) {
router := newAuthRouter(t)
req := httptest.NewRequest(http.MethodPost, "/api/change-password", strings.NewReader("{"))
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("admin:currentpassword")))
rec := httptest.NewRecorder()
router.handleChangePassword(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
var payload map[string]interface{}
if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload["code"] != "invalid_request" {
t.Fatalf("expected invalid_request, got %#v", payload["code"])
}
}
func TestHandleChangePassword_InvalidPassword(t *testing.T) {
router := newAuthRouter(t)
body := `{"currentPassword":"currentpassword","newPassword":"short"}`
req := httptest.NewRequest(http.MethodPost, "/api/change-password", strings.NewReader(body))
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("admin:currentpassword")))
rec := httptest.NewRecorder()
router.handleChangePassword(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
var payload map[string]interface{}
if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload["code"] != "invalid_password" {
t.Fatalf("expected invalid_password, got %#v", payload["code"])
}
}
func TestHandleChangePassword_MissingCurrent(t *testing.T) {
router := newAuthRouter(t)
body := `{"currentPassword":"","newPassword":"newpassword123"}`
req := httptest.NewRequest(http.MethodPost, "/api/change-password", strings.NewReader(body))
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("admin:currentpassword")))
rec := httptest.NewRecorder()
router.handleChangePassword(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
var payload map[string]interface{}
if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload["code"] != "unauthorized" {
t.Fatalf("expected unauthorized, got %#v", payload["code"])
}
}
func TestHandleChangePassword_SuccessDocker(t *testing.T) {
router := newAuthRouter(t)
t.Setenv("PULSE_DOCKER", "true")
body := `{"currentPassword":"currentpassword","newPassword":"newpassword123"}`
req := httptest.NewRequest(http.MethodPost, "/api/change-password", strings.NewReader(body))
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("admin:currentpassword")))
rec := httptest.NewRecorder()
router.handleChangePassword(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var payload map[string]interface{}
if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if ok, _ := payload["success"].(bool); !ok {
t.Fatalf("expected success=true, got %#v", payload["success"])
}
envPath := filepath.Join(router.config.ConfigPath, ".env")
if _, err := os.Stat(envPath); err != nil {
t.Fatalf("expected .env to be written, got error: %v", err)
}
}
func TestHandleResetLockout_InvalidJSON(t *testing.T) {
router := newAuthRouter(t)
req := httptest.NewRequest(http.MethodPost, "/api/reset-lockout", strings.NewReader("{"))
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("admin:currentpassword")))
rec := httptest.NewRecorder()
router.handleResetLockout(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
func TestHandleResetLockout_MissingIdentifier(t *testing.T) {
router := newAuthRouter(t)
req := httptest.NewRequest(http.MethodPost, "/api/reset-lockout", strings.NewReader(`{"identifier":""}`))
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("admin:currentpassword")))
rec := httptest.NewRecorder()
router.handleResetLockout(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
func TestHandleResetLockout_Success(t *testing.T) {
router := newAuthRouter(t)
req := httptest.NewRequest(http.MethodPost, "/api/reset-lockout", strings.NewReader(`{"identifier":"user1"}`))
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("admin:currentpassword")))
rec := httptest.NewRecorder()
router.handleResetLockout(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var payload map[string]interface{}
if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if ok, _ := payload["success"].(bool); !ok {
t.Fatalf("expected success=true, got %#v", payload["success"])
}
}
// TestHandleChangePassword_InvalidatesSessionsDocker verifies the security-critical
// invariant that all existing sessions are invalidated after a successful password
// change. Without this, stolen session tokens remain valid even after the user
// changes their password.
func TestHandleChangePassword_InvalidatesSessionsDocker(t *testing.T) {
resetSessionTracking()
authLimiter.Reset("192.0.2.1")
defer authLimiter.Reset("192.0.2.1")
dir := t.TempDir()
InitSessionStore(dir)
InitCSRFStore(dir)
t.Setenv("PULSE_DOCKER", "true")
router := newAuthRouter(t)
store := GetSessionStore()
// Create two active sessions for the admin user.
sessA := generateSessionToken()
sessB := generateSessionToken()
store.CreateSession(sessA, time.Hour, "browser", "10.0.0.1", "admin")
store.CreateSession(sessB, time.Hour, "browser", "10.0.0.2", "admin")
TrackUserSession("admin", sessA)
TrackUserSession("admin", sessB)
// Generate CSRF tokens tied to the sessions.
csrfA := generateCSRFToken(sessA)
csrfB := generateCSRFToken(sessB)
// Sanity: sessions and CSRF tokens are valid before password change.
if !ValidateSession(sessA) || !ValidateSession(sessB) {
t.Fatal("sessions must be valid before password change")
}
if !GetCSRFStore().ValidateCSRFToken(sessA, csrfA) || !GetCSRFStore().ValidateCSRFToken(sessB, csrfB) {
t.Fatal("CSRF tokens must be valid before password change")
}
// Execute password change.
body := `{"currentPassword":"currentpassword","newPassword":"newpassword123"}`
req := httptest.NewRequest(http.MethodPost, "/api/change-password", strings.NewReader(body))
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("admin:currentpassword")))
rec := httptest.NewRecorder()
router.handleChangePassword(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("password change failed: status = %d, body = %s", rec.Code, rec.Body.String())
}
// Sessions must be invalidated.
if ValidateSession(sessA) {
t.Error("session A must be invalid after password change")
}
if ValidateSession(sessB) {
t.Error("session B must be invalid after password change")
}
// CSRF tokens tied to those sessions must also be gone.
if GetCSRFStore().ValidateCSRFToken(sessA, csrfA) {
t.Error("CSRF token A must be invalid after password change")
}
if GetCSRFStore().ValidateCSRFToken(sessB, csrfB) {
t.Error("CSRF token B must be invalid after password change")
}
// Session-to-user tracking must be cleared.
if GetSessionUsername(sessA) != "" {
t.Error("session A user tracking must be cleared after password change")
}
if GetSessionUsername(sessB) != "" {
t.Error("session B user tracking must be cleared after password change")
}
}
// TestHandleChangePassword_InvalidatesSessionsNonDocker verifies the same
// session invalidation invariant for the non-Docker (systemd/manual) code path.
func TestHandleChangePassword_InvalidatesSessionsNonDocker(t *testing.T) {
resetSessionTracking()
authLimiter.Reset("192.0.2.1")
defer authLimiter.Reset("192.0.2.1")
dir := t.TempDir()
InitSessionStore(dir)
InitCSRFStore(dir)
t.Setenv("PULSE_DOCKER", "false")
router := newAuthRouter(t)
// Point ConfigPath to a writable temp dir so .env write succeeds.
router.config.ConfigPath = dir
store := GetSessionStore()
// Create an active session.
sess := generateSessionToken()
store.CreateSession(sess, time.Hour, "browser", "10.0.0.1", "admin")
TrackUserSession("admin", sess)
csrf := generateCSRFToken(sess)
// Sanity: session and CSRF token are valid before password change.
if !ValidateSession(sess) {
t.Fatal("session must be valid before password change")
}
if !GetCSRFStore().ValidateCSRFToken(sess, csrf) {
t.Fatal("CSRF token must be valid before password change")
}
body := `{"currentPassword":"currentpassword","newPassword":"newpassword123"}`
req := httptest.NewRequest(http.MethodPost, "/api/change-password", strings.NewReader(body))
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("admin:currentpassword")))
rec := httptest.NewRecorder()
router.handleChangePassword(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("password change failed: status = %d, body = %s", rec.Code, rec.Body.String())
}
if ValidateSession(sess) {
t.Error("session must be invalid after password change")
}
if GetCSRFStore().ValidateCSRFToken(sess, csrf) {
t.Error("CSRF token must be invalid after password change")
}
if GetSessionUsername(sess) != "" {
t.Error("session user tracking must be cleared after password change")
}
}