package httpapi_test import ( "bytes" "compress/gzip" "encoding/json" "log/slog" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "git.vakhrushev.me/av/healthlog/internal/archive" "git.vakhrushev.me/av/healthlog/internal/fold" "git.vakhrushev.me/av/healthlog/internal/httpapi" "git.vakhrushev.me/av/healthlog/internal/ingest" "git.vakhrushev.me/av/healthlog/internal/store" ) const samplePayload = `{"data":{"metrics":[{"name":"step_count","units":"count","data":[{"qty":8,"date":"2026-07-31 12:00:00 +0300"}]}],"workouts":[]}}` func TestIngestAcceptsPayload(t *testing.T) { h, st := newAPI(t, nil) req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", strings.NewReader(samplePayload)) req.Header.Set("Content-Type", "application/json") req.Header.Set("automation-name", "healthlog") req.Header.Set("automation-aggregation", "None") req.Header.Set("session-id", "sess-1") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("статус = %d, тело = %s", rec.Code, rec.Body.String()) } var resp struct { DeliveryID string `json:"delivery_id"` Bytes int64 `json:"bytes"` SHA256 string `json:"sha256"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("разбор ответа: %v", err) } if resp.DeliveryID == "" || resp.SHA256 == "" { t.Errorf("ответ без идентификатора или хеша: %+v", resp) } if resp.Bytes != int64(len(samplePayload)) { t.Errorf("bytes = %d, ожидалось %d", resp.Bytes, len(samplePayload)) } if n, err := st.CountDeliveries(t.Context()); err != nil || n != 1 { t.Errorf("доставок = %d (err=%v), ожидалась 1", n, err) } } // Заголовки сохраняются целиком: документация HAE неполна, и незадокументированные // поля уже приносили самые полезные находки. func TestIngestStoresAllHeaders(t *testing.T) { h, st := newAPI(t, nil) req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", strings.NewReader(samplePayload)) req.Header.Set("automation-id", "BC99C8A3-8BE7-4519-B545-F3ED6212008E") req.Header.Set("automation-aggregation", "Minutes") req.Header.Set("x-unknown-future-header", "нечто новое") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("статус = %d", rec.Code) } d, err := st.LastDelivery(t.Context()) if err != nil { t.Fatalf("LastDelivery: %v", err) } var got map[string][]string if err := json.Unmarshal([]byte(d.Headers), &got); err != nil { t.Fatalf("разбор headers: %v (%q)", err, d.Headers) } for name, want := range map[string]string{ "Automation-Id": "BC99C8A3-8BE7-4519-B545-F3ED6212008E", "Automation-Aggregation": "Minutes", "X-Unknown-Future-Header": "нечто новое", } { if len(got[name]) != 1 || got[name][0] != want { t.Errorf("заголовок %s = %v, ожидалось [%q]", name, got[name], want) } } if got["Host"] == nil { t.Error("Host не сохранён") } } // Секреты в БД не попадают — ни по имени заголовка, ни по совпадению значения // с настроенным токеном (токен можно положить в заголовок с любым именем). func TestIngestRedactsSecretHeaders(t *testing.T) { const token = "очень-секретный-токен" h, st := newAPI(t, []string{token}) req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", strings.NewReader(samplePayload)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("X-Api-Key", "ключ-метабазы") req.Header.Set("X-Custom-Auth", token) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("статус = %d, тело %s", rec.Code, rec.Body.String()) } d, err := st.LastDelivery(t.Context()) if err != nil { t.Fatalf("LastDelivery: %v", err) } if strings.Contains(d.Headers, token) { t.Errorf("токен утёк в headers: %s", d.Headers) } if strings.Contains(d.Headers, "ключ-метабазы") { t.Errorf("значение X-Api-Key утекло: %s", d.Headers) } } func TestIngestAcceptsGzip(t *testing.T) { h, st := newAPI(t, nil) var buf bytes.Buffer gz := gzip.NewWriter(&buf) if _, err := gz.Write([]byte(samplePayload)); err != nil { t.Fatalf("подготовка gzip: %v", err) } if err := gz.Close(); err != nil { t.Fatalf("подготовка gzip: %v", err) } req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", &buf) req.Header.Set("Content-Encoding", "gzip") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("статус = %d, тело = %s", rec.Code, rec.Body.String()) } if n, err := st.CountDeliveries(t.Context()); err != nil || n != 1 { t.Errorf("доставок = %d (err=%v), ожидалась 1", n, err) } } // Битое тело — отказ доставки, о нём отправителю сообщаем. func TestIngestRejectsMalformed(t *testing.T) { h, st := newAPI(t, nil) req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", strings.NewReader(`{"data":{"metrics":[`)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("статус = %d, ожидался 400", rec.Code) } if n, _ := st.CountDeliveries(t.Context()); n != 0 { //nolint:errcheck // проверяем только счётчик t.Errorf("доставок = %d, ожидалось 0", n) } } func TestIngestRejectsOversizedBody(t *testing.T) { h, _ := newAPI(t, nil) // Лимит в тесте — 1 МиБ (см. newAPI), шлём заведомо больше. big := `{"data":{"note":"` + strings.Repeat("x", 2<<20) + `"}}` req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", strings.NewReader(big)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusRequestEntityTooLarge { t.Fatalf("статус = %d, ожидался 413", rec.Code) } } // Граница обязана стоять на РАСПАКОВАННОМ теле, а не только на сжатом. // // Измерено на прежней версии: 400 КиБ сжатого тела разворачивались в 400 МиБ, // принимались со статусом 200 и выедали гигабайт. При потолке степени сжатия // gzip около 1030:1 и штатных 64 МиБ речь о десятках гигабайт на запрос, а // параллельные складываются. Цена отказа здесь наивысшая в проекте: приём — // единственное место, где поток существует, и доставка, не попавшая в архив, // не попадает в журнал вовсе. func TestIngestRejectsGzipBomb(t *testing.T) { h, st := newAPI(t, nil) // Лимит в тесте — 1 МиБ (см. newAPI). Тело валидное, просто очень длинное // и сжимается почти нацело. payload := `{"data":{"metrics":[]},"pad":"` + strings.Repeat(" ", 8<<20) + `"}` var buf bytes.Buffer gz := gzip.NewWriter(&buf) if _, err := gz.Write([]byte(payload)); err != nil { t.Fatalf("подготовка gzip: %v", err) } if err := gz.Close(); err != nil { t.Fatalf("подготовка gzip: %v", err) } if buf.Len() >= 1<<20 { t.Fatalf("сжатое тело %d байт — уже больше лимита, тест проверяет не то", buf.Len()) } req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", &buf) req.Header.Set("Content-Encoding", "gzip") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusRequestEntityTooLarge { t.Fatalf("статус = %d, ожидался 413: распакованное тело больше лимита", rec.Code) } if n, _ := st.CountDeliveries(t.Context()); n != 0 { //nolint:errcheck // проверяем только счётчик t.Errorf("доставок = %d, ожидалось 0", n) } } func TestIngestChecksTokenWhenConfigured(t *testing.T) { h, _ := newAPI(t, []string{"верный-токен"}) cases := map[string]struct { header string want int }{ "без токена": {"", http.StatusUnauthorized}, "чужой токен": {"Bearer чужой", http.StatusUnauthorized}, "без префикса": {"верный-токен", http.StatusUnauthorized}, "верный токен": {"Bearer верный-токен", http.StatusOK}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", strings.NewReader(samplePayload)) if tc.header != "" { req.Header.Set("Authorization", tc.header) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != tc.want { t.Errorf("статус = %d, ожидался %d", rec.Code, tc.want) } }) } } func TestHealthz(t *testing.T) { h, _ := newAPI(t, nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) if rec.Code != http.StatusOK { t.Fatalf("статус = %d", rec.Code) } } func newAPI(t *testing.T, writeTokens []string) (http.Handler, *store.Store) { t.Helper() dir := t.TempDir() st, err := store.Open(filepath.Join(dir, "healthlog.db")) if err != nil { t.Fatalf("store.Open: %v", err) } t.Cleanup(func() { _ = st.Close() }) arch, err := archive.New(filepath.Join(dir, "raw")) if err != nil { t.Fatalf("archive.New: %v", err) } log := slog.New(slog.DiscardHandler) h := httpapi.New(httpapi.Options{ Ingest: ingest.New(arch, st, fold.New(arch, st, 0, log), log), Log: log, WriteTokens: writeTokens, MaxBodyMB: 1, }) return h, st }