mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 10:35:51 +00:00
9e8cdde75c
Commit restart-time token revocation before clearing live authentication or sessions. Restore auth environment files and remove the staged bootstrap credential when token persistence fails, preserving a usable retry path. Contract-Neutral: hardens development reset failure handling without changing successful API payloads or extension contracts
215 lines
7.9 KiB
Go
215 lines
7.9 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"
|
|
)
|
|
|
|
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")
|
|
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())
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|