Files
pulse/internal/api/change_password_session_admin_test.go
T
pulse-triage[bot] 63f9bdcbbe test(api): own password-change fixture router lifecycle
Register shared router cleanup at the password-change constructor boundary so workers are cancelled and joined before temporary state is removed. Add a subtest-boundary regression that fails without cleanup and retain the three affected authorisation tests. No production behaviour or qualification threshold changes.

Change-source: pulse-maintainer
2026-09-08 21:53:18 +01:00

116 lines
3.8 KiB
Go

package api
import (
"encoding/base64"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"golang.org/x/crypto/bcrypt"
)
const changePasswordProbeCurrent = "correct-admin-password"
func newChangePasswordRouter(t *testing.T) (*Router, *config.Config) {
t.Helper()
dir := t.TempDir()
hashed, err := bcrypt.GenerateFromPassword([]byte(changePasswordProbeCurrent), bcrypt.DefaultCost)
if err != nil {
t.Fatalf("bcrypt: %v", err)
}
cfg := &config.Config{DataPath: dir, ConfigPath: dir, AuthUser: "admin", AuthPass: string(hashed)}
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
cleanupTestRouter(t, router)
return router, cfg
}
func changePasswordRequest(t *testing.T, current string) *http.Request {
t.Helper()
body := `{"currentPassword":"` + current + `","newPassword":"Str0ng-New-Passw0rd!x"}`
req := httptest.NewRequest(http.MethodPost, "/api/security/change-password", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
return req
}
func attachChangePasswordSession(t *testing.T, req *http.Request, user string) {
t.Helper()
tok := "change-pw-" + strconv.FormatInt(time.Now().UnixNano(), 10)
GetSessionStore().CreateSession(tok, time.Hour, "browser", "127.0.0.1", user)
req.AddCookie(&http.Cookie{Name: sessionCookieName(false), Value: tok})
req.Header.Set("X-CSRF-Token", generateCSRFToken(tok))
}
// The proxy branch of handleChangePassword refuses non-admin proxy users. The
// session branch did not, so any authenticated non-admin session could submit
// guesses at the local admin password and read the answer from the 401, and
// could change it outright once it knew it.
func TestChangePasswordRefusesNonAdminSession(t *testing.T) {
router, _ := newChangePasswordRouter(t)
req := changePasswordRequest(t, changePasswordProbeCurrent)
attachChangePasswordSession(t, req, "sso:outsider@example.com")
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("non-admin session change-password = %d, want 403 (body %s)", rec.Code, rec.Body.String())
}
}
// The configured admin must still be able to change their own password.
func TestChangePasswordAllowsConfiguredAdminSession(t *testing.T) {
router, _ := newChangePasswordRouter(t)
req := changePasswordRequest(t, changePasswordProbeCurrent)
attachChangePasswordSession(t, req, "admin")
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("configured admin change-password = %d, want 200 (body %s)", rec.Code, rec.Body.String())
}
}
// Basic Auth carries no session cookie, so the session gate must not touch it.
func TestChangePasswordBasicAuthPathUnaffected(t *testing.T) {
router, _ := newChangePasswordRouter(t)
req := changePasswordRequest(t, changePasswordProbeCurrent)
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString(
[]byte("admin:"+changePasswordProbeCurrent)))
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code == http.StatusForbidden {
t.Fatalf("Basic Auth change-password must not be refused by the session gate, got 403 (body %s)", rec.Body.String())
}
}
func TestChangePasswordFixtureOwnsRouterLifecycle(t *testing.T) {
var router *Router
workerDone := make(chan struct{})
t.Run("fixture", func(t *testing.T) {
router, _ = newChangePasswordRouter(t)
router.startLifecycleWorker(func() {
<-router.lifecycleCtx.Done()
close(workerDone)
})
})
// Ensure a failing regression does not itself leave workers running.
cleanupTestRouter(t, router)
select {
case <-router.lifecycleCtx.Done():
default:
t.Fatal("password-change fixture left router lifecycle active")
}
select {
case <-workerDone:
default:
t.Fatal("password-change fixture did not join its router worker")
}
}