Files
bare/internal/api/account_test.go
T
mayatnikovandClaude Opus 5 383e374f0f iOS: запрет масштабирования, версия в подвале, самообновление, строка ввода
Отзыв с iPhone после первого живого прогона.

Масштабирование запрещено (ADR-066): viewport как в соседнем sixlines плюс
viewport-fit=cover, мета-теги установленного приложения, touch-action против
двойного тапа и страховка на JS — гашение жестов iOS, потому что мета-строку
Safari вправе проигнорировать. Размеры шрифтов не тронуты: 14 px из брифа
остаются, поднимать поля до 16 px владелец отклонил. Цена — доступность
для тех, кому зум нужен — записана в ADR и в ui.md честно.

Версия в подвале (ADR-065, 067): GET /api/config отдаёт короткую ревизию
и время коммита. Именно коммита, а не сборки: штамп компиляции в бинаре
means каждая пересборка даёт новый хеш, и обещание ADR-022 «бинарь собирается
из тега байт в байт» перестаёт проверяться.

Самообновление PWA (ADR-068, 070): в режиме приложения на iPhone перезагрузки
нет, и старая версия жила бы вечно. Проверка при возврате в приложение,
включение ждущей оболочки, перезагрузка — но не тогда, когда в строке набран
текст. Замок от петли сравнивает версию оболочки, а не считает перезагрузки:
счётчик отменял саму фичу, долгоживущая вкладка переставала обновляться.
Замерено: было 30, 60, 120, 240 секунд между релизами, стало ровно 30.

Строка ввода — редактируемый блок (ADR-069): iOS рисует над клавиатурой
полосу со стрелками и «готово» для полей формы, и убрать её со страницы
нельзя. Для contenteditable не рисует. Режим plaintext-only с проверкой,
что он применился, и запасным путём с разбором вставки: невалидное значение
атрибута в части браузеров делает блок нередактируемым вовсе. Чтение
и запись только через textContent, innerHTML в клиенте по-прежнему нет.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
2026-08-23 15:11:13 +03:00

