feat(api): ручки Фаз(ы) 1 — профиль, серверы, роли, админ инстанса
REST-слой Фазы 1 на huma (OpenAPI 3.1 генерируется из кода):
- профиль: GET/PATCH /users/@me, смена пароля со step-up, публичный профиль,
завершение онбординга (новая миграция 00003 с onboarding_completed_at);
- серверы: создание/изменение/удаление, join/leave, список серверов
пользователя, журнал действий;
- комнаты: список с учётом прав, создание/изменение/удаление;
- участники: список с профилями и ролями, никнейм, тайм-аут, исключение;
- роли: CRUD, выдача/снятие с проверкой иерархии и запретом выдачи прав выше
собственных;
- админ инстанса: публичная информация, настройки, серверы, пользователи,
аудит, выдача прав администратора со step-up; обход лимитов фиксируется в
аудите отдельной записью limits.bypass;
- движок прав: участие в сервере стало обязательным условием (IsMember),
не участник не получает прав роли @user; калькулятор прав общий для API и
Gateway, инвалидация кэша после изменений;
- Gateway: браузерный клиент аутентифицируется cookie на рукопожатии, IDENTIFY
без токена использует её; события GUILD/CHANNEL/MEMBER/ROLE рассылаются из
ручек, USER_UPDATE — адресно;
- ошибки huma отдаются в едином конверте {"error":{"code","message"}}.
Тесты: 8 сценариев API (профиль, жизненный цикл сервера и права, лимиты и
обход админом, иерархия ролей, тайм-аут, скрытие комнаты оверрайдом,
членство в движке прав, cookie-идентификация Gateway).
This commit is contained in:
@@ -60,7 +60,7 @@ func newFixture(t *testing.T) *fixture {
|
||||
t.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
|
||||
service := gateway.New(st, authService, gateway.NewSnapshot(st), logger, []string{"https://gl.test"})
|
||||
service := gateway.New(st, authService, gateway.NewSnapshot(st, nil), logger, []string{"https://gl.test"})
|
||||
server := httptest.NewServer(service.Handler())
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
@@ -242,6 +242,62 @@ func TestIdentifyWithInvalidToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestIdentifyWithSessionCookie проверяет браузерный вход: токен в IDENTIFY
|
||||
// не передаётся, сессия берётся из cookie рукопожатия (AGENT.md 8.1).
|
||||
func TestIdentifyWithSessionCookie(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
_, token, session, err := f.auth.Register(context.Background(), auth.RegisterInput{
|
||||
Username: "cookie_user", Email: "cookie@example.com", Password: "correct-horse-battery",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
if session == nil {
|
||||
t.Fatal("register returned no session")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
url := "ws" + strings.TrimPrefix(f.server.URL, "http")
|
||||
conn, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{
|
||||
HTTPHeader: map[string][]string{
|
||||
"Origin": {"https://gl.test"},
|
||||
"Cookie": {gateway.SessionCookieName + "=" + token},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dial with cookie: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.CloseNow() })
|
||||
|
||||
if hello := readEnvelope(t, conn); hello.Op != gateway.OpHello {
|
||||
t.Fatalf("first frame op = %d, want HELLO", hello.Op)
|
||||
}
|
||||
// IDENTIFY без токена: сервер доверяет cookie.
|
||||
send(t, conn, gateway.OpIdentify, map[string]any{})
|
||||
ready := readEnvelope(t, conn)
|
||||
if ready.T != "READY" {
|
||||
t.Fatalf("frame = %q, want READY", ready.T)
|
||||
}
|
||||
var snapshot gateway.Ready
|
||||
if err := json.Unmarshal(ready.D, &snapshot); err != nil {
|
||||
t.Fatalf("decode READY: %v", err)
|
||||
}
|
||||
if snapshot.User.Username != "cookie_user" {
|
||||
t.Fatalf("READY user = %q, want cookie_user", snapshot.User.Username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentifyWithoutTokenOrCookie(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
conn, _ := f.dial(t)
|
||||
send(t, conn, gateway.OpIdentify, map[string]any{})
|
||||
response := readEnvelope(t, conn)
|
||||
if response.Op != gateway.OpInvalidSess {
|
||||
t.Fatalf("op = %d, want INVALID_SESSION", response.Op)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatAck(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
token := registerUser(t, f, "heartbeat_user", "heartbeat@example.com")
|
||||
|
||||
@@ -2,6 +2,7 @@ package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -17,8 +18,13 @@ type Snapshot struct {
|
||||
calculator *permissions.Calculator
|
||||
}
|
||||
|
||||
func NewSnapshot(st *store.Store) *Snapshot {
|
||||
return &Snapshot{store: st, calculator: permissions.NewCalculator(newPermissionSource(st))}
|
||||
// NewSnapshot собирает READY через переданный калькулятор прав: сервер и
|
||||
// Gateway должны использовать один кэш, иначе инвалидация не видна обоим.
|
||||
func NewSnapshot(st *store.Store, calculator *permissions.Calculator) *Snapshot {
|
||||
if calculator == nil {
|
||||
calculator = permissions.NewCalculator(newPermissionSource(st))
|
||||
}
|
||||
return &Snapshot{store: st, calculator: calculator}
|
||||
}
|
||||
|
||||
var _ SnapshotBuilder = (*Snapshot)(nil)
|
||||
@@ -182,6 +188,16 @@ func (p *permissionSource) MemberRoleIDs(ctx context.Context, guildID, userID ui
|
||||
return p.store.MemberRoleIDs(ctx, guildID, userID)
|
||||
}
|
||||
|
||||
func (p *permissionSource) IsMember(ctx context.Context, guildID, userID uint64) (bool, error) {
|
||||
if _, err := p.store.GetGuildMember(ctx, guildID, userID); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (p *permissionSource) ChannelOverrides(ctx context.Context, channelID uint64) ([]permissions.OverrideData, error) {
|
||||
overrides, err := p.store.ListChannelOverrides(ctx, channelID)
|
||||
if err != nil {
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// clientSession — одно подключение клиента (AGENT.md 8.3: сессий может быть
|
||||
@@ -24,8 +26,16 @@ type clientSession struct {
|
||||
closeOnce sync.Once
|
||||
closed chan struct{}
|
||||
buffer *resumeBuffer
|
||||
// preAuth — сессия, восстановленная из cookie на рукопожатии: браузерный
|
||||
// клиент не имеет доступа к токену (AGENT.md 8.1, 8.3).
|
||||
preAuth *store.Session
|
||||
}
|
||||
|
||||
// SessionCookieName — имя cookie сессии. Значение должно совпадать с
|
||||
// server.sessionCookieName: браузерный Gateway-клиент аутентифицируется
|
||||
// cookie, а не токеном в IDENTIFY (AGENT.md 8.1).
|
||||
const SessionCookieName = "__Host-session"
|
||||
|
||||
// outbound — кадр в очереди на отправку. done используется, когда отправитель
|
||||
// ждёт фактической записи в сокет (например, перед закрытием соединения).
|
||||
type outbound struct {
|
||||
@@ -97,6 +107,12 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
send: make(chan outbound, 128),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
// Cookie сессии проверяем до HELLO: ошибку вернёт IDENTIFY.
|
||||
if cookie, err := r.Cookie(SessionCookieName); err == nil && cookie.Value != "" {
|
||||
if _, authSession, err := s.auth.ResolveSession(ctx, cookie.Value); err == nil {
|
||||
session.preAuth = authSession
|
||||
}
|
||||
}
|
||||
conn.SetReadLimit(MaxIncomingFrame)
|
||||
|
||||
// HELLO с интервалом heartbeat и идентификатором сессии.
|
||||
@@ -215,7 +231,21 @@ func (s *Service) sendInvalidSession(session *clientSession, reason string) {
|
||||
// handleIdentify проверяет токен, регистрирует сессию и отправляет READY
|
||||
// либо догоняет пропущенные события при RESUME.
|
||||
func (s *Service) handleIdentify(ctx context.Context, session *clientSession, payload identifyPayload, resume bool) error {
|
||||
user, authSession, err := s.auth.ResolveSession(ctx, payload.Token)
|
||||
var (
|
||||
user *store.User
|
||||
authSession *store.Session
|
||||
err error
|
||||
)
|
||||
switch {
|
||||
case payload.Token != "":
|
||||
user, authSession, err = s.auth.ResolveSession(ctx, payload.Token)
|
||||
case session.preAuth != nil:
|
||||
// Токен не передан: доверяем cookie, проверенной на рукопожатии.
|
||||
user, err = s.store.GetUser(ctx, session.preAuth.UserID)
|
||||
authSession = session.preAuth
|
||||
default:
|
||||
err = errors.New("identify without token and without session cookie")
|
||||
}
|
||||
if err != nil {
|
||||
s.sendInvalidSession(session, "invalid token")
|
||||
return err
|
||||
@@ -340,14 +370,12 @@ func writeDirect(ctx context.Context, conn *websocket.Conn, payload []byte) erro
|
||||
func decodeIdentify(raw json.RawMessage) (identifyPayload, error) {
|
||||
var payload identifyPayload
|
||||
if len(raw) == 0 {
|
||||
return payload, errors.New("empty identify payload")
|
||||
// Пустой payload допустим: браузер аутентифицируется cookie.
|
||||
return payload, nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return payload, err
|
||||
}
|
||||
if payload.Token == "" {
|
||||
return payload, errors.New("token is required")
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user