f61751ed26
- переход на chi + huma (решение D-006): huma отдаёт типизированные ручки и генерирует документ, auth-ручки живут на chi (cookie и заголовки напрямую) - единый формат ошибок: код (auth.invalid_credentials, auth.2fa_required, perm.denied и т.д.) + человекочитаемое сообщение (AGENT.md 8.5) - cookie сессии __Host-session: HttpOnly, SameSite=Lax, Secure при TLS; альтернатива — Bearer-токен для desktop/CLI (AGENT.md 8.1) - ручки: register, login, logout, logout-all, sessions, step-up, 2fa/setup, 2fa/enable, users/@me; IP и User-Agent прокидываются из запроса в контекст - /api/v1/openapi.json: объединённый документ (схемы huma + контракт auth) - тесты: регистрация через API с cookie, ошибки входа, обязательная сессия, валидация, наличие всех путей в OpenAPI
119 lines
3.8 KiB
Go
119 lines
3.8 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func doJSON(t *testing.T, srv *Server, method, path, body string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequestWithContext(context.Background(), method, path, strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
for _, cookie := range cookies {
|
|
req.AddCookie(cookie)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func TestRegisterEndpointCreatesSession(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/register",
|
|
`{"username":"api_user","email":"api@example.com","password":"correct-horse-battery"}`)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
var payload struct {
|
|
User struct {
|
|
ID string `json:"id"`
|
|
Username string `json:"username"`
|
|
DisplayName string `json:"display_name"`
|
|
} `json:"user"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode body: %v", err)
|
|
}
|
|
if payload.User.Username != "api_user" || payload.User.ID == "" {
|
|
t.Fatalf("unexpected user payload: %s", rec.Body.String())
|
|
}
|
|
|
|
cookies := rec.Result().Cookies()
|
|
if len(cookies) == 0 || cookies[0].Name != sessionCookieName {
|
|
t.Fatalf("session cookie is missing: %v", cookies)
|
|
}
|
|
if !cookies[0].HttpOnly {
|
|
t.Fatal("session cookie must be HttpOnly")
|
|
}
|
|
|
|
// С полученной cookie доступен профиль.
|
|
me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookies[0])
|
|
if me.Code != http.StatusOK {
|
|
t.Fatalf("GET /users/@me = %d, body = %s", me.Code, me.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestLoginEndpointErrors(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
doJSON(t, srv, http.MethodPost, "/api/v1/auth/register",
|
|
`{"username":"login_user","email":"login@example.com","password":"correct-horse-battery"}`)
|
|
|
|
rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
|
|
`{"email":"login@example.com","password":"wrong-password"}`)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("wrong password status = %d, want 401", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "invalid credentials") {
|
|
t.Fatalf("unexpected error body: %s", rec.Body.String())
|
|
}
|
|
|
|
rec = doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
|
|
`{"email":"login@example.com","password":"correct-horse-battery"}`)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("valid login status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAuthenticatedEndpointsRequireSession(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
for _, path := range []string{"/api/v1/users/@me", "/api/v1/auth/sessions"} {
|
|
rec := doJSON(t, srv, http.MethodGet, path, "")
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("GET %s without session = %d, want 401", path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidationRejectsShortPassword(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/register",
|
|
`{"username":"short_user","email":"short@example.com","password":"short"}`)
|
|
if rec.Code == http.StatusOK {
|
|
t.Fatalf("short password accepted: %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestOpenAPIDocumentsAuthEndpoints(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
rec := doJSON(t, srv, http.MethodGet, "/api/v1/openapi.json", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("openapi status = %d", rec.Code)
|
|
}
|
|
var doc struct {
|
|
Paths map[string]any `json:"paths"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
|
|
t.Fatalf("decode openapi: %v", err)
|
|
}
|
|
for _, path := range []string{"/auth/register", "/auth/login", "/auth/logout", "/auth/sessions", "/users/@me", "/auth/2fa/setup", "/meta"} {
|
|
if _, ok := doc.Paths[path]; !ok {
|
|
t.Errorf("openapi is missing %s", path)
|
|
}
|
|
}
|
|
}
|