feat(api): REST на chi + huma с auth-ручками и OpenAPI 3.1
- переход на 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
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user