package api import ( "context" "errors" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "dana/internal/storage" ) func ipRequest(remote string, headers map[string]string) *http.Request { r := httptest.NewRequest(http.MethodGet, "/", nil) r.RemoteAddr = remote for k, v := range headers { r.Header.Set(k, v) } return r } func TestClientIPIgnoresSpoofedHeaders(t *testing.T) { saved := trustedProxies t.Cleanup(func() { trustedProxies = saved }) cases := []struct { name string remote string headers map[string]string want string }{ {"посторонний подставил X-Real-IP", "203.0.113.7:5555", map[string]string{"X-Real-IP": "1.1.1.1"}, "203.0.113.7"}, {"посторонний подставил X-Forwarded-For", "203.0.113.7:5555", map[string]string{"X-Forwarded-For": "1.1.1.1"}, "203.0.113.7"}, {"доверенный прокси передал X-Real-IP", "127.0.0.1:4000", map[string]string{"X-Real-IP": "198.51.100.9"}, "198.51.100.9"}, {"доверенный прокси: XFF справа налево", "127.0.0.1:4000", map[string]string{"X-Forwarded-For": "6.6.6.6, 198.51.100.9, 127.0.0.1"}, "198.51.100.9"}, {"доверенный прокси без заголовков", "127.0.0.1:4000", nil, "127.0.0.1"}, } for _, c := range cases { if got := clientIP(ipRequest(c.remote, c.headers)); got != c.want { t.Errorf("%s: %q, ждали %q", c.name, got, c.want) } } // Caddy пропускает X-Real-IP посетителя как есть, а X-Forwarded-For формирует сам — верим XFF spoof := map[string]string{"X-Real-IP": "6.6.6.6", "X-Forwarded-For": "198.51.100.9"} if got := clientIP(ipRequest("172.17.0.1:5000", spoof)); got != "198.51.100.9" { t.Errorf("подделанный X-Real-IP через прокси: %q, ждали адрес из X-Forwarded-For", got) } // TRUSTED_PROXIES заменяет список: адрес вне списка перестаёт быть доверенным if err := SetTrustedProxies("10.0.0.1"); err != nil { t.Fatal(err) } if got := clientIP(ipRequest("172.17.0.1:5000", spoof)); got != "172.17.0.1" { t.Errorf("Docker-адрес вне TRUSTED_PROXIES: %q", got) } if got := clientIP(ipRequest("10.0.0.1:5000", spoof)); got != "198.51.100.9" { t.Errorf("адрес из TRUSTED_PROXIES: %q", got) } if err := SetTrustedProxies("не-адрес"); err == nil { t.Error("ошибочный TRUSTED_PROXIES должен давать ошибку при запуске") } } func TestRedactURLHidesSecrets(t *testing.T) { req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://127.0.0.1:1/sys/wait_call.php?login=clinic&psw=TOPSECRET", nil) _, err := (&http.Client{Timeout: 2 * time.Second}).Do(req) if err == nil { t.Skip("порт 1 неожиданно ответил") } if !strings.Contains(err.Error(), "TOPSECRET") { t.Fatalf("ожидали, что исходная ошибка содержит адрес: %v", err) } if got := redactURL(err).Error(); strings.Contains(got, "TOPSECRET") || strings.Contains(got, "psw=") { t.Errorf("пароль остался в ошибке: %q", got) } plain := errors.New("обычная ошибка") if redactURL(plain) != plain { t.Error("ошибку без адреса трогать не нужно") } } func TestMediaHasNoDirectoryListing(t *testing.T) { dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, "uploads", "2026"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "uploads", "2026", "doc.pdf"), []byte("%PDF-1.4"), 0o644); err != nil { t.Fatal(err) } h := mediaHandler(dir) for path, want := range map[string]int{ "/media/": http.StatusNotFound, "/media/uploads/": http.StatusNotFound, "/media/uploads/2026": http.StatusNotFound, "/media/uploads/2026/doc.pdf": http.StatusOK, } { rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) if rec.Code != want { t.Errorf("%s: код %d, ждали %d", path, rec.Code, want) } if strings.Contains(rec.Body.String(), "doc.pdf") && path != "/media/uploads/2026/doc.pdf" { t.Errorf("%s: в ответе виден список файлов", path) } } } func TestLeadOnlyDraftExplainsBookingLimit(t *testing.T) { it := storage.ConfirmItem{ Start: time.Date(2026, 10, 12, 15, 30, 0, 0, moscow), Patient: "Анна", Phone: "+79280000000", DoctorName: "Петрова", Error: errBookingLimit.Error() + ": за час 10 онлайн-записей при лимите 10", } d := leadOnlyDraft(it) if !strings.Contains(d.Comments, "защита от ботов") || strings.Contains(d.Comments, "1С была недоступна") { t.Errorf("при лимите лид должен объяснять защиту, а не сбой 1С: %q", d.Comments) } it.Error = "1С недоступна (HTTP 502): host=10.240.70.1" d = leadOnlyDraft(it) if !strings.Contains(d.Comments, "1С была недоступна") || strings.Contains(d.Comments, "10.240.70.1") { t.Errorf("при сбое 1С — общая причина без внутренних адресов: %q", d.Comments) } }