- род метрики выводится сверкой минутного слоя с часовым: часовое значение сходится с суммой минутных — накопительная, со средним — мгновенная, иначе `unknown` и свёртка не предлагается вовсе. На живом архиве (123 доставки, 31 метрика) 7 накопительных, 9 мгновенных, противоречащих часов ноль - `GET /api/v1/metrics` под токеном чтения отдаёт единицы, слои с границами и род вместе с основанием измерения; род нигде не хранится — он функция витрины, а витрина функция журнала, устаревать в нём нечему - миграция 00009: покрывающий индекс, чтобы каталог отвечал по учётным колонкам, не разжимая содержимое объектов
325 lines
12 KiB
Go
325 lines
12 KiB
Go
package httpapi_test
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.vakhrushev.me/av/healthlog/internal/archive"
|
|
"git.vakhrushev.me/av/healthlog/internal/catalog"
|
|
"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 TestПриёмРаботаетНаТранспортеБезДедлайнов(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.StatusOK {
|
|
t.Fatalf("статус = %d, ожидался 200", rec.Code)
|
|
}
|
|
n, err := st.CountDeliveries(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("CountDeliveries: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("доставок в учёте %d, ожидалась 1", n)
|
|
}
|
|
}
|
|
|
|
func newAPI(t *testing.T, writeTokens []string) (http.Handler, *store.Store) {
|
|
t.Helper()
|
|
|
|
h, st, _ := newAPITokens(t, writeTokens, nil)
|
|
return h, st
|
|
}
|
|
|
|
// newAPITokens собирает роутер с обоими контурами. Отдельно от newAPI, чтобы не
|
|
// переписывать два десятка вызовов ради одного параметра.
|
|
func newAPITokens(t *testing.T, writeTokens, readTokens []string) (http.Handler, *store.Store, *catalog.Service) {
|
|
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)
|
|
cat := catalog.New(st, log)
|
|
h := httpapi.New(httpapi.Options{
|
|
Ingest: ingest.New(arch, st, nil, log),
|
|
Catalog: cat,
|
|
Log: log,
|
|
WriteTokens: writeTokens,
|
|
ReadTokens: readTokens,
|
|
MaxBodyMB: 1,
|
|
// Бюджет задаётся всегда: httptest.ResponseRecorder дедлайнов не умеет,
|
|
// и это ровно тот транспорт, на котором приём обязан продолжать работать.
|
|
IngestWriteBudget: time.Minute,
|
|
})
|
|
return h, st, cat
|
|
}
|