Compare commits
4 Commits
785af0dc7b
...
e5af7b1f83
| Author | SHA1 | Date | |
|---|---|---|---|
| e5af7b1f83 | |||
| d241714a6d | |||
| ff2fc878b0 | |||
| 66dd07eb7b |
+1
-1
@@ -38,5 +38,5 @@ web/node_modules/.cache/
|
|||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|
||||||
# артефакты Playwright (e2e-прогоны) — локальные
|
# артефакты Playwright (e2e-прогоны) — локальные
|
||||||
web/test-results/
|
web/test-results*/
|
||||||
web/playwright-report/
|
web/playwright-report/
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pquerna/otp/totp"
|
"github.com/pquerna/otp/totp"
|
||||||
@@ -33,6 +35,8 @@ func runCLI(args []string) int {
|
|||||||
return cliTOTPSetup(args[1:])
|
return cliTOTPSetup(args[1:])
|
||||||
case "totp-reset":
|
case "totp-reset":
|
||||||
return cliTOTPReset(args[1:])
|
return cliTOTPReset(args[1:])
|
||||||
|
case "create-user":
|
||||||
|
return cliCreateUser(args[1:])
|
||||||
case "cleanup":
|
case "cleanup":
|
||||||
return cliCleanup(args[1:])
|
return cliCleanup(args[1:])
|
||||||
case "reindex":
|
case "reindex":
|
||||||
@@ -273,6 +277,107 @@ func cliSetInstanceAdmin(command string, args []string) int {
|
|||||||
|
|
||||||
// cliCleanup запускает чистку вручную: сессии, аудит и файлы без ссылок
|
// cliCleanup запускает чистку вручную: сессии, аудит и файлы без ссылок
|
||||||
// (AGENT.md 6.4, 10.7).
|
// (AGENT.md 6.4, 10.7).
|
||||||
|
// cliCreateUser создаёт аккаунты по команде обслуживания: регистрация на
|
||||||
|
// инстансе может быть выключена, а для нагрузочного теста (AGENT.md 11.4)
|
||||||
|
// нужны десятки пользователей. Пароль задаёт оператор, аккаунты создаются без
|
||||||
|
// сессий; список логинов и паролей можно выгрузить в файл для генератора
|
||||||
|
// нагрузки (--out).
|
||||||
|
func cliCreateUser(args []string) int {
|
||||||
|
flags := flag.NewFlagSet("create-user", flag.ContinueOnError)
|
||||||
|
email := flags.String("email", "", "email пользователя")
|
||||||
|
username := flags.String("username", "", "username (по умолчанию — часть email)")
|
||||||
|
password := flags.String("password", "", "пароль")
|
||||||
|
count := flags.Int("count", 1, "сколько аккаунтов создать (для нагрузки)")
|
||||||
|
prefix := flags.String("prefix", "", "префикс логина в пакетном режиме, например loadtest")
|
||||||
|
domain := flags.String("domain", "loadtest.local", "домен email в пакетном режиме")
|
||||||
|
out := flags.String("out", "", "файл с созданными учётными данными (JSON)")
|
||||||
|
sessions := flags.Bool("sessions", false, "выдать каждому аккаунту сессию (для нагрузочного теста)")
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if *password == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "glchat create-user: укажите --password")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if *count > 1 && *prefix == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "glchat create-user: для --count > 1 нужен --prefix")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if *count <= 1 && *email == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "glchat create-user: укажите --email")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
type credentials struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
}
|
||||||
|
created := make([]credentials, 0, *count)
|
||||||
|
|
||||||
|
code := withServices(func(ctx context.Context, _ *store.Store, authService *auth.Service, _ config.Config) error {
|
||||||
|
for index := 1; index <= *count; index += 1 {
|
||||||
|
name := *username
|
||||||
|
address := *email
|
||||||
|
if *count > 1 {
|
||||||
|
name = fmt.Sprintf("%s%d", *prefix, index)
|
||||||
|
address = fmt.Sprintf("%s%d@%s", *prefix, index, *domain)
|
||||||
|
}
|
||||||
|
user, err := authService.CreateUserByOperator(ctx, auth.RegisterInput{
|
||||||
|
Username: name,
|
||||||
|
Email: address,
|
||||||
|
Password: *password,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, auth.ErrUsernameTaken) || errors.Is(err, auth.ErrEmailTaken) {
|
||||||
|
fmt.Printf("пропущен (уже существует): %s\n", address)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("создать %s: %w", address, err)
|
||||||
|
}
|
||||||
|
entry := credentials{
|
||||||
|
ID: strconv.FormatUint(user.ID, 10),
|
||||||
|
Username: user.Username,
|
||||||
|
Email: address,
|
||||||
|
Password: *password,
|
||||||
|
}
|
||||||
|
if *sessions {
|
||||||
|
token, err := authService.IssueSessionForOperator(ctx, user.ID, "glchat-cli loadtest")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("сессия для %s: %w", address, err)
|
||||||
|
}
|
||||||
|
entry.Token = token
|
||||||
|
}
|
||||||
|
created = append(created, entry)
|
||||||
|
if *count == 1 || index%25 == 0 || index == *count {
|
||||||
|
fmt.Printf("создано %d из %d\n", index, *count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if code != 0 {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
if *out != "" {
|
||||||
|
// #nosec G117 -- файл с тестовыми паролями создаётся намеренно, права 600.
|
||||||
|
encoded, err := json.MarshalIndent(created, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "glchat create-user: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
// Файл содержит пароли тестовых аккаунтов: права 600.
|
||||||
|
if err := os.WriteFile(*out, encoded, 0o600); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "glchat create-user: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Printf("учётные данные: %s (%d)\n", *out, len(created))
|
||||||
|
}
|
||||||
|
fmt.Printf("создано аккаунтов: %d\n", len(created))
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
func cliCleanup(args []string) int {
|
func cliCleanup(args []string) int {
|
||||||
flags := flag.NewFlagSet("cleanup", flag.ContinueOnError)
|
flags := flag.NewFlagSet("cleanup", flag.ContinueOnError)
|
||||||
auditDays := flags.Int("audit-days", -1, "сколько дней хранить журнал аудита (по умолчанию из конфигурации)")
|
auditDays := flags.Int("audit-days", -1, "сколько дней хранить журнал аудита (по умолчанию из конфигурации)")
|
||||||
|
|||||||
+1
-1
@@ -43,7 +43,7 @@ func main() {
|
|||||||
if len(os.Args) > 1 {
|
if len(os.Args) > 1 {
|
||||||
switch os.Args[1] {
|
switch os.Args[1] {
|
||||||
case "bootstrap-admin", "reset-password", "make-admin", "remove-admin", "totp-setup", "totp-reset",
|
case "bootstrap-admin", "reset-password", "make-admin", "remove-admin", "totp-setup", "totp-reset",
|
||||||
"cleanup", "reindex":
|
"create-user", "cleanup", "reindex":
|
||||||
os.Exit(runCLI(os.Args[1:]))
|
os.Exit(runCLI(os.Args[1:]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,577 @@
|
|||||||
|
// loadgen — генератор нагрузки для проверки целевых показателей AGENT.md 9.1,
|
||||||
|
// 11.4: держит заданное число WebSocket-клиентов Gateway, шлёт сообщения с
|
||||||
|
// нужной частотой и измеряет задержку доставки и ответы API.
|
||||||
|
//
|
||||||
|
// Аккаунты готовит команда обслуживания на сервере:
|
||||||
|
//
|
||||||
|
// glchat create-user --count 100 --prefix loadtest --password '...' \
|
||||||
|
// --out /tmp/loadtest-users.json --sessions
|
||||||
|
//
|
||||||
|
// Затем генератор запускается с машины-генератора (не с сервера, иначе он
|
||||||
|
// отбирает у сервера процессор и портит замеры):
|
||||||
|
//
|
||||||
|
// go run ./cmd/loadgen -base https://gl.mhspx.su \
|
||||||
|
// -creds /tmp/loadtest-users.json -invite <код> \
|
||||||
|
// -clients 100 -rate 20 -duration 60s
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
type credentials struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// envelope — кадр Gateway (AGENT.md 8.2).
|
||||||
|
type envelope struct {
|
||||||
|
Op int `json:"op"`
|
||||||
|
T string `json:"t,omitempty"`
|
||||||
|
D json.RawMessage `json:"d,omitempty"`
|
||||||
|
S int64 `json:"s,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
opDispatch = 0
|
||||||
|
opHeartbeat = 1
|
||||||
|
opIdentify = 2
|
||||||
|
opHello = 10
|
||||||
|
opHeartbeatAck = 11
|
||||||
|
)
|
||||||
|
|
||||||
|
// report — итог прогона: его печатают и складывают в JSON.
|
||||||
|
type report struct {
|
||||||
|
Clients int `json:"clients"`
|
||||||
|
Rate float64 `json:"messages_per_second"`
|
||||||
|
DurationSeconds float64 `json:"duration_seconds"`
|
||||||
|
Sent int64 `json:"messages_sent"`
|
||||||
|
SendErrors int64 `json:"send_errors"`
|
||||||
|
Received int64 `json:"events_received"`
|
||||||
|
Delivered int64 `json:"messages_delivered"`
|
||||||
|
DeliveryP50MS float64 `json:"delivery_p50_ms"`
|
||||||
|
DeliveryP95MS float64 `json:"delivery_p95_ms"`
|
||||||
|
DeliveryP99MS float64 `json:"delivery_p99_ms"`
|
||||||
|
DeliveryMaxMS float64 `json:"delivery_max_ms"`
|
||||||
|
APIP50MS float64 `json:"api_p50_ms"`
|
||||||
|
APIP95MS float64 `json:"api_p95_ms"`
|
||||||
|
APIMaxMS float64 `json:"api_max_ms"`
|
||||||
|
APIRequests int64 `json:"api_requests"`
|
||||||
|
APIErrors int64 `json:"api_errors"`
|
||||||
|
ReadyClients int `json:"ready_clients"`
|
||||||
|
Disconnects int64 `json:"disconnects"`
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
base := flag.String("base", "https://gl.mhspx.su", "адрес инстанса")
|
||||||
|
credsPath := flag.String("creds", "", "файл с учётными данными (glchat create-user --out --sessions)")
|
||||||
|
invite := flag.String("invite", "", "код приглашения в тестовый сервер (необязательно)")
|
||||||
|
channelID := flag.String("channel", "", "комната для сообщений (по умолчанию — первая текстовая сервера)")
|
||||||
|
guildID := flag.String("guild", "", "сервер, в котором работают клиенты")
|
||||||
|
clients := flag.Int("clients", 100, "число WS-клиентов")
|
||||||
|
rate := flag.Float64("rate", 20, "частота сообщений в секунду (все клиенты вместе)")
|
||||||
|
duration := flag.Duration("duration", 60*time.Second, "длительность нагрузки")
|
||||||
|
warmup := flag.Duration("warmup", 5*time.Second, "прогрев до начала замера")
|
||||||
|
apiEvery := flag.Duration("api-interval", 2*time.Second, "частота проверок API одним клиентом")
|
||||||
|
jsonOut := flag.String("json", "", "куда записать отчёт в JSON")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *credsPath == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "loadgen: укажите -creds")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
all, err := readCredentials(*credsPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "loadgen: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if *clients > len(all) {
|
||||||
|
fmt.Fprintf(os.Stderr, "loadgen: в файле %d аккаунтов, нужно %d\n", len(all), *clients)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := strings.TrimRight(*base, "/")
|
||||||
|
runner := &runner{
|
||||||
|
baseURL: baseURL,
|
||||||
|
clients: all[:*clients],
|
||||||
|
rate: *rate,
|
||||||
|
warmup: *warmup,
|
||||||
|
apiEvery: *apiEvery,
|
||||||
|
}
|
||||||
|
if *invite != "" {
|
||||||
|
if err := runner.joinGuild(context.Background(), *invite); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "loadgen: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
target, err := runner.resolveChannel(context.Background(), *guildID, *channelID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "loadgen: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
runner.guildID = target.guildID
|
||||||
|
runner.channelID = target.channelID
|
||||||
|
fmt.Printf("сервер %s, комната %s, клиентов %d, темп %.1f msg/s, длительность %s\n",
|
||||||
|
target.guildID, target.channelID, len(runner.clients), *rate, duration.String())
|
||||||
|
|
||||||
|
final := runner.run(context.Background(), *duration)
|
||||||
|
printReport(final)
|
||||||
|
if *jsonOut != "" {
|
||||||
|
if err := writeReport(*jsonOut, final); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "loadgen: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("отчёт: %s\n", *jsonOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readCredentials(path string) ([]credentials, error) {
|
||||||
|
// Файл создаёт оператор командой create-user: путь задаётся явно.
|
||||||
|
raw, err := os.ReadFile(path) //nolint:gosec
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var list []credentials
|
||||||
|
if err := json.Unmarshal(raw, &list); err != nil {
|
||||||
|
return nil, fmt.Errorf("разобрать %s: %w", path, err)
|
||||||
|
}
|
||||||
|
for _, item := range list {
|
||||||
|
if item.Token == "" {
|
||||||
|
return nil, errors.New("в файле нет сессий: создайте аккаунты с флагом --sessions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type channelTarget struct {
|
||||||
|
guildID string
|
||||||
|
channelID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type runner struct {
|
||||||
|
baseURL string
|
||||||
|
clients []credentials
|
||||||
|
rate float64
|
||||||
|
warmup time.Duration
|
||||||
|
apiEvery time.Duration
|
||||||
|
|
||||||
|
client *http.Client
|
||||||
|
guildID string
|
||||||
|
channelID string
|
||||||
|
|
||||||
|
sent atomic.Int64
|
||||||
|
sendErrors atomic.Int64
|
||||||
|
received atomic.Int64
|
||||||
|
delivered atomic.Int64
|
||||||
|
disconnect atomic.Int64
|
||||||
|
apiCount atomic.Int64
|
||||||
|
apiErrors atomic.Int64
|
||||||
|
ready atomic.Int64
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
sendTimes map[string]time.Time
|
||||||
|
latencies []float64
|
||||||
|
apiLatency []float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runner) httpClient() *http.Client {
|
||||||
|
if r.client == nil {
|
||||||
|
r.client = &http.Client{Timeout: 15 * time.Second}
|
||||||
|
}
|
||||||
|
return r.client
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinGuild вступает в тестовый сервер по приглашению от имени всех клиентов.
|
||||||
|
func (r *runner) joinGuild(ctx context.Context, code string) error {
|
||||||
|
failed := atomic.Int64{}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, item := range r.clients {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(item credentials) {
|
||||||
|
defer wg.Done()
|
||||||
|
response, err := r.do(ctx, item.Token, http.MethodPost, "/api/v1/invites/"+code, nil)
|
||||||
|
if err != nil {
|
||||||
|
failed.Add(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = response.Body.Close() }()
|
||||||
|
// 409 — уже участник: это не ошибка прогона.
|
||||||
|
if response.StatusCode >= 400 && response.StatusCode != http.StatusConflict {
|
||||||
|
failed.Add(1)
|
||||||
|
}
|
||||||
|
}(item)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if failed.Load() > 0 {
|
||||||
|
return fmt.Errorf("не все клиенты вошли в сервер: ошибок %d", failed.Load())
|
||||||
|
}
|
||||||
|
fmt.Printf("клиенты вошли в сервер по приглашению %s\n", code)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveChannel выбирает сервер и текстовую комнату для нагрузки.
|
||||||
|
func (r *runner) resolveChannel(ctx context.Context, guildID, channelID string) (channelTarget, error) {
|
||||||
|
if guildID == "" {
|
||||||
|
response, err := r.do(ctx, r.clients[0].Token, http.MethodGet, "/api/v1/users/@me/guilds", nil)
|
||||||
|
if err != nil {
|
||||||
|
return channelTarget{}, err
|
||||||
|
}
|
||||||
|
defer func() { _ = response.Body.Close() }()
|
||||||
|
var payload struct {
|
||||||
|
Guilds []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"guilds"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
||||||
|
return channelTarget{}, err
|
||||||
|
}
|
||||||
|
if len(payload.Guilds) == 0 {
|
||||||
|
return channelTarget{}, errors.New("у клиента нет серверов: передайте -guild и -invite")
|
||||||
|
}
|
||||||
|
guildID = payload.Guilds[0].ID
|
||||||
|
}
|
||||||
|
response, err := r.do(ctx, r.clients[0].Token, http.MethodGet, "/api/v1/guilds/"+guildID+"/channels", nil)
|
||||||
|
if err != nil {
|
||||||
|
return channelTarget{}, err
|
||||||
|
}
|
||||||
|
defer func() { _ = response.Body.Close() }()
|
||||||
|
var payload struct {
|
||||||
|
Channels []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
} `json:"channels"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
||||||
|
return channelTarget{}, err
|
||||||
|
}
|
||||||
|
if channelID == "" {
|
||||||
|
for _, channel := range payload.Channels {
|
||||||
|
if channel.Type == "text" {
|
||||||
|
channelID = channel.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if channelID == "" {
|
||||||
|
return channelTarget{}, errors.New("в сервере нет текстовых комнат")
|
||||||
|
}
|
||||||
|
return channelTarget{guildID: guildID, channelID: channelID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// run поднимает клиентов, греет их, затем держит нагрузку заданное время.
|
||||||
|
func (r *runner) run(ctx context.Context, duration time.Duration) report {
|
||||||
|
r.sendTimes = make(map[string]time.Time, 4096)
|
||||||
|
started := time.Now().UTC()
|
||||||
|
|
||||||
|
runCtx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for index := range r.clients {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(index int) {
|
||||||
|
defer wg.Done()
|
||||||
|
r.clientLoop(runCtx, index)
|
||||||
|
}(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ждём READY у большинства клиентов: иначе замер пойдёт по «холодным» сессиям.
|
||||||
|
deadline := time.Now().Add(30 * time.Second)
|
||||||
|
for time.Now().Before(deadline) && r.ready.Load() < int64(len(r.clients)*9/10) {
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
fmt.Printf("готовы к работе: %d из %d клиентов\n", r.ready.Load(), len(r.clients))
|
||||||
|
time.Sleep(r.warmup)
|
||||||
|
|
||||||
|
sendCtx, stopSending := context.WithTimeout(runCtx, duration)
|
||||||
|
defer stopSending()
|
||||||
|
r.sendLoop(sendCtx)
|
||||||
|
// Даём последним сообщениям дойти до клиентов.
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
cancel()
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
final := r.report(started, duration)
|
||||||
|
return final
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendLoop шлёт сообщения с заданной частотой, распределяя их по клиентам.
|
||||||
|
func (r *runner) sendLoop(ctx context.Context) {
|
||||||
|
interval := time.Duration(float64(time.Second) / r.rate)
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
random := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
sender := r.clients[random.Intn(len(r.clients))]
|
||||||
|
go r.sendMessage(ctx, sender)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendMessage отправляет сообщение и запоминает время отправки по id.
|
||||||
|
func (r *runner) sendMessage(ctx context.Context, sender credentials) {
|
||||||
|
body := map[string]any{"content": fmt.Sprintf("нагрузка %d", time.Now().UnixNano())}
|
||||||
|
response, err := r.do(ctx, sender.Token, http.MethodPost, "/api/v1/channels/"+r.channelID+"/messages", body)
|
||||||
|
if err != nil {
|
||||||
|
r.sendErrors.Add(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = response.Body.Close() }()
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
r.sendErrors.Add(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Message struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"message"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil || payload.Message.ID == "" {
|
||||||
|
r.sendErrors.Add(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
r.sendTimes[payload.Message.ID] = time.Now()
|
||||||
|
r.mu.Unlock()
|
||||||
|
r.sent.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientLoop держит WebSocket, отвечает на HELLO, шлёт heartbeat и меряет
|
||||||
|
// задержку доставки MESSAGE_CREATE, а также периодически дёргает API.
|
||||||
|
func (r *runner) clientLoop(ctx context.Context, index int) {
|
||||||
|
item := r.clients[index]
|
||||||
|
wsURL := strings.Replace(strings.Replace(r.baseURL, "https://", "wss://", 1), "http://", "ws://", 1) + "/gateway"
|
||||||
|
conn, handshake, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{
|
||||||
|
HTTPHeader: http.Header{"Authorization": []string{"Bearer " + item.Token}},
|
||||||
|
})
|
||||||
|
if handshake != nil && handshake.Body != nil {
|
||||||
|
_ = handshake.Body.Close()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
r.disconnect.Add(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.CloseNow() }()
|
||||||
|
conn.SetReadLimit(1 << 20)
|
||||||
|
|
||||||
|
heartbeat := time.NewTicker(30 * time.Second)
|
||||||
|
defer heartbeat.Stop()
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-heartbeat.C:
|
||||||
|
payload, _ := json.Marshal(envelope{Op: opHeartbeat})
|
||||||
|
if err := conn.Write(ctx, websocket.MessageText, payload); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if r.apiEvery > 0 {
|
||||||
|
go r.apiLoop(ctx, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
kind, data, err := conn.Read(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() == nil {
|
||||||
|
r.disconnect.Add(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if kind != websocket.MessageText {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var frame envelope
|
||||||
|
if err := json.Unmarshal(data, &frame); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch frame.Op {
|
||||||
|
case opHello:
|
||||||
|
// Токен передаём в IDENTIFY: cookie рукопожатия тоже подошла бы,
|
||||||
|
// но Bearer-токен — основной путь для внешних клиентов (AGENT.md 8.1).
|
||||||
|
identify, _ := json.Marshal(map[string]any{"token": item.Token})
|
||||||
|
payload, _ := json.Marshal(envelope{Op: opIdentify, D: identify})
|
||||||
|
if err := conn.Write(ctx, websocket.MessageText, payload); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case opHeartbeatAck:
|
||||||
|
// Ответ на heartbeat: разрыв соединения не нужен.
|
||||||
|
case opDispatch:
|
||||||
|
r.received.Add(1)
|
||||||
|
switch frame.T {
|
||||||
|
case "READY", "RESUMED":
|
||||||
|
r.ready.Add(1)
|
||||||
|
case "MESSAGE_CREATE":
|
||||||
|
r.noteMessage(frame.D)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteMessage фиксирует задержку доставки конкретного сообщения.
|
||||||
|
func (r *runner) noteMessage(payload json.RawMessage) {
|
||||||
|
var message struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payload, &message); err != nil || message.ID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
r.mu.Lock()
|
||||||
|
sentAt, ok := r.sendTimes[message.ID]
|
||||||
|
if ok {
|
||||||
|
delete(r.sendTimes, message.ID)
|
||||||
|
r.latencies = append(r.latencies, float64(now.Sub(sentAt).Microseconds())/1000)
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
if ok {
|
||||||
|
r.delivered.Add(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiLoop измеряет задержку обычных запросов API тем же клиентом.
|
||||||
|
func (r *runner) apiLoop(ctx context.Context, item credentials) {
|
||||||
|
ticker := time.NewTicker(r.apiEvery)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
started := time.Now()
|
||||||
|
response, err := r.do(ctx, item.Token, http.MethodGet, "/api/v1/users/@me", nil)
|
||||||
|
if err != nil {
|
||||||
|
r.apiErrors.Add(1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
r.apiErrors.Add(1)
|
||||||
|
_ = response.Body.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = response.Body.Close()
|
||||||
|
r.mu.Lock()
|
||||||
|
r.apiLatency = append(r.apiLatency, float64(time.Since(started).Microseconds())/1000)
|
||||||
|
r.mu.Unlock()
|
||||||
|
r.apiCount.Add(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runner) do(ctx context.Context, token, method, path string, body any) (*http.Response, error) {
|
||||||
|
var reader *strings.Reader
|
||||||
|
if body == nil {
|
||||||
|
reader = strings.NewReader("")
|
||||||
|
} else {
|
||||||
|
encoded, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reader = strings.NewReader(string(encoded))
|
||||||
|
}
|
||||||
|
request, err := http.NewRequestWithContext(ctx, method, r.baseURL+path, reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
request.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
if body != nil {
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
return r.httpClient().Do(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runner) report(started time.Time, duration time.Duration) report {
|
||||||
|
r.mu.Lock()
|
||||||
|
latencies := append([]float64(nil), r.latencies...)
|
||||||
|
apiLatency := append([]float64(nil), r.apiLatency...)
|
||||||
|
undelivered := len(r.sendTimes)
|
||||||
|
r.mu.Unlock()
|
||||||
|
sort.Float64s(latencies)
|
||||||
|
sort.Float64s(apiLatency)
|
||||||
|
|
||||||
|
final := report{
|
||||||
|
Clients: len(r.clients),
|
||||||
|
Rate: r.rate,
|
||||||
|
DurationSeconds: duration.Seconds(),
|
||||||
|
Sent: r.sent.Load(),
|
||||||
|
SendErrors: r.sendErrors.Load(),
|
||||||
|
Received: r.received.Load(),
|
||||||
|
Delivered: r.delivered.Load(),
|
||||||
|
APIRequests: r.apiCount.Load(),
|
||||||
|
APIErrors: r.apiErrors.Load(),
|
||||||
|
ReadyClients: int(r.ready.Load()),
|
||||||
|
Disconnects: r.disconnect.Load(),
|
||||||
|
StartedAt: started,
|
||||||
|
}
|
||||||
|
final.DeliveryP50MS = percentile(latencies, 0.50)
|
||||||
|
final.DeliveryP95MS = percentile(latencies, 0.95)
|
||||||
|
final.DeliveryP99MS = percentile(latencies, 0.99)
|
||||||
|
if len(latencies) > 0 {
|
||||||
|
final.DeliveryMaxMS = latencies[len(latencies)-1]
|
||||||
|
}
|
||||||
|
final.APIP50MS = percentile(apiLatency, 0.50)
|
||||||
|
final.APIP95MS = percentile(apiLatency, 0.95)
|
||||||
|
if len(apiLatency) > 0 {
|
||||||
|
final.APIMaxMS = apiLatency[len(apiLatency)-1]
|
||||||
|
}
|
||||||
|
if undelivered > 0 {
|
||||||
|
fmt.Printf("внимание: %d сообщений не дошли до клиентов за время прогона\n", undelivered)
|
||||||
|
}
|
||||||
|
return final
|
||||||
|
}
|
||||||
|
|
||||||
|
func percentile(sorted []float64, quantile float64) float64 {
|
||||||
|
if len(sorted) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
index := int(quantile * float64(len(sorted)-1))
|
||||||
|
return sorted[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
func printReport(final report) {
|
||||||
|
fmt.Printf("\n=== нагрузка: %d клиентов, %.0f msg/s, %.0f с ===\n",
|
||||||
|
final.Clients, final.Rate, final.DurationSeconds)
|
||||||
|
fmt.Printf("клиентов с READY: %d\n", final.ReadyClients)
|
||||||
|
fmt.Printf("отправлено сообщений: %d (ошибок %d)\n", final.Sent, final.SendErrors)
|
||||||
|
fmt.Printf("доставлено: %d\n", final.Delivered)
|
||||||
|
fmt.Printf("доставка, мс: p50 %.1f | p95 %.1f | p99 %.1f | max %.1f\n",
|
||||||
|
final.DeliveryP50MS, final.DeliveryP95MS, final.DeliveryP99MS, final.DeliveryMaxMS)
|
||||||
|
fmt.Printf("API /users/@me, мс: p50 %.1f | p95 %.1f | max %.1f (запросов %d, ошибок %d)\n",
|
||||||
|
final.APIP50MS, final.APIP95MS, final.APIMaxMS, final.APIRequests, final.APIErrors)
|
||||||
|
fmt.Printf("событий получено: %d, разрывов WS: %d\n", final.Received, final.Disconnects)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeReport(path string, final report) error {
|
||||||
|
encoded, err := json.MarshalIndent(final, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Отчёт не содержит секретов, но рядом лежат учётные данные: 600.
|
||||||
|
return os.WriteFile(path, encoded, 0o600)
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ glchat — управление инстансом
|
|||||||
glchat security-check аудит: SSH, fail2ban, порты, лимиты
|
glchat security-check аудит: SSH, fail2ban, порты, лимиты
|
||||||
glchat reindex пересобрать индекс поиска (FTS5)
|
glchat reindex пересобрать индекс поиска (FTS5)
|
||||||
glchat cleanup чистка: сессии, аудит, файлы без ссылок
|
glchat cleanup чистка: сессии, аудит, файлы без ссылок
|
||||||
|
glchat create-user создать аккаунты (--count/--prefix/--sessions)
|
||||||
glchat change-domain <domain> сменить домен
|
glchat change-domain <domain> сменить домен
|
||||||
glchat uninstall [--keep-data] удалить стек
|
glchat uninstall [--keep-data] удалить стек
|
||||||
glchat version версия CLI и приложения
|
glchat version версия CLI и приложения
|
||||||
@@ -175,6 +176,14 @@ cmd_cleanup() {
|
|||||||
${COMPOSE} exec -T app glchat cleanup "$@"
|
${COMPOSE} exec -T app glchat cleanup "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# cmd_create_user создаёт аккаунты командой обслуживания: регистрация может
|
||||||
|
# быть выключена, а для нагрузочного теста нужны десятки пользователей
|
||||||
|
# (AGENT.md 10.7, 11.4). Флаг --sessions сразу выдаёт сессии для генератора.
|
||||||
|
cmd_create_user() {
|
||||||
|
require_stack
|
||||||
|
${COMPOSE} exec -T app glchat create-user "$@"
|
||||||
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
local cmd="${1:-help}"
|
local cmd="${1:-help}"
|
||||||
shift || true
|
shift || true
|
||||||
@@ -196,6 +205,7 @@ main() {
|
|||||||
reindex) cmd_reindex "$@" ;;
|
reindex) cmd_reindex "$@" ;;
|
||||||
verify-backup) cmd_verify_backup "$@" ;;
|
verify-backup) cmd_verify_backup "$@" ;;
|
||||||
cleanup) cmd_cleanup "$@" ;;
|
cleanup) cmd_cleanup "$@" ;;
|
||||||
|
create-user) cmd_create_user "$@" ;;
|
||||||
change-domain) cmd_change_domain "$@" ;;
|
change-domain) cmd_change_domain "$@" ;;
|
||||||
uninstall) cmd_uninstall "$@" ;;
|
uninstall) cmd_uninstall "$@" ;;
|
||||||
version) cmd_version "$@" ;;
|
version) cmd_version "$@" ;;
|
||||||
|
|||||||
+4
-1
@@ -154,7 +154,10 @@ install.sh — установка glchat (AGENT.md 10).
|
|||||||
--turn-cert <файл> сертификат для TURN/TLS (нужен при включённом TURN)
|
--turn-cert <файл> сертификат для TURN/TLS (нужен при включённом TURN)
|
||||||
--turn-key <файл> приватный ключ сертификата TURN/TLS
|
--turn-key <файл> приватный ключ сертификата TURN/TLS
|
||||||
--turn-relay-host <host> внешний TURN-релей с публичным IP (например, VPS):
|
--turn-relay-host <host> внешний TURN-релей с публичным IP (например, VPS):
|
||||||
клиенты получают его в списке turn_servers
|
клиенты получают его в списке turn_servers.
|
||||||
|
На релее поднимите user-quota: одному участнику
|
||||||
|
нужно два allocation, а TURN-пользователь у
|
||||||
|
инстанса один (см. docs/DECISIONS.md)
|
||||||
--turn-relay-port <port> UDP-порт внешнего релея (по умолчанию 3478)
|
--turn-relay-port <port> UDP-порт внешнего релея (по умолчанию 3478)
|
||||||
--turn-relay-tls-port <port> TLS-порт внешнего релея (по умолчанию 5349)
|
--turn-relay-tls-port <port> TLS-порт внешнего релея (по умолчанию 5349)
|
||||||
--turn-relay-tls-host <host> имя хоста для TURN/TLS (по умолчанию turn.<домен>):
|
--turn-relay-tls-host <host> имя хоста для TURN/TLS (по умолчанию turn.<домен>):
|
||||||
|
|||||||
@@ -213,6 +213,61 @@ func (s *Service) Register(ctx context.Context, in RegisterInput) (*store.User,
|
|||||||
return user, token, session, nil
|
return user, token, session, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateUserByOperator создаёт аккаунт по команде обслуживания (CLI
|
||||||
|
// create-user): регистрация на инстансе может быть выключена, а сессия
|
||||||
|
// создателю не нужна — он действует от имени администратора. Используется в
|
||||||
|
// том числе для подготовки нагрузки (AGENT.md 11.4).
|
||||||
|
func (s *Service) CreateUserByOperator(ctx context.Context, in RegisterInput) (*store.User, error) {
|
||||||
|
username := strings.TrimSpace(in.Username)
|
||||||
|
if err := crypto.ValidateUsername(username); err != nil {
|
||||||
|
return nil, ErrInvalidUsername
|
||||||
|
}
|
||||||
|
if err := s.policy.validate(in.Password); err != nil {
|
||||||
|
return nil, ErrWeakPassword
|
||||||
|
}
|
||||||
|
email := crypto.NormalizeEmail(in.Email)
|
||||||
|
if err := validateEmail(email); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
passwordHash, err := s.hasher.Hash(in.Password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hash password: %w", err)
|
||||||
|
}
|
||||||
|
userID := s.store.NextID()
|
||||||
|
emailEncrypted, err := s.encryptEmail(userID, email)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
user, err := s.store.CreateUser(ctx, store.CreateUserParams{
|
||||||
|
ID: userID,
|
||||||
|
Username: username,
|
||||||
|
DisplayName: defaultDisplayName(in.DisplayName, username),
|
||||||
|
EmailEnc: emailEncrypted,
|
||||||
|
EmailIndex: s.masterKey.BlindIndex(email),
|
||||||
|
PasswordHash: passwordHash,
|
||||||
|
Locale: in.Locale,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrConflict) {
|
||||||
|
if existing, lookupErr := s.store.GetUserByUsername(ctx, username); lookupErr == nil && existing != nil {
|
||||||
|
return nil, ErrUsernameTaken
|
||||||
|
}
|
||||||
|
return nil, ErrEmailTaken
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("create user: %w", err)
|
||||||
|
}
|
||||||
|
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "created_by_operator", "", "", "")
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueSessionForOperator выдаёт сессию по команде обслуживания: нужна для
|
||||||
|
// нагрузочного теста (AGENT.md 11.4), где сотни клиентов не могут пройти вход
|
||||||
|
// через /auth/login — лимит попыток по IP рассчитан на защиту от перебора.
|
||||||
|
func (s *Service) IssueSessionForOperator(ctx context.Context, userID uint64, userAgent string) (string, error) {
|
||||||
|
token, _, err := s.createSession(ctx, userID, userAgent, "")
|
||||||
|
return token, err
|
||||||
|
}
|
||||||
|
|
||||||
// UserByEmail ищет аккаунт по email через blind index (без расшифровки БД).
|
// UserByEmail ищет аккаунт по email через blind index (без расшифровки БД).
|
||||||
func (s *Service) UserByEmail(ctx context.Context, email string) (*store.User, error) {
|
func (s *Service) UserByEmail(ctx context.Context, email string) (*store.User, error) {
|
||||||
return s.userByEmail(ctx, email)
|
return s.userByEmail(ctx, email)
|
||||||
|
|||||||
@@ -459,3 +459,48 @@ func totpCode(t *testing.T, secret string) string {
|
|||||||
}
|
}
|
||||||
return code
|
return code
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCreateUserByOperator проверяет команду обслуживания: аккаунт создаётся
|
||||||
|
// даже при выключенной регистрации, дубликаты и слабые пароли отклоняются
|
||||||
|
// (AGENT.md 10.7, 11.4 — подготовка нагрузки).
|
||||||
|
func TestCreateUserByOperator(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
service, st := newService(t)
|
||||||
|
|
||||||
|
if err := st.SetInstanceSetting(ctx, "registration_enabled", "false"); err != nil {
|
||||||
|
t.Fatalf("SetInstanceSetting: %v", err)
|
||||||
|
}
|
||||||
|
// Публичная регистрация закрыта.
|
||||||
|
if _, _, _, err := service.Register(ctx, auth.RegisterInput{
|
||||||
|
Username: "operator_blocked", Email: "blocked@example.com", Password: "correct-horse-battery",
|
||||||
|
}); !errors.Is(err, auth.ErrRegistrationOff) {
|
||||||
|
t.Fatalf("Register with registration off = %v, want ErrRegistrationOff", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := service.CreateUserByOperator(ctx, auth.RegisterInput{
|
||||||
|
Username: "loadtest1", Email: "loadtest1@loadtest.local", Password: "correct-horse-battery",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUserByOperator: %v", err)
|
||||||
|
}
|
||||||
|
if user.Username != "loadtest1" || user.IsInstanceAdmin {
|
||||||
|
t.Fatalf("created user = %+v", user)
|
||||||
|
}
|
||||||
|
// Вход новым аккаунтом работает без выдачи сессии создателю.
|
||||||
|
if _, _, _, err := service.Login(ctx, auth.LoginInput{
|
||||||
|
Email: "loadtest1@loadtest.local", Password: "correct-horse-battery",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Login as created user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := service.CreateUserByOperator(ctx, auth.RegisterInput{
|
||||||
|
Username: "loadtest1", Email: "other@loadtest.local", Password: "correct-horse-battery",
|
||||||
|
}); !errors.Is(err, auth.ErrUsernameTaken) {
|
||||||
|
t.Fatalf("duplicate username = %v, want ErrUsernameTaken", err)
|
||||||
|
}
|
||||||
|
if _, err := service.CreateUserByOperator(ctx, auth.RegisterInput{
|
||||||
|
Username: "loadtest2", Email: "loadtest2@loadtest.local", Password: "short",
|
||||||
|
}); !errors.Is(err, auth.ErrWeakPassword) {
|
||||||
|
t.Fatalf("weak password = %v, want ErrWeakPassword", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -391,6 +391,26 @@ func (s *Service) SendToUser(userID uint64, event string, payload any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InvalidateUser закрывает все соединения пользователя: клиент получает
|
||||||
|
// INVALID_SESSION и уходит на экран входа. Нужно там, где сессии отозваны
|
||||||
|
// (logout-all, смена пароля, действия администратора) — иначе второе
|
||||||
|
// устройство остаётся «в приложении» до следующего запроса (AGENT.md 11.6).
|
||||||
|
func (s *Service) InvalidateUser(userID uint64, reason string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
targets := make([]*clientSession, 0, 2)
|
||||||
|
for _, session := range s.sessions {
|
||||||
|
if session.userID == userID {
|
||||||
|
targets = append(targets, session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
for _, session := range targets {
|
||||||
|
// Сначала кадр (writeSync дожидается записи), потом закрытие.
|
||||||
|
s.sendInvalidSession(session, reason)
|
||||||
|
session.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func frame(op int, payload any) ([]byte, error) {
|
func frame(op int, payload any) ([]byte, error) {
|
||||||
var encoded json.RawMessage
|
var encoded json.RawMessage
|
||||||
if payload != nil {
|
if payload != nil {
|
||||||
|
|||||||
@@ -404,3 +404,42 @@ func TestResumeReplaysMissedEvents(t *testing.T) {
|
|||||||
t.Fatalf("stale resume = %q, want full READY", full.T)
|
t.Fatalf("stale resume = %q, want full READY", full.T)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestInvalidateUserClosesConnections проверяет отзыв сессий: после
|
||||||
|
// logout-all, смены пароля или действий администратора соединения клиента
|
||||||
|
// должны закрыться с INVALID_SESSION, а не ждать следующего запроса
|
||||||
|
// (AGENT.md 11.6). Отдельно проверяется, что чужого пользователя это не задевает.
|
||||||
|
func TestInvalidateUserClosesConnections(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
token := registerUser(t, f, "invalidate_user", "invalidate@example.com")
|
||||||
|
otherToken := registerUser(t, f, "invalidate_other", "invalidate-other@example.com")
|
||||||
|
|
||||||
|
conn, _ := f.dial(t)
|
||||||
|
send(t, conn, gateway.OpIdentify, map[string]any{"token": token})
|
||||||
|
if ready := readEnvelope(t, conn); ready.T != "READY" {
|
||||||
|
t.Fatalf("expected READY, got %q", ready.T)
|
||||||
|
}
|
||||||
|
|
||||||
|
otherConn, _ := f.dial(t)
|
||||||
|
send(t, otherConn, gateway.OpIdentify, map[string]any{"token": otherToken})
|
||||||
|
if ready := readEnvelope(t, otherConn); ready.T != "READY" {
|
||||||
|
t.Fatalf("expected READY for the other client, got %q", ready.T)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := f.auth.UserByEmail(context.Background(), "invalidate@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
f.service.InvalidateUser(user.ID, "logout_all")
|
||||||
|
|
||||||
|
invalid := readEnvelope(t, conn)
|
||||||
|
if invalid.Op != gateway.OpInvalidSess {
|
||||||
|
t.Fatalf("op = %d, want INVALID_SESSION (%d)", invalid.Op, gateway.OpInvalidSess)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Второй пользователь продолжает работать: heartbeat отвечает.
|
||||||
|
send(t, otherConn, gateway.OpHeartbeat, nil)
|
||||||
|
if ack := readEnvelope(t, otherConn); ack.Op != gateway.OpHeartbeatAck {
|
||||||
|
t.Fatalf("other client op = %d, want HEARTBEAT_ACK", ack.Op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,7 +60,10 @@ func channelOverwritePayloads(overrides []store.ChannelOverride) []channelOverwr
|
|||||||
}
|
}
|
||||||
|
|
||||||
type rolePayload struct {
|
type rolePayload struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
// GuildID нужен событиям ROLE_CREATE/ROLE_UPDATE: в REST он избыточен
|
||||||
|
// (сервер и так в пути), а в кадре шлюза без него роль некуда отнести.
|
||||||
|
GuildID string `json:"guild_id,omitempty"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Color int64 `json:"color"`
|
Color int64 `json:"color"`
|
||||||
Position int `json:"position"`
|
Position int `json:"position"`
|
||||||
@@ -110,6 +113,14 @@ type guildDetailPayload struct {
|
|||||||
Channels []channelPayload `json:"channels,omitempty"`
|
Channels []channelPayload `json:"channels,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// channelEventPayload собирает тело комнаты для события шлюза: права в нём
|
||||||
|
// не вычислены (они персональные), поэтому признаки can_* не выставляются —
|
||||||
|
// клиент по этому событию перечитывает список комнат, а не прячет комнату по
|
||||||
|
// нулевому can_view.
|
||||||
|
func channelEventPayload(channel store.Channel) channelPayload {
|
||||||
|
return channelFromStore(channel, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
func channelFromStore(channel store.Channel, resolved *permissions.Resolved, overrides []store.ChannelOverride) channelPayload {
|
func channelFromStore(channel store.Channel, resolved *permissions.Resolved, overrides []store.ChannelOverride) channelPayload {
|
||||||
payload := channelPayload{
|
payload := channelPayload{
|
||||||
ID: formatSnowflake(channel.ID),
|
ID: formatSnowflake(channel.ID),
|
||||||
@@ -141,6 +152,7 @@ func channelFromStore(channel store.Channel, resolved *permissions.Resolved, ove
|
|||||||
func roleFromStore(role store.Role) rolePayload {
|
func roleFromStore(role store.Role) rolePayload {
|
||||||
payload := rolePayload{
|
payload := rolePayload{
|
||||||
ID: formatSnowflake(role.ID),
|
ID: formatSnowflake(role.ID),
|
||||||
|
GuildID: formatSnowflake(role.GuildID),
|
||||||
Name: role.Name,
|
Name: role.Name,
|
||||||
Color: role.Color,
|
Color: role.Color,
|
||||||
Position: role.Position,
|
Position: role.Position,
|
||||||
@@ -585,7 +597,12 @@ func (s *Server) registerChannelRoutes(api huma.API) {
|
|||||||
"type": string(channel.Type),
|
"type": string(channel.Type),
|
||||||
})
|
})
|
||||||
s.invalidateGuild(guildID)
|
s.invalidateGuild(guildID)
|
||||||
s.dispatchGuildEvent(guildID, "CHANNEL_CREATE", channelFromStore(*channel, nil, nil))
|
// Видимость комнаты считается персонально, поэтому вместо тела комнаты
|
||||||
|
// (в нём can_view был бы нулевым) просим клиентов перечитать список.
|
||||||
|
s.dispatchGuildEvent(guildID, "CHANNEL_CREATE", channelEventPayload(*channel))
|
||||||
|
s.dispatchGuildEvent(guildID, "GUILD_CHANNELS_SYNC", map[string]any{
|
||||||
|
"guild_id": formatSnowflake(guildID),
|
||||||
|
})
|
||||||
// Права на новую комнату считаем для автора запроса.
|
// Права на новую комнату считаем для автора запроса.
|
||||||
resolved, err := s.perms.Channel(ctx, guildID, channel.ID, user.ID, user.IsInstanceAdmin)
|
resolved, err := s.perms.Channel(ctx, guildID, channel.ID, user.ID, user.IsInstanceAdmin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -642,7 +659,10 @@ func (s *Server) registerChannelRoutes(api huma.API) {
|
|||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
s.invalidateGuild(guildID)
|
s.invalidateGuild(guildID)
|
||||||
s.dispatchGuildEvent(guildID, "CHANNEL_UPDATE", channelFromStore(*channel, nil, nil))
|
s.dispatchGuildEvent(guildID, "CHANNEL_UPDATE", channelEventPayload(*channel))
|
||||||
|
s.dispatchGuildEvent(guildID, "GUILD_CHANNELS_SYNC", map[string]any{
|
||||||
|
"guild_id": formatSnowflake(guildID),
|
||||||
|
})
|
||||||
resolved, err := s.perms.Channel(ctx, guildID, channelID, user.ID, user.IsInstanceAdmin)
|
resolved, err := s.perms.Channel(ctx, guildID, channelID, user.ID, user.IsInstanceAdmin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
@@ -822,6 +842,11 @@ func (s *Server) registerChannelRoutes(api huma.API) {
|
|||||||
"channel_id": formatSnowflake(channelID),
|
"channel_id": formatSnowflake(channelID),
|
||||||
"guild_id": formatSnowflake(guildID),
|
"guild_id": formatSnowflake(guildID),
|
||||||
})
|
})
|
||||||
|
// Удалили комнату — у части участников могли остаться права на неё,
|
||||||
|
// поэтому список перечитывают все (AGENT.md 11.6).
|
||||||
|
s.dispatchGuildEvent(guildID, "GUILD_CHANNELS_SYNC", map[string]any{
|
||||||
|
"guild_id": formatSnowflake(guildID),
|
||||||
|
})
|
||||||
return newOKOutput(), nil
|
return newOKOutput(), nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -488,6 +488,9 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
|
|||||||
if err := s.auth.SetPassword(ctx, target.ID, password); err != nil {
|
if err := s.auth.SetPassword(ctx, target.ID, password); err != nil {
|
||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
|
if s.gateway != nil {
|
||||||
|
s.gateway.InvalidateUser(target.ID, "password_reset")
|
||||||
|
}
|
||||||
s.recordAudit(ctx, admin, 0, "instance.user_password_reset", "user", &target.ID, "")
|
s.recordAudit(ctx, admin, 0, "instance.user_password_reset", "user", &target.ID, "")
|
||||||
output := &adminPasswordOutput{}
|
output := &adminPasswordOutput{}
|
||||||
output.Body.UserID = formatSnowflake(target.ID)
|
output.Body.UserID = formatSnowflake(target.ID)
|
||||||
@@ -521,6 +524,9 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
|
|||||||
if _, err := s.store.RevokeUserSessions(ctx, targetID); err != nil {
|
if _, err := s.store.RevokeUserSessions(ctx, targetID); err != nil {
|
||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
|
if s.gateway != nil {
|
||||||
|
s.gateway.InvalidateUser(targetID, "logout_all")
|
||||||
|
}
|
||||||
s.recordAudit(ctx, admin, 0, "instance.user_logout", "user", &targetID, "")
|
s.recordAudit(ctx, admin, 0, "instance.user_logout", "user", &targetID, "")
|
||||||
return newOKOutput(), nil
|
return newOKOutput(), nil
|
||||||
})
|
})
|
||||||
@@ -575,7 +581,7 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
|
|||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
if s.gateway != nil {
|
if s.gateway != nil {
|
||||||
s.gateway.SendToUser(target.ID, "SESSION_INVALIDATED", map[string]any{"reason": "user_banned"})
|
s.gateway.InvalidateUser(target.ID, "user_banned")
|
||||||
}
|
}
|
||||||
s.recordAudit(ctx, admin, 0, "instance.user_ban", "user", &target.ID, reason)
|
s.recordAudit(ctx, admin, 0, "instance.user_ban", "user", &target.ID, reason)
|
||||||
updated, err := s.store.GetUser(ctx, target.ID)
|
updated, err := s.store.GetUser(ctx, target.ID)
|
||||||
@@ -663,7 +669,7 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
|
|||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
if s.gateway != nil {
|
if s.gateway != nil {
|
||||||
s.gateway.SendToUser(targetID, "SESSION_INVALIDATED", map[string]any{"reason": "user_deleted"})
|
s.gateway.InvalidateUser(targetID, "user_deleted")
|
||||||
}
|
}
|
||||||
s.recordAudit(ctx, admin, 0, "instance.user_delete", "user", &targetID, "")
|
s.recordAudit(ctx, admin, 0, "instance.user_delete", "user", &targetID, "")
|
||||||
return newOKOutput(), nil
|
return newOKOutput(), nil
|
||||||
|
|||||||
@@ -270,6 +270,10 @@ func (s *Server) registerUserRoutes(api huma.API) {
|
|||||||
if err := s.auth.ChangePassword(ctx, user.ID, session.ID, input.Body.CurrentPassword, input.Body.NewPassword); err != nil {
|
if err := s.auth.ChangePassword(ctx, user.ID, session.ID, input.Body.CurrentPassword, input.Body.NewPassword); err != nil {
|
||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
|
// Смена пароля отзывает остальные сессии: соединения закрываем сразу.
|
||||||
|
if s.gateway != nil {
|
||||||
|
s.gateway.InvalidateUser(user.ID, "password_changed")
|
||||||
|
}
|
||||||
return newOKOutput(), nil
|
return newOKOutput(), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -247,6 +247,10 @@ func (s *Server) handleLogoutAll(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeAPIError(w, err)
|
writeAPIError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Другие устройства должны узнать об отзыве сессий сразу (AGENT.md 11.6).
|
||||||
|
if s.gateway != nil {
|
||||||
|
s.gateway.InvalidateUser(user.ID, "logout_all")
|
||||||
|
}
|
||||||
http.SetCookie(w, s.clearSessionCookie())
|
http.SetCookie(w, s.clearSessionCookie())
|
||||||
writeJSON(w, map[string]any{"ok": true})
|
writeJSON(w, map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { expect, test, type Browser, type BrowserContext, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Голосовая нагрузка (AGENT.md 9.1, 11.4): десять участников в одной комнате
|
||||||
|
* и внешний 1080p60-поток. Сессии берутся из файла, который готовит команда
|
||||||
|
* обслуживания (`glchat create-user --sessions`), — вход через форму упёрся бы
|
||||||
|
* в лимит попыток по IP, рассчитанный на защиту от перебора.
|
||||||
|
*
|
||||||
|
* GLCHAT_E2E_VOICE_TEN=1 GLCHAT_URL=https://gl.mhspx.su \
|
||||||
|
* GLCHAT_LOADTEST_CREDS=../../build/loadtest-users.json \
|
||||||
|
* GLCHAT_LOADTEST_FIXTURE=../../build/loadtest-fixture.json \
|
||||||
|
* npx playwright test e2e/voice-ten.spec.ts
|
||||||
|
*/
|
||||||
|
const enabled = process.env.GLCHAT_E2E_VOICE_TEN === '1';
|
||||||
|
const CREDS_PATH = process.env.GLCHAT_LOADTEST_CREDS ?? '../../build/loadtest-users.json';
|
||||||
|
const FIXTURE_PATH = process.env.GLCHAT_LOADTEST_FIXTURE ?? '../../build/loadtest-fixture.json';
|
||||||
|
const PARTICIPANTS = Number(process.env.GLCHAT_VOICE_PARTICIPANTS ?? '10');
|
||||||
|
const EXPECT_SCREENSHARE = process.env.GLCHAT_EXPECT_SCREENSHARE === '1';
|
||||||
|
// Идентификатор внешнего издателя: реальный участник сервера (см. build/loadtest-voice.sh).
|
||||||
|
const SCREENSHARE_IDENTITY = process.env.GLCHAT_SCREENSHARE_IDENTITY ?? 'screenshare-1080p60';
|
||||||
|
|
||||||
|
interface Account {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Fixture {
|
||||||
|
guildId: string;
|
||||||
|
voiceChannelId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAccounts(): Account[] {
|
||||||
|
const raw = JSON.parse(readFileSync(CREDS_PATH, 'utf8')) as Account[];
|
||||||
|
return raw.filter((item) => item.token !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFixture(): Fixture {
|
||||||
|
return JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')) as Fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** signInWithToken открывает контекст с готовой сессией: форма входа не нужна. */
|
||||||
|
async function signInWithToken(
|
||||||
|
browser: Browser,
|
||||||
|
account: Account,
|
||||||
|
baseURL: string,
|
||||||
|
): Promise<{ context: BrowserContext; page: Page }> {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
permissions: ['microphone', 'camera'],
|
||||||
|
baseURL,
|
||||||
|
});
|
||||||
|
await context.addCookies([
|
||||||
|
// Cookie сессии — __Host-session (internal/gateway/session.go).
|
||||||
|
{ name: '__Host-session', value: account.token, url: baseURL, httpOnly: true, secure: true },
|
||||||
|
]);
|
||||||
|
// Аккаунты создаёт команда обслуживания, онбординг у них не пройден: без
|
||||||
|
// этого AuthGuard уводит браузер на /onboarding и комнаты не видно.
|
||||||
|
const headers = { Authorization: `Bearer ${account.token}` };
|
||||||
|
const me = await context.request.get('/api/v1/users/@me', { headers });
|
||||||
|
if (me.ok()) {
|
||||||
|
const payload = (await me.json()) as {
|
||||||
|
user: { onboarding_completed: boolean; display_name?: string | null; username: string };
|
||||||
|
};
|
||||||
|
if (!payload.user.onboarding_completed) {
|
||||||
|
await context.request.post('/api/v1/users/@me/onboarding/complete', {
|
||||||
|
headers,
|
||||||
|
data: { display_name: payload.user.display_name ?? payload.user.username },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto('/app');
|
||||||
|
await expect(page).toHaveURL(/\/app/u, { timeout: 30_000 });
|
||||||
|
return { context, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** joinVoice нажимает «Войти» в голосовой комнате и ждёт подключения. */
|
||||||
|
async function joinVoice(page: Page): Promise<void> {
|
||||||
|
const button = page.getByTestId('voice-join');
|
||||||
|
await button.waitFor({ state: 'visible', timeout: 30_000 });
|
||||||
|
await button.click({ force: true, timeout: 10_000 }).catch(() => undefined);
|
||||||
|
await expect(button).toBeHidden({ timeout: 60_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** inboundAudioBytes суммирует полученные аудио-байты по всем соединениям. */
|
||||||
|
async function inboundAudioBytes(page: Page): Promise<number> {
|
||||||
|
return page.evaluate(async () => {
|
||||||
|
const globalWindow = window as unknown as { __pcs?: RTCPeerConnection[] };
|
||||||
|
let total = 0;
|
||||||
|
for (const pc of globalWindow.__pcs ?? []) {
|
||||||
|
const stats = await pc.getStats();
|
||||||
|
stats.forEach((raw) => {
|
||||||
|
const report = raw as Record<string, unknown>;
|
||||||
|
if (report['type'] === 'inbound-rtp' && report['kind'] === 'audio') {
|
||||||
|
total += Number(report['bytesReceived'] ?? 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** inboundVideoStats отдаёт лучший входящий видеопоток: размер и кадры в секунду. */
|
||||||
|
async function inboundVideoStats(
|
||||||
|
page: Page,
|
||||||
|
): Promise<{ width: number; height: number; fps: number }> {
|
||||||
|
return page.evaluate(async () => {
|
||||||
|
const globalWindow = window as unknown as { __pcs?: RTCPeerConnection[] };
|
||||||
|
let best = { width: 0, height: 0, fps: 0, decoded: 0, bytes: 0 };
|
||||||
|
for (const pc of globalWindow.__pcs ?? []) {
|
||||||
|
const stats = await pc.getStats();
|
||||||
|
stats.forEach((raw) => {
|
||||||
|
const report = raw as Record<string, unknown>;
|
||||||
|
if (report['type'] !== 'inbound-rtp' || report['kind'] !== 'video') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const width = Number(report['frameWidth'] ?? 0);
|
||||||
|
const height = Number(report['frameHeight'] ?? 0);
|
||||||
|
const fps = Number(report['framesPerSecond'] ?? 0);
|
||||||
|
const decoded = Number(report['framesDecoded'] ?? 0);
|
||||||
|
const bytes = Number(report['bytesReceived'] ?? 0);
|
||||||
|
if (width * height > best.width * best.height) {
|
||||||
|
best = { width, height, fps, decoded, bytes };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** instrumentPeerConnections собирает соединения страницы для статистики. */
|
||||||
|
async function instrumentPeerConnections(context: BrowserContext): Promise<void> {
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
const globalWindow = window as unknown as { __pcs?: RTCPeerConnection[] };
|
||||||
|
globalWindow.__pcs = [];
|
||||||
|
const Original = window.RTCPeerConnection;
|
||||||
|
window.RTCPeerConnection = function Patched(
|
||||||
|
...args: ConstructorParameters<typeof RTCPeerConnection>
|
||||||
|
) {
|
||||||
|
const pc = new Original(...args);
|
||||||
|
globalWindow.__pcs?.push(pc);
|
||||||
|
return pc;
|
||||||
|
} as unknown as typeof RTCPeerConnection;
|
||||||
|
window.RTCPeerConnection.prototype = Original.prototype;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('голосовая нагрузка: десять участников', () => {
|
||||||
|
test.skip(!enabled, 'нужен развёрнутый инстанс: GLCHAT_E2E_VOICE_TEN=1');
|
||||||
|
test('десять клиентов видят друг друга, аудио и 1080p60 доходят', async ({ browser }) => {
|
||||||
|
test.setTimeout(300_000);
|
||||||
|
const baseURL = process.env.GLCHAT_URL ?? 'https://gl.mhspx.su';
|
||||||
|
const accounts = readAccounts().slice(0, PARTICIPANTS);
|
||||||
|
const fixture = readFixture();
|
||||||
|
expect(accounts.length, 'нужны аккаунты с сессиями').toBeGreaterThanOrEqual(PARTICIPANTS);
|
||||||
|
|
||||||
|
const sessions: { account: Account; context: BrowserContext; page: Page }[] = [];
|
||||||
|
try {
|
||||||
|
for (const account of accounts) {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
permissions: ['microphone', 'camera'],
|
||||||
|
baseURL,
|
||||||
|
});
|
||||||
|
await instrumentPeerConnections(context);
|
||||||
|
await context.addCookies([
|
||||||
|
{
|
||||||
|
name: '__Host-session',
|
||||||
|
value: account.token,
|
||||||
|
url: baseURL,
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(`/app/${fixture.guildId}/${fixture.voiceChannelId}`);
|
||||||
|
await expect(page).toHaveURL(/\/app/u, { timeout: 30_000 });
|
||||||
|
sessions.push({ account, context, page });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Все десять входят в комнату.
|
||||||
|
for (const session of sessions) {
|
||||||
|
await joinVoice(session.page);
|
||||||
|
}
|
||||||
|
console.log(`участников в комнате: ${sessions.length}`);
|
||||||
|
|
||||||
|
// Каждый видит плитки всех участников: это состояние LiveKit, а не список сервера.
|
||||||
|
for (const session of sessions.slice(0, 2)) {
|
||||||
|
for (const other of sessions) {
|
||||||
|
await expect(session.page.getByTestId(`voice-tile-${other.account.id}`)).toBeVisible({
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Аудио реально доходит: у двух клиентов считаем входящие аудио-байты.
|
||||||
|
for (const session of sessions.slice(0, 2)) {
|
||||||
|
await expect
|
||||||
|
.poll(async () => inboundAudioBytes(session.page), { timeout: 90_000, intervals: [1000] })
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EXPECT_SCREENSHARE) {
|
||||||
|
// Внешний 1080p60-поток (lk room join с IVF) должен дойти до клиента.
|
||||||
|
// Adaptive stream ставит видео на паузу, пока плитка не на экране, а
|
||||||
|
// плитки внешнего издателя в интерфейсе может не быть вовсе (он не
|
||||||
|
// проходит через приложение). Поэтому здесь проверяется, что слой
|
||||||
|
// 1080p60 доехал до клиента (размер кадра и байты), а реальное
|
||||||
|
// декодирование 60 кадров в секунду меряет независимый подписчик —
|
||||||
|
// build/verify-1080p60-browser.mjs (adaptive stream выключен).
|
||||||
|
await expect
|
||||||
|
.poll(async () => inboundVideoStats(sessions[0]!.page), {
|
||||||
|
timeout: 90_000,
|
||||||
|
intervals: [2000],
|
||||||
|
})
|
||||||
|
.toMatchObject({ width: 1920, height: 1080 });
|
||||||
|
const stats = await inboundVideoStats(sessions[0]!.page);
|
||||||
|
console.log('входящее видео:', JSON.stringify(stats));
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await inboundVideoStats(sessions[0]!.page)).bytes, {
|
||||||
|
timeout: 60_000,
|
||||||
|
intervals: [2000],
|
||||||
|
})
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
for (const session of sessions) {
|
||||||
|
await session.context.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,8 @@ import reactRefresh from 'eslint-plugin-react-refresh';
|
|||||||
import tseslint from 'typescript-eslint';
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ['dist', 'coverage', 'node_modules'] },
|
// Артефакты Playwright (test-results*, отчёты) не линтуем: их пишет прогон.
|
||||||
|
{ ignores: ['dist', 'coverage', 'node_modules', 'test-results*', 'playwright-report'] },
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked],
|
extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked],
|
||||||
|
|||||||
@@ -215,7 +215,9 @@ export function parseMemberRemove(value: unknown): MemberRemoveEvent | null {
|
|||||||
export function parseRoleEvent(value: unknown): RoleEvent | null {
|
export function parseRoleEvent(value: unknown): RoleEvent | null {
|
||||||
const record = asRecord(value);
|
const record = asRecord(value);
|
||||||
const guildId = asString(record?.['guild_id']);
|
const guildId = asString(record?.['guild_id']);
|
||||||
const roleId = asString(record?.['role_id']);
|
// Сервер шлёт тело роли (поле id), старый формат — ссылку role_id:
|
||||||
|
// принимаем оба, иначе событие роли отбрасывалось и список не обновлялся.
|
||||||
|
const roleId = asString(record?.['role_id']) ?? asString(record?.['id']);
|
||||||
if (record === null || guildId === undefined || roleId === undefined) {
|
if (record === null || guildId === undefined || roleId === undefined) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ interface GatewayState {
|
|||||||
invalidated: boolean;
|
invalidated: boolean;
|
||||||
connect: (userId: string) => void;
|
connect: (userId: string) => void;
|
||||||
disconnect: () => void;
|
disconnect: () => void;
|
||||||
|
/** Сессия отозвана сервером (logout-all, смена пароля, бан): экран входа. */
|
||||||
|
markInvalidated: () => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +109,10 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
|
|||||||
set({ ...defaults });
|
set({ ...defaults });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** markInvalidated — сессия отозвана сервером: показываем экран входа. */
|
||||||
|
markInvalidated: () => {
|
||||||
|
set({ invalidated: true, status: 'disconnected' });
|
||||||
|
},
|
||||||
reset: () => {
|
reset: () => {
|
||||||
disconnectGateway();
|
disconnectGateway();
|
||||||
set({ ...defaults });
|
set({ ...defaults });
|
||||||
@@ -199,7 +205,10 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
|||||||
}
|
}
|
||||||
const guildId = channel.guild_id ?? session.selectedGuildId;
|
const guildId = channel.guild_id ?? session.selectedGuildId;
|
||||||
if (guildId !== null) {
|
if (guildId !== null) {
|
||||||
session.upsertChannel(guildId, channel);
|
// Права на комнату персональные: тело события их не несёт, поэтому
|
||||||
|
// перечитываем список — иначе новая или переименованная комната не
|
||||||
|
// появится в сайдбаре без перезагрузки (AGENT.md 11.6).
|
||||||
|
void session.reloadGuildChannels(guildId);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -274,10 +283,12 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
|||||||
}
|
}
|
||||||
case 'MEMBER_UPDATE':
|
case 'MEMBER_UPDATE':
|
||||||
case 'MEMBER_REMOVE': {
|
case 'MEMBER_REMOVE': {
|
||||||
// Роли участника влияют на права: перечитываем участников и карточку сервера.
|
// Роли участника влияют на права: перечитываем участников, карточку
|
||||||
|
// сервера и список комнат (приватные комнаты открываются ролью).
|
||||||
const guildId = parseGuildId(event.d);
|
const guildId = parseGuildId(event.d);
|
||||||
if (guildId !== null) {
|
if (guildId !== null) {
|
||||||
void invalidateMembershipQueries(guildId);
|
void invalidateMembershipQueries(guildId);
|
||||||
|
void session.reloadGuildChannels(guildId);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -287,6 +298,7 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
|||||||
const role = parseRoleEvent(event.d);
|
const role = parseRoleEvent(event.d);
|
||||||
if (role !== null) {
|
if (role !== null) {
|
||||||
void invalidateMembershipQueries(role.guild_id);
|
void invalidateMembershipQueries(role.guild_id);
|
||||||
|
void session.reloadGuildChannels(role.guild_id);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -335,6 +347,16 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case 'SESSION_INVALIDATED': {
|
||||||
|
// Сервер отозвал сессии (logout-all, смена пароля, бан, удаление):
|
||||||
|
// уходим на экран входа, не дожидаясь следующего запроса (AGENT.md 11.6).
|
||||||
|
useGatewayStore.getState().markInvalidated();
|
||||||
|
session.reset();
|
||||||
|
messages.reset();
|
||||||
|
useVoiceConnectionStore.getState().reset();
|
||||||
|
void invalidateCurrentUser();
|
||||||
|
return;
|
||||||
|
}
|
||||||
case 'GUILD_CHANNELS_SYNC': {
|
case 'GUILD_CHANNELS_SYNC': {
|
||||||
// Права комнаты изменились: видимость может поменяться у всех
|
// Права комнаты изменились: видимость может поменяться у всех
|
||||||
// участников, поэтому список комнат перечитываем целиком (AGENT.md 11.6).
|
// участников, поэтому список комнат перечитываем целиком (AGENT.md 11.6).
|
||||||
|
|||||||
@@ -140,28 +140,49 @@ describe('диспетчер событий шлюза', () => {
|
|||||||
expect(state.selectedChannelId).toBeNull();
|
expect(state.selectedChannelId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('CHANNEL_CREATE добавляет комнату, CHANNEL_UPDATE заменяет её', () => {
|
it('CHANNEL_CREATE и CHANNEL_UPDATE перечитывают комнаты с сервера', async () => {
|
||||||
|
// Права на комнату персональные: тело события их не несёт, поэтому клиент
|
||||||
|
// перечитывает список, а не подставляет комнату из кадра (AGENDA.md 11.6).
|
||||||
|
installFetch([
|
||||||
|
{
|
||||||
|
match: '/api/v1/guilds/g-1/channels',
|
||||||
|
response: () =>
|
||||||
|
json({
|
||||||
|
channels: [
|
||||||
|
{ id: 'c-1', guild_id: 'g-1', name: 'общий', type: 'text', position: 0 },
|
||||||
|
{ id: 'c-2', guild_id: 'g-1', name: 'флуд', type: 'text', position: 1 },
|
||||||
|
{
|
||||||
|
id: 'c-3',
|
||||||
|
guild_id: 'g-1',
|
||||||
|
name: 'новости',
|
||||||
|
type: 'text',
|
||||||
|
position: 2,
|
||||||
|
can_send: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
seedSession();
|
seedSession();
|
||||||
|
|
||||||
dispatchGatewayEvent({
|
dispatchGatewayEvent({
|
||||||
op: 0,
|
op: 0,
|
||||||
t: 'CHANNEL_CREATE',
|
t: 'CHANNEL_CREATE',
|
||||||
s: 8,
|
s: 8,
|
||||||
d: {
|
d: { id: 'c-3', guild_id: 'g-1', name: 'анонсы', type: 'text', position: 2 },
|
||||||
id: 'c-3',
|
|
||||||
guild_id: 'g-1',
|
|
||||||
name: 'анонсы',
|
|
||||||
type: 'text',
|
|
||||||
position: 2,
|
|
||||||
can_send: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const channels = useSessionStore
|
||||||
|
.getState()
|
||||||
|
.guilds.find((guild) => guild.id === 'g-1')
|
||||||
|
?.channels.map((channel) => channel.name);
|
||||||
|
expect(channels).toEqual(['общий', 'флуд', 'новости']);
|
||||||
|
});
|
||||||
const created = useSessionStore
|
const created = useSessionStore
|
||||||
.getState()
|
.getState()
|
||||||
.guilds.find((guild) => guild.id === 'g-1')
|
.guilds.find((guild) => guild.id === 'g-1')
|
||||||
?.channels.find((channel) => channel.id === 'c-3');
|
?.channels.find((channel) => channel.id === 'c-3');
|
||||||
expect(created?.name).toBe('анонсы');
|
|
||||||
expect(created?.can_send).toBe(false);
|
expect(created?.can_send).toBe(false);
|
||||||
|
|
||||||
dispatchGatewayEvent({
|
dispatchGatewayEvent({
|
||||||
@@ -170,13 +191,13 @@ describe('диспетчер событий шлюза', () => {
|
|||||||
s: 9,
|
s: 9,
|
||||||
d: { id: 'c-3', guild_id: 'g-1', name: 'новости', type: 'text', position: 2 },
|
d: { id: 'c-3', guild_id: 'g-1', name: 'новости', type: 'text', position: 2 },
|
||||||
});
|
});
|
||||||
|
await waitFor(() => {
|
||||||
const updated = useSessionStore
|
const updated = useSessionStore
|
||||||
.getState()
|
.getState()
|
||||||
.guilds.find((guild) => guild.id === 'g-1')
|
.guilds.find((guild) => guild.id === 'g-1')
|
||||||
?.channels.filter((channel) => channel.id === 'c-3');
|
?.channels.filter((channel) => channel.id === 'c-3');
|
||||||
expect(updated).toHaveLength(1);
|
expect(updated).toHaveLength(1);
|
||||||
expect(updated?.[0]?.name).toBe('новости');
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('CHANNEL_DELETE удаляет комнату и переносит выбор на первую доступную', () => {
|
it('CHANNEL_DELETE удаляет комнату и переносит выбор на первую доступную', () => {
|
||||||
@@ -197,6 +218,24 @@ describe('диспетчер событий шлюза', () => {
|
|||||||
expect(state.selectedChannelId).toBe('c-2');
|
expect(state.selectedChannelId).toBe('c-2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('SESSION_INVALIDATED переводит клиент на экран входа', () => {
|
||||||
|
// Сервер отзывает сессии при logout-all, смене пароля и бане: второе
|
||||||
|
// устройство не должно оставаться в приложении до следующего запроса.
|
||||||
|
seedSession();
|
||||||
|
useGatewayStore.setState({ invalidated: false, status: 'connected' });
|
||||||
|
|
||||||
|
dispatchGatewayEvent({
|
||||||
|
op: 0,
|
||||||
|
t: 'SESSION_INVALIDATED',
|
||||||
|
s: 42,
|
||||||
|
d: { reason: 'logout_all' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useGatewayStore.getState().invalidated).toBe(true);
|
||||||
|
expect(useGatewayStore.getState().status).toBe('disconnected');
|
||||||
|
expect(useSessionStore.getState().guilds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('MEMBER_UPDATE с ролями инвалидирует участников, роли и карточку сервера', async () => {
|
it('MEMBER_UPDATE с ролями инвалидирует участников, роли и карточку сервера', async () => {
|
||||||
const { client, invalidated } = makeRecordingClient();
|
const { client, invalidated } = makeRecordingClient();
|
||||||
client.setQueryData(guildMembersQueryKey('g-1'), []);
|
client.setQueryData(guildMembersQueryKey('g-1'), []);
|
||||||
|
|||||||
Reference in New Issue
Block a user