Этап 1: аккаунты — argon2id, сессии, ключевой блоб, вход и регистрация
Сервер: миграция 001 со всей схемой storage.md, store на modernc.org/sqlite (WAL, foreign_keys, один писатель), фоновая чистка раз в час, argon2id с параметрами ADR-021 и сверкой constant-time, сессии по SHA-256 токена, cookie bare_session, глобальная проверка Origin, девять эндпоинтов аккаунта. Ник в журнал не попадает: для /api/ пишется шаблон маршрута. Клиент: crypto.js по crypto.md построчно — мастер из пароля, два независимых ключа из мастера, ключевой блоб с ником в AAD, отпечаток от сырой точки; db.js со всеми хранилищами версии 1; экран входа и регистрации, настройки со сменой пароля, выходом и удалением аккаунта. Пароль не покидает клиент: проверено на боевом сервере — ни пароля, ни priv.d ни в одном теле запроса, вход на втором устройстве даёт тот же отпечаток. ADR-027: код internal для 500, причина только в журнале. ADR-028: тексты состояний клиента сведены в ui.md. ADR-029: вход под другим ником стирает историю только после подтверждения. ADR-030: верхняя граница итераций KDF, проверка границ на обеих сторонах. ADR-031: служебный выход перед повторным входом не заканчивает сеанс. ADR-032: каталог состояния 0700, файлы базы 0600. Прямые зависимости: modernc.org/sqlite, golang.org/x/crypto. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
This commit is contained in:
+174
-62
@@ -6,37 +6,140 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/api"
|
||||
"github.com/xmatic-squad/bare/internal/config"
|
||||
"github.com/xmatic-squad/bare/internal/store"
|
||||
"github.com/xmatic-squad/bare/internal/web"
|
||||
)
|
||||
|
||||
func handler(t *testing.T) http.Handler {
|
||||
const origin = "https://bare.test"
|
||||
|
||||
// env — сервер на временной базе плюс журнал, в который он пишет.
|
||||
type env struct {
|
||||
t *testing.T
|
||||
h http.Handler
|
||||
st *store.Store
|
||||
log *bytes.Buffer
|
||||
}
|
||||
|
||||
func newEnv(t *testing.T) *env { return invited(t, "") }
|
||||
|
||||
// invited — сервер на временной базе; непустой code включает инвайты.
|
||||
func invited(t *testing.T, code string) *env {
|
||||
t.Helper()
|
||||
static, err := web.New()
|
||||
if err != nil {
|
||||
t.Fatalf("web.New: %v", err)
|
||||
}
|
||||
return api.New(static, nil)
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "bare.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
|
||||
cfg := &config.Config{
|
||||
Addr: "127.0.0.1:0",
|
||||
DB: "bare.db",
|
||||
Origin: origin,
|
||||
VAPIDPublic: "vapid",
|
||||
InviteCode: code,
|
||||
}
|
||||
e := &env{t: t, st: st, log: &bytes.Buffer{}}
|
||||
e.h = api.New(cfg, st, static, e.log)
|
||||
return e
|
||||
}
|
||||
|
||||
// do отправляет запрос. Origin для методов кроме GET и HEAD ставится сам —
|
||||
// без него любой такой запрос получил бы 403 (ADR-021).
|
||||
func (e *env) do(method, target string, body any, opts ...func(*http.Request)) *httptest.ResponseRecorder {
|
||||
e.t.Helper()
|
||||
var reader io.Reader
|
||||
switch v := body.(type) {
|
||||
case nil:
|
||||
case string:
|
||||
reader = strings.NewReader(v)
|
||||
default:
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
e.t.Fatalf("сборка тела: %v", err)
|
||||
}
|
||||
reader = bytes.NewReader(raw)
|
||||
}
|
||||
r := httptest.NewRequest(method, target, reader)
|
||||
if method != http.MethodGet && method != http.MethodHead {
|
||||
r.Header.Set("Origin", origin)
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(r)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
e.h.ServeHTTP(rec, r)
|
||||
return rec
|
||||
}
|
||||
|
||||
func with(c *http.Cookie) func(*http.Request) {
|
||||
return func(r *http.Request) {
|
||||
if c != nil {
|
||||
r.AddCookie(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func withOrigin(value string) func(*http.Request) {
|
||||
return func(r *http.Request) {
|
||||
if value == "" {
|
||||
r.Header.Del("Origin")
|
||||
return
|
||||
}
|
||||
r.Header.Set("Origin", value)
|
||||
}
|
||||
}
|
||||
|
||||
// code достаёт код ошибки из тела ответа.
|
||||
func code(t *testing.T, rec *httptest.ResponseRecorder) string {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("разбор тела %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
return body.Error
|
||||
}
|
||||
|
||||
// expect проверяет статус и код ошибки; код "" — ответ без ошибки.
|
||||
func expect(t *testing.T, rec *httptest.ResponseRecorder, status int, errCode string) {
|
||||
t.Helper()
|
||||
if rec.Code != status {
|
||||
t.Fatalf("статус: получено %d (%s), ожидалось %d", rec.Code, rec.Body.String(), status)
|
||||
}
|
||||
if errCode != "" {
|
||||
if got := code(t, rec); got != errCode {
|
||||
t.Errorf("код ошибки: получено %q, ожидалось %q", got, errCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func decodeBody(t *testing.T, rec *httptest.ResponseRecorder, v any) {
|
||||
t.Helper()
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil {
|
||||
t.Fatalf("разбор тела %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
handler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
e := newEnv(t)
|
||||
rec := e.do(http.MethodGet, "/healthz", nil)
|
||||
|
||||
res := rec.Result()
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("статус: получено %d, ожидалось 200", res.StatusCode)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("статус: получено %d, ожидалось 200", rec.Code)
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение тела: %v", err)
|
||||
}
|
||||
if string(body) != "ok" {
|
||||
t.Errorf("тело: получено %q, ожидалось \"ok\"", body)
|
||||
if rec.Body.String() != "ok" {
|
||||
t.Errorf("тело: получено %q, ожидалось \"ok\"", rec.Body.String())
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
@@ -45,17 +148,16 @@ func TestHealthz(t *testing.T) {
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}
|
||||
for header, value := range want {
|
||||
if got := res.Header.Get(header); got != value {
|
||||
if got := rec.Header().Get(header); got != value {
|
||||
t.Errorf("%s: получено %q, ожидалось %q", header, got, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticNotModified(t *testing.T) {
|
||||
h := handler(t)
|
||||
e := newEnv(t)
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
h.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/app.css", nil))
|
||||
first := e.do(http.MethodGet, "/app.css", nil)
|
||||
if first.Code != http.StatusOK {
|
||||
t.Fatalf("статус: получено %d, ожидалось 200", first.Code)
|
||||
}
|
||||
@@ -67,11 +169,9 @@ func TestStaticNotModified(t *testing.T) {
|
||||
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)
|
||||
|
||||
second := e.do(http.MethodGet, "/app.css", nil, func(r *http.Request) {
|
||||
r.Header.Set("If-None-Match", etag)
|
||||
})
|
||||
if second.Code != http.StatusNotModified {
|
||||
t.Errorf("статус: получено %d, ожидалось 304", second.Code)
|
||||
}
|
||||
@@ -84,52 +184,44 @@ func TestStaticNotModified(t *testing.T) {
|
||||
}
|
||||
|
||||
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))
|
||||
e := newEnv(t)
|
||||
rec := e.do(http.MethodPost, "/api/login", strings.Repeat("a", api.MaxBody+1))
|
||||
expect(t, rec, http.StatusRequestEntityTooLarge, "too_large")
|
||||
}
|
||||
|
||||
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"])
|
||||
}
|
||||
// Тело без заявленной длины обрывается при чтении — тем же кодом.
|
||||
func TestBodyTooLargeUnannounced(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
// Тело — валидный json, чтобы разбор дошёл до предела чтения, а не
|
||||
// споткнулся о первый же байт.
|
||||
body := `{"nick":"` + strings.Repeat("a", api.MaxBody) + `"}`
|
||||
rec := e.do(http.MethodPost, "/api/login", nil, func(r *http.Request) {
|
||||
r.Body = io.NopCloser(strings.NewReader(body))
|
||||
r.ContentLength = -1
|
||||
})
|
||||
expect(t, rec, http.StatusRequestEntityTooLarge, "too_large")
|
||||
}
|
||||
|
||||
// Неподдерживаемый метод на известном пути — 404 not_found (ADR-026).
|
||||
func TestStaticRejectsWrite(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
handler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/app.css", nil))
|
||||
e := newEnv(t)
|
||||
expect(t, e.do(http.MethodPost, "/app.css", nil), http.StatusNotFound, "not_found")
|
||||
}
|
||||
|
||||
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 TestMethodOnKnownAPIPathIs404(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
expect(t, e.do(http.MethodPost, "/api/me", nil), http.StatusNotFound, "not_found")
|
||||
expect(t, e.do(http.MethodGet, "/api/nope", nil), http.StatusNotFound, "not_found")
|
||||
}
|
||||
|
||||
// Путь из запроса не должен уметь дописать строку в журнал.
|
||||
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)
|
||||
e := newEnv(t)
|
||||
|
||||
target := "/x%0a2026-01-01T00:00:00Z%20GET%20/fake%20200%201ms"
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, target, nil))
|
||||
e.do(http.MethodGet, target, nil)
|
||||
|
||||
line := log.String()
|
||||
line := e.log.String()
|
||||
if n := strings.Count(line, "\n"); n != 1 {
|
||||
t.Errorf("строк в логе: получено %d, ожидалась 1: %q", n, line)
|
||||
}
|
||||
@@ -137,19 +229,25 @@ func TestLogPathEscaped(t *testing.T) {
|
||||
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()))
|
||||
e.log.Reset()
|
||||
e.do(http.MethodGet, "/"+strings.Repeat("z", 4096), nil)
|
||||
if len(e.log.String()) > 512 {
|
||||
t.Errorf("длина строки лога: получено %d байт, ожидалось не больше 512", len(e.log.String()))
|
||||
}
|
||||
}
|
||||
|
||||
// SSE (docs/protocol.md, «События») флашит каждое событие: обёртка логгера
|
||||
// не должна прятать Flush от http.ResponseController.
|
||||
func TestFlushThroughMiddleware(t *testing.T) {
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "bare.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
var flushErr error
|
||||
h := api.New(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := &config.Config{Addr: "127.0.0.1:0", DB: "bare.db", Origin: origin}
|
||||
h := api.New(cfg, st, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
flushErr = http.NewResponseController(w).Flush()
|
||||
}), io.Discard)
|
||||
|
||||
@@ -158,3 +256,17 @@ func TestFlushThroughMiddleware(t *testing.T) {
|
||||
t.Errorf("Flush: %v", flushErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalErrorHasCode(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
// Закрытая база — единственный простой способ получить сбой хранилища.
|
||||
e.st.Close()
|
||||
rec := e.do(http.MethodGet, "/api/kdf?nick=marta", nil)
|
||||
expect(t, rec, http.StatusInternalServerError, "internal")
|
||||
if !strings.Contains(e.log.String(), "ошибка:") {
|
||||
t.Errorf("причина не попала в журнал: %q", e.log.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "sql") {
|
||||
t.Errorf("причина уехала клиенту: %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user