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-074). Тестовому бинарю 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) } }