package httpapi import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "github.com/alexedwards/scs/v2" "github.com/descrybe/descrybe-v2/apps/api/internal/auth" "github.com/descrybe/descrybe-v2/apps/api/internal/billing" "github.com/descrybe/descrybe-v2/apps/api/internal/config" "github.com/descrybe/descrybe-v2/apps/api/internal/processing" "github.com/google/uuid" ) func TestHandleAdminDiagnosticsNilPool(t *testing.T) { t.Parallel() dir := t.TempDir() s := &Server{Config: config.Config{UploadDir: dir, AppEnv: "test"}} req := httptest.NewRequest(http.MethodGet, "/api/admin/diagnostics", nil) rec := httptest.NewRecorder() s.handleAdminDiagnostics(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status=%d want 200 body=%s", rec.Code, rec.Body.String()) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("json: %v", err) } if body["status"] != "fail" { t.Fatalf("overall status=%v want fail", body["status"]) } if _, ok := body["runtime_metrics"].(map[string]any); !ok { t.Fatalf("expected runtime_metrics object, got %#v", body["runtime_metrics"]) } cutover, ok := body["cutover"].(map[string]any) if !ok { t.Fatalf("expected cutover object, got %#v", body["cutover"]) } if _, ok := cutover["goose"].(map[string]any); !ok { t.Fatalf("expected cutover.goose object, got %#v", cutover["goose"]) } if _, ok := cutover["worker"].(map[string]any); !ok { t.Fatalf("expected cutover.worker object, got %#v", cutover["worker"]) } if _, hasPlans := cutover["companies_without_plan"]; hasPlans { t.Fatal("nil pool must omit companies_without_plan (query skipped)") } links, _ := body["links"].(map[string]any) if links["metrics"] != "/metrics" { t.Fatalf("links.metrics=%v want /metrics", links["metrics"]) } if links["readiness"] != "/api/admin/readiness" { t.Fatalf("links.readiness=%v want /api/admin/readiness", links["readiness"]) } cfg, _ := body["config"].(map[string]any) for _, secretKey := range []string{ "database_url", "token_signing_secret", "openai_api_key", "smtp_password", "stripe_secret_key", "pinecone_api_key", "password", } { if _, ok := cfg[secretKey]; ok { t.Fatalf("config must not expose %q", secretKey) } } raw := strings.ToLower(rec.Body.String()) for _, leak := range []string{"sk_live", "password=", "postgres://", "bearer "} { if strings.Contains(raw, leak) { t.Fatalf("response leaked secret-like substring %q", leak) } } } func TestHandleAdminDiagnosticsInvalidStatus(t *testing.T) { t.Parallel() s := &Server{Config: config.Config{UploadDir: t.TempDir()}} req := httptest.NewRequest(http.MethodGet, "/api/admin/diagnostics?status=bogus", nil) rec := httptest.NewRecorder() s.handleAdminDiagnostics(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status=%d want 400 body=%s", rec.Code, rec.Body.String()) } } // TestDiagConfigSanityNeverEmitsSecretValues seeds Config with realistic secrets and // asserts the diagnostics payload only exposes presence flags — never values/DSNs. func TestDiagConfigSanityNeverEmitsSecretValues(t *testing.T) { t.Parallel() s := &Server{ Config: config.Config{ AppEnv: "production", UploadDir: t.TempDir(), DatabaseURL: "postgres://descrybe:s3cret@localhost:5433/descrybe", TokenSigningSecret: "super-secret-token-signing-key", OpenAIAPIKey: "sk-abcdefghijklmnopqrstuvwxyz0123456789", PineconeAPIKey: "pcsk_live_example_key_value", StripeSecretKey: "sk_live_51ExampleSecretValue", StripeWebhookSecret: "whsec_example_webhook_secret", SMTPPassword: "smtp-password-value", SMTPHost: "smtp.secret-host.example", SMTPEnabled: true, EmailDryRun: true, CredentialsEncryptionKey: "creds-encryption-key-32bytes!!", ResendAPIKey: "re_example_resend_key", EPRELAPIKey: "eprel-secret-key", WebOrigin: "https://app.example.com", PublicAPIURL: "https://api.example.com", }, } stripeCfg := billing.StripeConfig{ SecretKey: s.Config.StripeSecretKey, WebhookSecret: s.Config.StripeWebhookSecret, ForceMock: s.Config.StripeMock, } cfg := s.diagConfigSanity(stripeCfg) raw, err := json.Marshal(cfg) if err != nil { t.Fatal(err) } body := strings.ToLower(string(raw)) for _, leak := range []string{ "postgres://", "s3cret", "super-secret-token", "sk-abcdefghijklmnopqrstuvwxyz", "sk_live_51", "whsec_", "smtp-password", "creds-encryption", "re_example", "eprel-secret", "database_url", "openai_api_key", "smtp_password", "smtp.secret-host", } { if strings.Contains(body, strings.ToLower(leak)) { t.Fatalf("config sanity leaked %q in %s", leak, body) } } if cfg["openai_key_set"] != true || cfg["stripe_secret_set"] != true || cfg["stripe_webhook_secret_set"] != true { t.Fatalf("expected presence flags true, got openai=%v stripe=%v webhook=%v", cfg["openai_key_set"], cfg["stripe_secret_set"], cfg["stripe_webhook_secret_set"]) } if cfg["smtp_enabled"] != true || cfg["email_dry_run"] != true || cfg["smtp_host_set"] != true { t.Fatalf("expected mail presence flags true, got enabled=%v dry_run=%v host_set=%v", cfg["smtp_enabled"], cfg["email_dry_run"], cfg["smtp_host_set"]) } if cfg["stripe_mock_rejected_in_prod"] != true { t.Fatalf("expected stripe_mock_rejected_in_prod=true, got %v", cfg["stripe_mock_rejected_in_prod"]) } if _, ok := cfg["database_url"]; ok { t.Fatal("database_url must not appear in config sanity") } } func TestDiagMailConfigStatusPresenceOnly(t *testing.T) { t.Parallel() t.Run("ready dry-run with host", func(t *testing.T) { t.Parallel() s := &Server{ Config: config.Config{ SMTPEnabled: true, SMTPHost: "smtp.secret-host.example", SMTPPassword: "smtp-password-value", EmailDryRun: true, }, } check := s.diagMail() if check["status"] != "ok" || check["enabled"] != true || check["dry_run"] != true || check["host_set"] != true { t.Fatalf("check=%v", check) } raw, err := json.Marshal(check) if err != nil { t.Fatal(err) } body := strings.ToLower(string(raw)) for _, leak := range []string{"smtp.secret-host", "smtp-password", "smtp_password"} { if strings.Contains(body, leak) { t.Fatalf("mail check leaked %q in %s", leak, body) } } }) t.Run("enabled without host warns", func(t *testing.T) { t.Parallel() s := &Server{Config: config.Config{SMTPEnabled: true, EmailDryRun: false}} check := s.diagMail() if check["status"] != "warn" || check["host_set"] != false || check["dry_run"] != false { t.Fatalf("check=%v", check) } }) t.Run("disabled noop", func(t *testing.T) { t.Parallel() s := &Server{Config: config.Config{EmailDryRun: true}} check := s.diagMail() if check["status"] != "ok" || check["enabled"] != false || check["dry_run"] != true { t.Fatalf("check=%v", check) } }) } func TestDiagCutoverReadinessNilPool(t *testing.T) { t.Parallel() s := &Server{Config: config.Config{AppEnv: "test"}} cutover := s.diagCutoverReadiness(context.Background()) if cutover["status"] != "warn" { t.Fatalf("status=%v want warn", cutover["status"]) } goose, _ := cutover["goose"].(map[string]any) if goose["status"] != "skip" { t.Fatalf("goose.status=%v want skip", goose["status"]) } if _, ok := goose["version_max"]; ok { t.Fatal("nil pool must not invent goose version_max") } worker, _ := cutover["worker"].(map[string]any) if worker["status"] != "unavailable" { t.Fatalf("worker.status=%v want unavailable", worker["status"]) } if _, ok := worker["last_seen_age_s"]; ok { t.Fatal("nil pool must omit last_seen_age_s") } if _, ok := cutover["companies_without_plan"]; ok { t.Fatal("nil pool must omit companies_without_plan") } raw, err := json.Marshal(cutover) if err != nil { t.Fatal(err) } body := strings.ToLower(string(raw)) for _, leak := range []string{"postgres://", "sk_live", "password=", "smtp_password", "bearer "} { if strings.Contains(body, leak) { t.Fatalf("cutover leaked %q in %s", leak, body) } } } func TestDiagMigrationInventoryNilPool(t *testing.T) { t.Parallel() s := &Server{Config: config.Config{AppEnv: "test"}} inv := s.diagMigrationInventory(context.Background()) if inv["status"] != "skip" { t.Fatalf("status=%v want skip", inv["status"]) } if inv["jobs_domain_ran"] != false { t.Fatalf("jobs_domain_ran=%v want false", inv["jobs_domain_ran"]) } for _, key := range []string{ "files_total", "files_metadata_only", "processing_jobs_total", "processing_jobs_migrated", "tasks_total", } { n, ok := inv[key].(int64) if !ok || n != 0 { t.Fatalf("%s=%v want int64(0)", key, inv[key]) } } notes, ok := inv["notes"].([]string) if !ok || len(notes) == 0 { t.Fatalf("notes=%v want non-empty []string", inv["notes"]) } raw, err := json.Marshal(inv) if err != nil { t.Fatal(err) } body := strings.ToLower(string(raw)) for _, leak := range []string{"postgres://", "sk_live", "password=", "/var/", "c:\\"} { if strings.Contains(body, leak) { t.Fatalf("migration inventory leaked %q in %s", leak, body) } } } func TestHandleAdminDiagnosticsIncludesMigrationInventory(t *testing.T) { t.Parallel() dir := t.TempDir() s := &Server{Config: config.Config{UploadDir: dir, AppEnv: "test"}} req := httptest.NewRequest(http.MethodGet, "/api/admin/diagnostics", nil) rec := httptest.NewRecorder() s.handleAdminDiagnostics(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status=%d want 200 body=%s", rec.Code, rec.Body.String()) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("json: %v", err) } inv, ok := body["migration_inventory"].(map[string]any) if !ok { t.Fatalf("migration_inventory missing: %#v", body["migration_inventory"]) } if inv["status"] != "skip" { t.Fatalf("migration_inventory.status=%v want skip (nil pool)", inv["status"]) } } func TestDiagStripeReadiness(t *testing.T) { t.Parallel() t.Run("prod mock fails", func(t *testing.T) { t.Parallel() check, ok := diagStripeReadiness(true, billing.StripeConfig{ SecretKey: "sk_live_x", WebhookSecret: "whsec_x", ForceMock: true, }) if ok || check["status"] != "fail" || check["mock_rejected_in_prod"] != false { t.Fatalf("check=%v ok=%v", check, ok) } raw, _ := json.Marshal(check) if strings.Contains(strings.ToLower(string(raw)), "sk_live") || strings.Contains(string(raw), "whsec_") { t.Fatalf("leaked secret material: %s", raw) } }) t.Run("prod keys present", func(t *testing.T) { t.Parallel() check, ok := diagStripeReadiness(true, billing.StripeConfig{ SecretKey: "sk_live_x", WebhookSecret: "whsec_x", }) if !ok || check["status"] != "ok" || check["secret_key_set"] != true || check["webhook_secret_set"] != true { t.Fatalf("check=%v ok=%v", check, ok) } if check["mock_rejected_in_prod"] != true { t.Fatalf("mock_rejected_in_prod=%v", check["mock_rejected_in_prod"]) } }) t.Run("prod missing webhook warns", func(t *testing.T) { t.Parallel() check, ok := diagStripeReadiness(true, billing.StripeConfig{SecretKey: "sk_live_x"}) if ok || check["status"] != "warn" || check["webhook_secret_set"] != false { t.Fatalf("check=%v ok=%v", check, ok) } }) t.Run("dev mock ok", func(t *testing.T) { t.Parallel() check, ok := diagStripeReadiness(false, billing.StripeConfig{ForceMock: true}) if !ok || check["status"] != "ok" || check["mock"] != true { t.Fatalf("check=%v ok=%v", check, ok) } }) } func TestDiagJobErrorUsesTruncateError(t *testing.T) { t.Parallel() // Contract lock: job error strings must go through TruncateError before JSON. secretish := "provider failed authorization: Bearer sk-abcdefghijklmnopqrstuvwxyz012345" redacted := processing.TruncateError(errors.New(secretish)) if strings.Contains(strings.ToLower(redacted), "sk-abcdef") || strings.Contains(strings.ToLower(redacted), "bearer sk-") { t.Fatalf("TruncateError did not redact: %q", redacted) } if redacted == "" { t.Fatal("expected non-empty redacted message") } } func TestHandleAdminDiagnosticsStorageWritable(t *testing.T) { t.Parallel() dir := t.TempDir() s := &Server{Config: config.Config{UploadDir: dir}} check, ok := s.diagStorage() if !ok { t.Fatalf("expected writable storage check=%v", check) } if check["status"] != "ok" { t.Fatalf("status=%v", check["status"]) } // Absolute path must not appear in detail. if detail, _ := check["detail"].(string); filepath.IsAbs(detail) { t.Fatalf("detail must not be absolute path: %q", detail) } entries, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } for _, e := range entries { if e.Name() == ".diag_write_probe" { t.Fatal("probe file should be removed") } } } func TestRouterAdminDiagnosticsMounted(t *testing.T) { t.Parallel() sm := scs.New() sm.Cookie.Name = "descrybe_session" uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") s := &Server{ Config: config.Config{ CSRFCookieName: "descrybe_csrf", WebOrigin: "http://localhost:5173", UploadDir: t.TempDir(), }, Sessions: sm, Auth: &auth.Service{}, testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) { return got == uid, nil }, } var token string seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sm.Put(r.Context(), auth.SessionUserIDKey, uid.String()) w.WriteHeader(http.StatusNoContent) })) seedRec := httptest.NewRecorder() seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil)) for _, c := range seedRec.Result().Cookies() { if c.Name == sm.Cookie.Name { token = c.Value } } if token == "" { t.Fatal("expected session cookie from seed request") } h := s.Router() unauth := httptest.NewRecorder() h.ServeHTTP(unauth, httptest.NewRequest(http.MethodGet, "/api/admin/diagnostics", nil)) if unauth.Code != http.StatusUnauthorized { t.Fatalf("unauth status=%d want 401 body=%s", unauth.Code, unauth.Body.String()) } mounted := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/admin/diagnostics", nil) req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token}) h.ServeHTTP(mounted, req) if mounted.Code == http.StatusNotFound { t.Fatalf("diagnostics not mounted: status=404 body=%s", mounted.Body.String()) } if mounted.Code != http.StatusOK { t.Fatalf("mounted status=%d want 200 body=%s", mounted.Code, mounted.Body.String()) } }