Files

433 lines
14 KiB
Go
Raw Permalink Normal View History

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