Files
pulse/internal/api/security_first_run_reset_test.go
T
pulse-triage[bot] 7dd5982ace fix(auth): synchronise local admin permissions during setup
First-run setup saved the configured administrator but left the router authorizer on its startup identity. This split Settings capability checks: administrator-only panels remained visible while API Access and Pulse Intelligence were denied until restart. Keep the captured authorizer aligned when setup commits the identity, and clear its prior bypass on successful development reset.

Cover the exact split with real file-backed RBAC policy and session setup, preserve outsider denial and identity replacement, and synchronise concurrent policy reads. Extension implementations still require independent compatibility review; this is not installation or release acceptance.

Change-source: pulse-maintainer
2026-09-10 06:52:38 +01:00

226 lines
8.3 KiB
Go

package api
import (
"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 TestResetFirstRunSecurityRequiresDevMode(t *testing.T) {
t.Setenv("PULSE_DEV", "")
t.Setenv("NODE_ENV", "production")
record := newTokenRecord(t, "reset-first-run-token-123.12345678", []string{config.ScopeSettingsWrite}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodPost, "/api/security/dev/reset-first-run", nil)
req.Header.Set("X-API-Token", "reset-first-run-token-123.12345678")
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 outside dev mode, got %d", rec.Code)
}
}
func TestResetFirstRunSecurityClearsAuthAndReturnsBootstrapToken(t *testing.T) {
t.Setenv("PULSE_DEV", "true")
t.Setenv("NODE_ENV", "")
record := newTokenRecord(t, "reset-first-run-token-234.12345678", []string{config.ScopeSettingsWrite}, nil)
cfg := newTestConfigWithTokens(t, record)
cfg.AuthUser = "admin"
cfg.AuthPass = "hashed-password"
envPath, err := writeAuthEnvFile(cfg.ConfigPath, cfg.DataPath, []byte("PULSE_AUTH_USER='admin'\n"))
if err != nil {
t.Fatalf("writeAuthEnvFile: %v", err)
}
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
manager, err := auth.NewFileManager(t.TempDir())
if err != nil {
t.Fatal(err)
}
authorizer := auth.NewRBACAuthorizer(manager)
authorizer.SetAdminUser(cfg.AuthUser)
router.authorizer = authorizer
req := httptest.NewRequest(http.MethodPost, "/api/security/dev/reset-first-run", nil)
req.Header.Set("X-API-Token", "reset-first-run-token-234.12345678")
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (%s)", rec.Code, rec.Body.String())
}
if allowed, err := authorizer.Authorize(auth.WithUser(req.Context(), "admin"), auth.ActionAdmin, auth.ResourceUsers); allowed || err != nil {
t.Fatalf("reset retained configured-admin permission: allowed=%v err=%v", allowed, err)
}
var payload firstRunResetResponse
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if strings.TrimSpace(payload.BootstrapToken) == "" {
t.Fatal("expected bootstrapToken in response")
}
if strings.TrimSpace(payload.BootstrapTokenPath) == "" {
t.Fatal("expected bootstrapTokenPath in response")
}
if router.bootstrapTokenHash == "" || !router.bootstrapTokenValid(payload.BootstrapToken) {
t.Fatal("expected router to accept returned bootstrap token")
}
if cfg.AuthUser != "" || cfg.AuthPass != "" {
t.Fatalf("expected auth credentials cleared, got user=%q pass=%q", cfg.AuthUser, cfg.AuthPass)
}
if cfg.HasAPITokens() || cfg.APIToken != "" {
t.Fatalf("expected API tokens cleared, got %d tokens", len(cfg.APITokens))
}
if _, err := os.Stat(envPath); !os.IsNotExist(err) {
t.Fatalf("expected auth env file removed, stat err=%v", err)
}
persistence := config.NewConfigPersistence(cfg.DataPath)
tokens, err := persistence.LoadAPITokens()
if err != nil {
t.Fatalf("LoadAPITokens: %v", err)
}
if len(tokens) != 0 {
t.Fatalf("expected persisted API tokens cleared, got %d", len(tokens))
}
tokenPath := filepath.Join(cfg.DataPath, bootstrapTokenFilename)
if payload.BootstrapTokenPath != tokenPath {
t.Fatalf("bootstrap token path = %q, want %q", payload.BootstrapTokenPath, tokenPath)
}
}
func TestResetFirstRunSecurityClearsEnvBackedAuthFromStatus(t *testing.T) {
t.Setenv("PULSE_DEV", "true")
t.Setenv("NODE_ENV", "")
t.Setenv("PULSE_AUTH_USER", "admin")
t.Setenv("PULSE_AUTH_PASS", "hashed-password")
record := newTokenRecord(t, "reset-first-run-token-345.12345678", []string{config.ScopeSettingsWrite}, nil)
cfg := newTestConfigWithTokens(t, record)
cfg.AuthUser = "admin"
cfg.AuthPass = "hashed-password"
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
resetReq := httptest.NewRequest(http.MethodPost, "/api/security/dev/reset-first-run", nil)
resetReq.Header.Set("X-API-Token", "reset-first-run-token-345.12345678")
resetRec := httptest.NewRecorder()
router.Handler().ServeHTTP(resetRec, resetReq)
if resetRec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (%s)", resetRec.Code, resetRec.Body.String())
}
statusReq := httptest.NewRequest(http.MethodGet, "/api/security/status", nil)
statusRec := httptest.NewRecorder()
router.Handler().ServeHTTP(statusRec, statusReq)
if statusRec.Code != http.StatusOK {
t.Fatalf("security status expected 200, got %d (%s)", statusRec.Code, statusRec.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(statusRec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode security status: %v", err)
}
if got, _ := payload["hasAuthentication"].(bool); got {
t.Fatalf("expected hasAuthentication=false after reset, got %v", payload["hasAuthentication"])
}
if _, ok := payload["bootstrapTokenPath"]; ok {
t.Fatalf("unauthenticated status exposed bootstrapTokenPath: %v", payload["bootstrapTokenPath"])
}
if got := payload["detailLevel"]; got != securityStatusDetailPublic {
t.Fatalf("detailLevel = %v, want %q", got, securityStatusDetailPublic)
}
}
func TestResetFirstRunSecurityPreservesLiveCredentialsWhenTokenPersistenceFails(t *testing.T) {
t.Setenv("PULSE_DEV", "true")
t.Setenv("NODE_ENV", "")
t.Setenv("PULSE_AUTH_USER", "admin")
t.Setenv("PULSE_AUTH_PASS", "hashed-password")
t.Setenv("REQUIRE_AUTH", "true")
rawToken := "reset-first-run-token-persist-failure.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsWrite}, nil)
cfg := newTestConfigWithTokens(t, record)
cfg.AuthUser = "admin"
cfg.AuthPass = "hashed-password"
cfg.SortAPITokens()
authEnvContent := []byte("PULSE_AUTH_USER='admin'\nPULSE_AUTH_PASS='hashed-password'\nREQUIRE_AUTH='true'\n")
authEnvPath, err := writeAuthEnvFile(cfg.ConfigPath, cfg.DataPath, authEnvContent)
if err != nil {
t.Fatalf("write auth environment file: %v", err)
}
persistence := config.NewConfigPersistence(cfg.DataPath)
if err := persistence.SaveAPITokens(cfg.APITokens); err != nil {
t.Fatalf("persist initial API tokens: %v", err)
}
persistence.SetFileSystem(failingWriteFileSystem{})
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
router.persistence = persistence
sessionToken := "reset-first-run-session-persist-failure"
GetSessionStore().CreateSession(sessionToken, time.Hour, "browser", "127.0.0.1", "admin")
req := httptest.NewRequest(http.MethodPost, "/api/security/dev/reset-first-run", nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d (%s)", rec.Code, rec.Body.String())
}
if cfg.AuthUser != "admin" || cfg.AuthPass != "hashed-password" {
t.Fatalf("failed reset changed live password auth: user=%q pass=%q", cfg.AuthUser, cfg.AuthPass)
}
if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != record.ID {
t.Fatalf("failed reset changed live API tokens: %+v", cfg.APITokens)
}
if cfg.APIToken != record.Hash || !cfg.IsValidAPIToken(rawToken) {
t.Fatal("failed reset changed the live primary API token projection")
}
if !ValidateSession(sessionToken) {
t.Fatal("failed reset invalidated an existing user session")
}
if _, err := os.Stat(filepath.Join(cfg.DataPath, bootstrapTokenFilename)); !os.IsNotExist(err) {
t.Fatalf("failed reset left a staged bootstrap token behind: %v", err)
}
gotAuthEnv, err := os.ReadFile(authEnvPath)
if err != nil {
t.Fatalf("failed reset did not restore auth environment file: %v", err)
}
if string(gotAuthEnv) != string(authEnvContent) {
t.Fatalf("restored auth environment file = %q, want %q", gotAuthEnv, authEnvContent)
}
for key, want := range map[string]string{
"PULSE_AUTH_USER": "admin",
"PULSE_AUTH_PASS": "hashed-password",
"REQUIRE_AUTH": "true",
} {
if got := os.Getenv(key); got != want {
t.Fatalf("failed reset changed %s=%q, want %q", key, got, want)
}
}
persisted, err := config.NewConfigPersistence(cfg.DataPath).LoadAPITokens()
if err != nil {
t.Fatalf("load persisted API tokens: %v", err)
}
if len(persisted) != 1 || persisted[0].ID != record.ID {
t.Fatalf("failed reset changed restart-time API tokens: %+v", persisted)
}
}