526 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api_test
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/xmatic-squad/bare/internal/api"
"github.com/xmatic-squad/bare/internal/build"
"github.com/xmatic-squad/bare/internal/config"
)
// bytesOf — детерминированные «случайные» байты: содержимое сервер
// не проверяет, ему важна только форма.
func bytesOf(n int, seed byte) string {
raw := make([]byte, n)
for i := range raw {
raw[i] = seed + byte(i)
}
return base64.RawURLEncoding.EncodeToString(raw)
}
// blobOf — ключевой блоб в форме docs/crypto.md.
func blobOf(iter int) string {
return fmt.Sprintf(`{"v":1,"iter":%d,"iv":"%s","ct":"%s"}`, iter, bytesOf(12, 7), bytesOf(48, 11))
}
func jwk() map[string]string {
return map[string]string{"kty": "EC", "crv": "P-256", "x": bytesOf(32, 3), "y": bytesOf(32, 5)}
}
func account(nick string) map[string]any {
return map[string]any{
"nick": nick,
"authKey": bytesOf(32, 1),
"publicKey": jwk(),
"blob": blobOf(config.KDFIterations),
}
}
// signUp регистрирует аккаунт и отдаёт cookie сессии. Каждый ник приходит
// со своего адреса: регистрация ограничена пятью в час на IP (ADR-021),
// и общий адрес упирался бы в лимит на шестом аккаунте теста.
func (e *env) signUp(nick string) *http.Cookie {
e.t.Helper()
rec := e.do(http.MethodPost, "/api/register", account(nick), fromNick(nick))
expect(e.t, rec, http.StatusCreated, "")
return e.cookie(rec)
}
// fromNick — свой адрес соединения на каждый ник, лишь бы разный
// и не loopback.
func fromNick(nick string) func(*http.Request) {
sum := sha256.Sum256([]byte(nick))
return withRemote(fmt.Sprintf("198.51.%d.%d:41000", sum[0], sum[1]))
}
func (e *env) cookie(rec *httptest.ResponseRecorder) *http.Cookie {
e.t.Helper()
for _, c := range rec.Result().Cookies() {
if c.Name == "bare_session" {
return c
}
}
e.t.Fatal("в ответе нет cookie bare_session")
return nil
}
func TestConfig(t *testing.T) {
e := newEnv(t)
rec := e.do(http.MethodGet, "/api/config", nil)
expect(t, rec, http.StatusOK, "")
var got struct {
InviteRequired bool `json:"inviteRequired"`
VAPIDPublicKey string `json:"vapidPublicKey"`
KDFIterations int `json:"kdfIterations"`
MaxMessageChars int `json:"maxMessageChars"`
Version string `json:"version"`
CommitAt int64 `json:"commitAt"`
}
decodeBody(t, rec, &got)
// Имена полей — часть протокола (docs/protocol.md). Сравнение значений
// их не закрепляет: у половины полей нулевое значение законно, и ответ
// без поля разбирается в тот же ноль — переименованный тег прошёл бы
// незамеченным. Поэтому сначала перечень ключей, потом значения.
var keys map[string]json.RawMessage
decodeBody(t, rec, &keys)
for _, name := range []string{
"inviteRequired", "vapidPublicKey", "kdfIterations",
"maxMessageChars", "version", "commitAt",
} {
if _, ok := keys[name]; !ok {
t.Errorf("в ответе нет поля %q", name)
}
}
if got.InviteRequired {
t.Error("inviteRequired: получено true, ожидалось false")
}
if got.VAPIDPublicKey != "vapid" {
t.Errorf("vapidPublicKey: получено %q", got.VAPIDPublicKey)
}
if got.KDFIterations != 1_000_000 {
t.Errorf("kdfIterations: получено %d, ожидалось 1000000", got.KDFIterations)
}
if got.MaxMessageChars != 4000 {
t.Errorf("maxMessageChars: получено %d, ожидалось 4000", got.MaxMessageChars)
}
// Версия — то же самое, что печатает `bare version`: одно место,
// один формат (ADR-065). Тестовому бинарю vcs.* не проставляются,
// поэтому здесь проверяется в том числе поведение без build info —
// «unknown» и 0.
if !validVersion(got.Version) {
t.Errorf("version: получено %q, ожидались до семи hex-символов, «+dirty» или «unknown»", got.Version)
}
if want := build.Current().Version(); got.Version != want {
t.Errorf("version: получено %q, ожидалось %q", got.Version, want)
}
if want := build.Current().CommitMilli(); got.CommitAt != want {
t.Errorf("commitAt: получено %d, ожидалось %d", got.CommitAt, want)
}
if got.CommitAt < 0 {
t.Errorf("commitAt: получено %d, неизвестное время — это 0", got.CommitAt)
}
}
// validVersion — формат поля version: «unknown» либо до семи символов
// хеша, у сборки из изменённого дерева с суффиксом «+dirty».
func validVersion(v string) bool {
if v == "unknown" {
return true
}
rev, _ := strings.CutSuffix(v, "+dirty")
if rev == "" || len(rev) > 7 {
return false
}
for _, c := range rev {
if !strings.ContainsRune("0123456789abcdef", c) {
return false
}
}
return true
}
func TestRegisterAndLogin(t *testing.T) {
e := newEnv(t)
rec := e.do(http.MethodPost, "/api/register", account("marta"))
expect(t, rec, http.StatusCreated, "")
var created struct {
Nick string `json:"nick"`
}
decodeBody(t, rec, &created)
if created.Nick != "marta" {
t.Errorf("ник в ответе: получено %q", created.Nick)
}
c := e.cookie(rec)
if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteStrictMode || c.Path != "/" {
t.Errorf("флаги cookie: %+v", c)
}
if c.MaxAge < 89*24*3600 || c.MaxAge > 90*24*3600 {
t.Errorf("срок cookie: получено %d секунд, ожидалось около 90 суток", c.MaxAge)
}
// Занятый ник.
expect(t, e.do(http.MethodPost, "/api/register", account("marta")), http.StatusConflict, "nick_taken")
// Сессия из регистрации работает.
me := e.do(http.MethodGet, "/api/me", nil, with(c))
expect(t, me, http.StatusOK, "")
var self struct {
Nick string `json:"nick"`
PublicKey map[string]string `json:"publicKey"`
CreatedAt int64 `json:"createdAt"`
}
decodeBody(t, me, &self)
if self.Nick != "marta" || self.PublicKey["crv"] != "P-256" || self.CreatedAt == 0 {
t.Errorf("GET /api/me: %+v", self)
}
// Вход тем же authKey отдаёт публичный ключ и блоб.
login := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)})
expect(t, login, http.StatusOK, "")
var in struct {
Nick string `json:"nick"`
PublicKey map[string]string `json:"publicKey"`
Blob string `json:"blob"`
}
decodeBody(t, login, &in)
if in.Nick != "marta" || in.Blob != blobOf(config.KDFIterations) || in.PublicKey["x"] != bytesOf(32, 3) {
t.Errorf("вход: %+v", in)
}
if _, ok := in.PublicKey["d"]; ok {
t.Error("в публичном ключе есть d")
}
e.cookie(login)
// Неверный authKey и несуществующий ник неразличимы.
bad := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 9)})
expect(t, bad, http.StatusUnauthorized, "invalid_credentials")
none := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "никого", "authKey": bytesOf(32, 1)})
expect(t, none, http.StatusUnauthorized, "invalid_credentials")
unknown := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "petya", "authKey": bytesOf(32, 1)})
expect(t, unknown, http.StatusUnauthorized, "invalid_credentials")
}
func TestRegisterRejects(t *testing.T) {
private := jwk()
private["d"] = bytesOf(32, 13)
cases := []struct {
name string
change func(map[string]any)
status int
code string
field string
}{
{"кривой ник", func(m map[string]any) { m["nick"] = "Марта" }, http.StatusBadRequest, "invalid_nick", ""},
{"короткий ник", func(m map[string]any) { m["nick"] = "m" }, http.StatusBadRequest, "invalid_nick", ""},
{"ник с заглавной", func(m map[string]any) { m["nick"] = "Marta" }, http.StatusBadRequest, "invalid_nick", ""},
{"короткий authKey", func(m map[string]any) { m["authKey"] = bytesOf(16, 1) }, http.StatusBadRequest, "invalid", "authKey"},
{"authKey не base64url", func(m map[string]any) { m["authKey"] = strings.Repeat("=", 44) }, http.StatusBadRequest, "invalid", "authKey"},
{"приватный ключ в jwk", func(m map[string]any) { m["publicKey"] = private }, http.StatusBadRequest, "invalid", "publicKey"},
{"чужая кривая", func(m map[string]any) {
k := jwk()
k["crv"] = "P-384"
m["publicKey"] = k
}, http.StatusBadRequest, "invalid", "publicKey"},
{"нет публичного ключа", func(m map[string]any) { delete(m, "publicKey") }, http.StatusBadRequest, "invalid", "publicKey"},
{"слабый iter", func(m map[string]any) { m["blob"] = blobOf(599_999) }, http.StatusBadRequest, "invalid", "blob"},
// Неподъёмный iter сервер отдал бы клиентам из GET /api/kdf (ADR-030).
{"неподъёмный iter", func(m map[string]any) {
m["blob"] = blobOf(config.KDFMaxIterations + 1)
}, http.StatusBadRequest, "invalid", "blob"},
{"iter в триллион", func(m map[string]any) { m["blob"] = blobOf(1_000_000_000_000) }, http.StatusBadRequest, "invalid", "blob"},
{"дробный iter", func(m map[string]any) {
m["blob"] = `{"v":1,"iter":1e6,"iv":"` + bytesOf(12, 7) + `","ct":"` + bytesOf(48, 11) + `"}`
}, http.StatusBadRequest, "invalid", "blob"},
{"версия блоба", func(m map[string]any) {
m["blob"] = strings.Replace(blobOf(config.KDFIterations), `"v":1`, `"v":2`, 1)
}, http.StatusBadRequest, "invalid", "blob"},
{"блоб больше 8 КиБ", func(m map[string]any) {
m["blob"] = fmt.Sprintf(`{"v":1,"iter":%d,"iv":"%s","ct":"%s"}`,
config.KDFIterations, bytesOf(12, 7), strings.Repeat("a", 8<<10))
}, http.StatusBadRequest, "invalid", "blob"},
{"блоб не json", func(m map[string]any) { m["blob"] = "не json" }, http.StatusBadRequest, "invalid", "blob"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newEnv(t)
body := account("marta")
c.change(body)
rec := e.do(http.MethodPost, "/api/register", body)
expect(t, rec, c.status, c.code)
if c.field != "" {
var got struct {
Field string `json:"field"`
}
decodeBody(t, rec, &got)
if got.Field != c.field {
t.Errorf("field: получено %q, ожидалось %q", got.Field, c.field)
}
}
// Ни одна из этих регистраций не должна была создать аккаунт.
expect(t, e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)}),
http.StatusUnauthorized, "invalid_credentials")
})
}
}
func TestRegisterBadJSON(t *testing.T) {
e := newEnv(t)
expect(t, e.do(http.MethodPost, "/api/register", "{"), http.StatusBadRequest, "bad_json")
}
func TestInvite(t *testing.T) {
e := invited(t, "секрет")
rec := e.do(http.MethodGet, "/api/config", nil)
var cfg struct {
InviteRequired bool `json:"inviteRequired"`
}
decodeBody(t, rec, &cfg)
if !cfg.InviteRequired {
t.Error("inviteRequired: получено false, ожидалось true")
}
expect(t, e.do(http.MethodPost, "/api/register", account("marta")), http.StatusForbidden, "invite_required")
wrong := account("marta")
wrong["invite"] = "не секрет"
expect(t, e.do(http.MethodPost, "/api/register", wrong), http.StatusForbidden, "invalid_invite")
right := account("marta")
right["invite"] = "секрет"
expect(t, e.do(http.MethodPost, "/api/register", right), http.StatusCreated, "")
}
func TestKDF(t *testing.T) {
e := newEnv(t)
// Неизвестный ник — целевое значение, тем же статусом.
for _, nick := range []string{"marta", "", "МАРТА", strings.Repeat("x", 40)} {
rec := e.do(http.MethodGet, "/api/kdf?nick="+nick, nil)
expect(t, rec, http.StatusOK, "")
var got struct {
Iterations int `json:"iterations"`
}
decodeBody(t, rec, &got)
if got.Iterations != config.KDFIterations {
t.Errorf("iterations для %q: получено %d, ожидалось %d", nick, got.Iterations, config.KDFIterations)
}
}
// Известный ник — iter из его блоба.
body := account("marta")
body["blob"] = blobOf(700_000)
expect(t, e.do(http.MethodPost, "/api/register", body), http.StatusCreated, "")
rec := e.do(http.MethodGet, "/api/kdf?nick=marta", nil)
expect(t, rec, http.StatusOK, "")
var got struct {
Iterations int `json:"iterations"`
}
decodeBody(t, rec, &got)
if got.Iterations != 700_000 {
t.Errorf("iterations: получено %d, ожидалось 700000", got.Iterations)
}
}
func TestOrigin(t *testing.T) {
e := newEnv(t)
body := map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)}
expect(t, e.do(http.MethodPost, "/api/login", body, withOrigin("")), http.StatusForbidden, "bad_origin")
expect(t, e.do(http.MethodPost, "/api/login", body, withOrigin("https://зло.example")), http.StatusForbidden, "bad_origin")
expect(t, e.do(http.MethodPost, "/api/login", body, withOrigin("null")), http.StatusForbidden, "bad_origin")
// GET без Origin работает.
expect(t, e.do(http.MethodGet, "/api/config", nil), http.StatusOK, "")
expect(t, e.do(http.MethodGet, "/", nil), http.StatusOK, "")
// Свой Origin проходит: дальше — обычная ошибка входа, не 403.
expect(t, e.do(http.MethodPost, "/api/login", body), http.StatusUnauthorized, "invalid_credentials")
}
func TestPasswordChange(t *testing.T) {
e := newEnv(t)
first := e.signUp("marta")
// Второе устройство: свой вход, своя сессия.
login := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)})
expect(t, login, http.StatusOK, "")
second := e.cookie(login)
newBlob := blobOf(config.KDFIterations)
change := map[string]any{
"authKey": bytesOf(32, 1),
"newAuthKey": bytesOf(32, 9),
"blob": newBlob,
"logoutOthers": true,
}
// Без сессии — 401 unauthenticated, а не invalid_credentials.
expect(t, e.do(http.MethodPost, "/api/password", change), http.StatusUnauthorized, "unauthenticated")
// Неверный старый authKey.
wrong := map[string]any{"authKey": bytesOf(32, 42), "newAuthKey": bytesOf(32, 9), "blob": newBlob}
expect(t, e.do(http.MethodPost, "/api/password", wrong, with(second)), http.StatusUnauthorized, "invalid_credentials")
// Слабый новый блоб не принимается.
weak := map[string]any{"authKey": bytesOf(32, 1), "newAuthKey": bytesOf(32, 9), "blob": blobOf(599_999)}
expect(t, e.do(http.MethodPost, "/api/password", weak, with(second)), http.StatusBadRequest, "invalid")
expect(t, e.do(http.MethodPost, "/api/password", change, with(second)), http.StatusNoContent, "")
// Текущая сессия жива, остальные — нет.
expect(t, e.do(http.MethodGet, "/api/me", nil, with(second)), http.StatusOK, "")
expect(t, e.do(http.MethodGet, "/api/me", nil, with(first)), http.StatusUnauthorized, "unauthenticated")
// Старый authKey больше не подходит, новый отдаёт новый блоб.
expect(t, e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)}),
http.StatusUnauthorized, "invalid_credentials")
fresh := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 9)})
expect(t, fresh, http.StatusOK, "")
var got struct {
Blob string `json:"blob"`
}
decodeBody(t, fresh, &got)
if got.Blob != newBlob {
t.Errorf("блоб после смены пароля: получено %q", got.Blob)
}
}
// Повышение итераций — та же операция без выхода на других устройствах.
func TestPasswordKeepsOtherSessions(t *testing.T) {
e := newEnv(t)
first := e.signUp("marta")
login := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)})
second := e.cookie(login)
change := map[string]any{
"authKey": bytesOf(32, 1),
"newAuthKey": bytesOf(32, 9),
"blob": blobOf(config.KDFIterations),
"logoutOthers": false,
}
expect(t, e.do(http.MethodPost, "/api/password", change, with(second)), http.StatusNoContent, "")
expect(t, e.do(http.MethodGet, "/api/me", nil, with(first)), http.StatusOK, "")
}
func TestSessionRequired(t *testing.T) {
e := newEnv(t)
e.signUp("marta")
for _, target := range []string{"/api/me", "/api/users/marta"} {
expect(t, e.do(http.MethodGet, target, nil), http.StatusUnauthorized, "unauthenticated")
}
expect(t, e.do(http.MethodPost, "/api/logout", nil), http.StatusUnauthorized, "unauthenticated")
garbage := &http.Cookie{Name: "bare_session", Value: "not-a-token"}
expect(t, e.do(http.MethodGet, "/api/me", nil, with(garbage)), http.StatusUnauthorized, "unauthenticated")
stranger := &http.Cookie{Name: "bare_session", Value: bytesOf(32, 77)}
expect(t, e.do(http.MethodGet, "/api/me", nil, with(stranger)), http.StatusUnauthorized, "unauthenticated")
}
func TestLogout(t *testing.T) {
e := newEnv(t)
c := e.signUp("marta")
rec := e.do(http.MethodPost, "/api/logout", nil, with(c))
expect(t, rec, http.StatusNoContent, "")
if cleared := e.cookie(rec); cleared.Value != "" || cleared.MaxAge >= 0 {
t.Errorf("cookie не стёрта: %+v", cleared)
}
expect(t, e.do(http.MethodGet, "/api/me", nil, with(c)), http.StatusUnauthorized, "unauthenticated")
}
func TestUsers(t *testing.T) {
e := newEnv(t)
c := e.signUp("marta")
rec := e.do(http.MethodGet, "/api/users/marta", nil, with(c))
expect(t, rec, http.StatusOK, "")
var got struct {
Nick string `json:"nick"`
PublicKey json.RawMessage `json:"publicKey"`
}
decodeBody(t, rec, &got)
if got.Nick != "marta" || !strings.Contains(string(got.PublicKey), `"P-256"`) {
t.Errorf("ответ: %s", rec.Body.String())
}
expect(t, e.do(http.MethodGet, "/api/users/petya", nil, with(c)), http.StatusNotFound, "unknown_user")
expect(t, e.do(http.MethodGet, "/api/users/МАРТА", nil, with(c)), http.StatusNotFound, "unknown_user")
}
func TestDeleteMe(t *testing.T) {
e := newEnv(t)
c := e.signUp("marta")
expect(t, e.do(http.MethodDelete, "/api/me", map[string]any{"authKey": bytesOf(32, 42)}, with(c)),
http.StatusUnauthorized, "invalid_credentials")
rec := e.do(http.MethodDelete, "/api/me", map[string]any{"authKey": bytesOf(32, 1)}, with(c))
expect(t, rec, http.StatusNoContent, "")
if cleared := e.cookie(rec); cleared.Value != "" || cleared.MaxAge >= 0 {
t.Errorf("cookie не стёрта: %+v", cleared)
}
// Сессия ушла каскадом, ник свободен.
expect(t, e.do(http.MethodGet, "/api/me", nil, with(c)), http.StatusUnauthorized, "unauthenticated")
expect(t, e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)}),
http.StatusUnauthorized, "invalid_credentials")
expect(t, e.do(http.MethodPost, "/api/register", account("marta")), http.StatusCreated, "")
}
// Ник не должен попадать в журнал (docs/deploy.md, «Логи»).
func TestNickStaysOutOfLog(t *testing.T) {
e := newEnv(t)
c := e.signUp("marta")
e.log.Reset()
e.do(http.MethodGet, "/api/users/marta", nil, with(c))
e.do(http.MethodGet, "/api/kdf?nick=marta", nil)
e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)})
if strings.Contains(e.log.String(), "marta") {
t.Errorf("ник в журнале: %q", e.log.String())
}
if !strings.Contains(e.log.String(), "/api/users/{nick}") {
t.Errorf("шаблон маршрута не в журнале: %q", e.log.String())
}
}
// Отказ до маршрутизации — шаблона ещё нет, а путь с ником в журнал
// попадать не должен всё равно (docs/deploy.md, «Логи»).
func TestNickStaysOutOfLogBeforeRouting(t *testing.T) {
e := newEnv(t)
// 403 bad_origin: любой не-GET со стороннего сайта.
e.do(http.MethodPost, "/api/users/marta", nil, withOrigin("https://зло.example"))
e.do(http.MethodDelete, "/api/contacts/marta", nil, withOrigin(""))
// 413 too_large: тело больше предела, ответ до маршрутизации.
e.do(http.MethodGet, "/api/users/marta", strings.Repeat("a", api.MaxBody+1))
line := e.log.String()
if strings.Count(line, "\n") != 3 {
t.Fatalf("строк в журнале: %q", line)
}
if strings.Contains(line, "marta") {
t.Errorf("ник в журнале: %q", line)
}
if strings.Count(line, "/api/ ") != 3 {
t.Errorf("вместо пути ожидалось \"/api/\": %q", line)
}
}