Один бинарь на stdlib, ноль зависимостей: подкоманды serve, vapid, version; статика из embed с ETag и Cache-Control: no-cache; /healthz; заголовки безопасности ADR-021 на всех ответах, включая ошибки; лимит тела 32 КиБ. Путь в логе — в percent-форме: подделать строку журнала запросом нельзя. Ни IP, ни ника, ни query в логах нет. Клиент — знак «Скобы» и слово bare: без script-тегов, inline-стилей и внешних ресурсов. ADR-025: go:embed не выходит за каталог пакета, поэтому объявление статики живёт в корне модуля, а клиент остаётся в web/. ADR-026: 413 отдаётся кодом too_large, неподдерживаемый метод — 404; оба добавлены в перечень protocol.md. Сервер: пользователь bare, /opt/bare, /var/lib/bare, /etc/bare/env 0600, systemd-юнит, nginx с сертификатом certbot (плагин nginx, как у соседей). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
161 lines
5.1 KiB
Go
161 lines
5.1 KiB
Go
package api_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/xmatic-squad/bare/internal/api"
|
|
"github.com/xmatic-squad/bare/internal/web"
|
|
)
|
|
|
|
func handler(t *testing.T) http.Handler {
|
|
t.Helper()
|
|
static, err := web.New()
|
|
if err != nil {
|
|
t.Fatalf("web.New: %v", err)
|
|
}
|
|
return api.New(static, nil)
|
|
}
|
|
|
|
func TestHealthz(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
handler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
|
|
|
res := rec.Result()
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
t.Errorf("статус: получено %d, ожидалось 200", res.StatusCode)
|
|
}
|
|
body, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
t.Fatalf("чтение тела: %v", err)
|
|
}
|
|
if string(body) != "ok" {
|
|
t.Errorf("тело: получено %q, ожидалось \"ok\"", body)
|
|
}
|
|
|
|
want := map[string]string{
|
|
"Content-Security-Policy": "default-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
|
|
"Referrer-Policy": "no-referrer",
|
|
"X-Content-Type-Options": "nosniff",
|
|
}
|
|
for header, value := range want {
|
|
if got := res.Header.Get(header); got != value {
|
|
t.Errorf("%s: получено %q, ожидалось %q", header, got, value)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStaticNotModified(t *testing.T) {
|
|
h := handler(t)
|
|
|
|
first := httptest.NewRecorder()
|
|
h.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/app.css", nil))
|
|
if first.Code != http.StatusOK {
|
|
t.Fatalf("статус: получено %d, ожидалось 200", first.Code)
|
|
}
|
|
etag := first.Header().Get("ETag")
|
|
if etag == "" {
|
|
t.Fatal("нет ETag")
|
|
}
|
|
if got := first.Header().Get("Cache-Control"); got != "no-cache" {
|
|
t.Errorf("Cache-Control: получено %q, ожидалось \"no-cache\"", got)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/app.css", nil)
|
|
req.Header.Set("If-None-Match", etag)
|
|
second := httptest.NewRecorder()
|
|
h.ServeHTTP(second, req)
|
|
|
|
if second.Code != http.StatusNotModified {
|
|
t.Errorf("статус: получено %d, ожидалось 304", second.Code)
|
|
}
|
|
if second.Body.Len() != 0 {
|
|
t.Errorf("тело 304 не пустое: %q", second.Body.String())
|
|
}
|
|
if got := second.Header().Get("ETag"); got != etag {
|
|
t.Errorf("ETag на 304: получено %q, ожидалось %q", got, etag)
|
|
}
|
|
}
|
|
|
|
func TestBodyTooLarge(t *testing.T) {
|
|
body := strings.NewReader(strings.Repeat("a", api.MaxBody+1))
|
|
rec := httptest.NewRecorder()
|
|
handler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/nope", body))
|
|
|
|
if rec.Code != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("статус: получено %d, ожидалось 413", rec.Code)
|
|
}
|
|
var got map[string]string
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("разбор тела: %v", err)
|
|
}
|
|
if got["error"] != "too_large" {
|
|
t.Errorf("код ошибки: получено %q, ожидалось \"too_large\"", got["error"])
|
|
}
|
|
}
|
|
|
|
// Неподдерживаемый метод на известном пути — 404 not_found (ADR-026).
|
|
func TestStaticRejectsWrite(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
handler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/app.css", nil))
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("статус: получено %d, ожидалось 404", rec.Code)
|
|
}
|
|
var got map[string]string
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("разбор тела: %v", err)
|
|
}
|
|
if got["error"] != "not_found" {
|
|
t.Errorf("код ошибки: получено %q, ожидалось \"not_found\"", got["error"])
|
|
}
|
|
}
|
|
|
|
// Путь из запроса не должен уметь дописать строку в журнал.
|
|
func TestLogPathEscaped(t *testing.T) {
|
|
static, err := web.New()
|
|
if err != nil {
|
|
t.Fatalf("web.New: %v", err)
|
|
}
|
|
var log bytes.Buffer
|
|
h := api.New(static, &log)
|
|
|
|
target := "/x%0a2026-01-01T00:00:00Z%20GET%20/fake%20200%201ms"
|
|
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, target, nil))
|
|
|
|
line := log.String()
|
|
if n := strings.Count(line, "\n"); n != 1 {
|
|
t.Errorf("строк в логе: получено %d, ожидалась 1: %q", n, line)
|
|
}
|
|
if !strings.Contains(line, "%0a") {
|
|
t.Errorf("путь не в percent-форме: %q", line)
|
|
}
|
|
|
|
log.Reset()
|
|
long := "/" + strings.Repeat("z", 4096)
|
|
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, long, nil))
|
|
if len(log.String()) > 512 {
|
|
t.Errorf("длина строки лога: получено %d байт, ожидалось не больше 512", len(log.String()))
|
|
}
|
|
}
|
|
|
|
// SSE (docs/protocol.md, «События») флашит каждое событие: обёртка логгера
|
|
// не должна прятать Flush от http.ResponseController.
|
|
func TestFlushThroughMiddleware(t *testing.T) {
|
|
var flushErr error
|
|
h := api.New(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
flushErr = http.NewResponseController(w).Flush()
|
|
}), io.Discard)
|
|
|
|
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
|
|
if flushErr != nil {
|
|
t.Errorf("Flush: %v", flushErr)
|
|
}
|
|
}
|