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) } }