355 lines
12 KiB
Go
355 lines
12 KiB
Go
|
|
package server
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"crypto/ecdsa"
|
|||
|
|
"crypto/elliptic"
|
|||
|
|
"crypto/rand"
|
|||
|
|
"crypto/x509"
|
|||
|
|
"encoding/base64"
|
|||
|
|
"fmt"
|
|||
|
|
"log/slog"
|
|||
|
|
"net/http"
|
|||
|
|
"path/filepath"
|
|||
|
|
"testing"
|
|||
|
|
"time"
|
|||
|
|
|
|||
|
|
"glchat/internal/auth"
|
|||
|
|
"glchat/internal/config"
|
|||
|
|
"glchat/internal/database"
|
|||
|
|
"glchat/internal/gateway"
|
|||
|
|
"glchat/internal/httpx"
|
|||
|
|
"glchat/internal/permissions"
|
|||
|
|
"glchat/internal/source"
|
|||
|
|
"glchat/internal/store"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// newPushTestServer поднимает тестовый сервер с настроенным VAPID-ключом:
|
|||
|
|
// newTestServer из server_test.go о push ничего не знает, а ручки обязаны
|
|||
|
|
// работать и с ключом, и без него.
|
|||
|
|
func newPushTestServer(t *testing.T, withKeys bool) (*Server, *store.Store) {
|
|||
|
|
t.Helper()
|
|||
|
|
ctx := context.Background()
|
|||
|
|
db, err := database.Open(ctx, database.Options{
|
|||
|
|
Path: filepath.Join(t.TempDir(), "glchat.db"),
|
|||
|
|
ReadPool: 2,
|
|||
|
|
Migrate: true,
|
|||
|
|
})
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("open test database: %v", err)
|
|||
|
|
}
|
|||
|
|
t.Cleanup(func() {
|
|||
|
|
if err := db.Close(); err != nil {
|
|||
|
|
t.Errorf("close test database: %v", err)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
cfg := config.Config{
|
|||
|
|
DataDir: t.TempDir(),
|
|||
|
|
Domain: "gl.mhspx.su",
|
|||
|
|
WebRoot: filepath.Join("testdata", "web"),
|
|||
|
|
FilesDomain: "files.gl.mhspx.su",
|
|||
|
|
InstanceName: "glchat",
|
|||
|
|
ListenAddr: "127.0.0.1:0",
|
|||
|
|
Version: "v0.1.0-test",
|
|||
|
|
Commit: "deadbee",
|
|||
|
|
BuildDate: "2026-09-19T00:00:00Z",
|
|||
|
|
MaxUploadSize: 26214400,
|
|||
|
|
TLSEnabled: true,
|
|||
|
|
SessionPepper: "test-pepper",
|
|||
|
|
MasterKey: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff",
|
|||
|
|
Argon2MemoryKiB: 1024,
|
|||
|
|
Argon2Iterations: 1,
|
|||
|
|
Argon2Parallelism: 1,
|
|||
|
|
LogLevel: "error",
|
|||
|
|
LogFormat: "json",
|
|||
|
|
}
|
|||
|
|
if withKeys {
|
|||
|
|
cfg.VAPIDPrivateKey = testVAPIDPrivateKey(t)
|
|||
|
|
cfg.VAPIDSubject = "mailto:admin@gl.mhspx.su"
|
|||
|
|
}
|
|||
|
|
logger := slog.New(slog.DiscardHandler)
|
|||
|
|
st := store.New(db)
|
|||
|
|
authService, err := auth.New(context.Background(), cfg, st, logger)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("initialize authentication: %v", err)
|
|||
|
|
}
|
|||
|
|
calculator := permissions.NewCalculator(source.New(st))
|
|||
|
|
gatewayService := gateway.New(st, authService, gateway.NewSnapshot(st, calculator), logger, cfg.AllowedOrigins())
|
|||
|
|
return New(cfg, db, logger, Deps{
|
|||
|
|
Store: st, Auth: authService, Gateway: gatewayService, Permissions: calculator,
|
|||
|
|
}), st
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func testVAPIDPrivateKey(t *testing.T) string {
|
|||
|
|
t.Helper()
|
|||
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("generate key: %v", err)
|
|||
|
|
}
|
|||
|
|
der, err := x509.MarshalPKCS8PrivateKey(key)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("marshal PKCS#8: %v", err)
|
|||
|
|
}
|
|||
|
|
return base64.StdEncoding.EncodeToString(der)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// testPushKeys возвращает корректные ключи подписки (65-байтовая точка P-256
|
|||
|
|
// и 16 байт auth) в base64url — как их отдаёт PushSubscription.toJSON().
|
|||
|
|
func testPushKeys(t *testing.T) (string, string) {
|
|||
|
|
t.Helper()
|
|||
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("generate subscription key: %v", err)
|
|||
|
|
}
|
|||
|
|
point, err := key.PublicKey.Bytes()
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("public key bytes: %v", err)
|
|||
|
|
}
|
|||
|
|
auth := make([]byte, 16)
|
|||
|
|
if _, err := rand.Read(auth); err != nil {
|
|||
|
|
t.Fatalf("auth: %v", err)
|
|||
|
|
}
|
|||
|
|
return base64.RawURLEncoding.EncodeToString(point), base64.RawURLEncoding.EncodeToString(auth)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// testAuthKey — корректный auth-ключ подписки (16 байт).
|
|||
|
|
func testAuthKey(t *testing.T) string {
|
|||
|
|
t.Helper()
|
|||
|
|
_, auth := testPushKeys(t)
|
|||
|
|
return auth
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func pushSubscribeBody(t *testing.T, endpoint string) string {
|
|||
|
|
t.Helper()
|
|||
|
|
p256dh, auth := testPushKeys(t)
|
|||
|
|
return fmt.Sprintf(`{"endpoint":%q,"keys":{"p256dh":%q,"auth":%q}}`, endpoint, p256dh, auth)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestPushConfigDisabledWithoutKeys(t *testing.T) {
|
|||
|
|
srv, _ := newPushTestServer(t, false)
|
|||
|
|
cookie := registerAndLogin(t, srv, "push_off", "push-off@example.com")
|
|||
|
|
|
|||
|
|
rec := doJSON(t, srv, http.MethodGet, "/api/v1/push/config", "", cookie)
|
|||
|
|
if rec.Code != http.StatusOK {
|
|||
|
|
t.Fatalf("GET /push/config = %d, body = %s", rec.Code, rec.Body.String())
|
|||
|
|
}
|
|||
|
|
payload := decodeResponse[struct {
|
|||
|
|
Enabled bool `json:"enabled"`
|
|||
|
|
PublicKey string `json:"public_key"`
|
|||
|
|
}](t, rec)
|
|||
|
|
if payload.Enabled || payload.PublicKey != "" {
|
|||
|
|
t.Fatalf("без ключей push должен быть выключен: %+v", payload)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Подписка на инстансе без ключей — честная ошибка, а не молчаливый успех.
|
|||
|
|
rec = doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions",
|
|||
|
|
pushSubscribeBody(t, "https://fcm.googleapis.com/fcm/send/disabled"), cookie)
|
|||
|
|
if rec.Code != http.StatusServiceUnavailable {
|
|||
|
|
t.Fatalf("подписка без ключей = %d, ожидалось 503", rec.Code)
|
|||
|
|
}
|
|||
|
|
if code := errorCodeOf(t, rec); code != "push.disabled" {
|
|||
|
|
t.Fatalf("код ошибки = %q, ожидался push.disabled", code)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestPushSubscribeFlow(t *testing.T) {
|
|||
|
|
srv, st := newPushTestServer(t, true)
|
|||
|
|
cookie := registerAndLogin(t, srv, "push_on", "push-on@example.com")
|
|||
|
|
user, err := srv.auth.UserByEmail(t.Context(), "push-on@example.com")
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("UserByEmail: %v", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
config := decodeResponse[struct {
|
|||
|
|
Enabled bool `json:"enabled"`
|
|||
|
|
PublicKey string `json:"public_key"`
|
|||
|
|
}](t, doJSON(t, srv, http.MethodGet, "/api/v1/push/config", "", cookie))
|
|||
|
|
if !config.Enabled || config.PublicKey == "" {
|
|||
|
|
t.Fatalf("push должен быть включён: %+v", config)
|
|||
|
|
}
|
|||
|
|
decoded, err := base64.RawURLEncoding.DecodeString(config.PublicKey)
|
|||
|
|
if err != nil || len(decoded) != 65 {
|
|||
|
|
t.Fatalf("публичный ключ не годится для applicationServerKey: %v", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
endpoint := "https://fcm.googleapis.com/fcm/send/device-one"
|
|||
|
|
rec := doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions",
|
|||
|
|
pushSubscribeBody(t, endpoint), cookie)
|
|||
|
|
if rec.Code != http.StatusOK {
|
|||
|
|
t.Fatalf("подписка = %d, body = %s", rec.Code, rec.Body.String())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
subscriptions, err := st.ListPushSubscriptions(t.Context(), user.ID)
|
|||
|
|
if err != nil || len(subscriptions) != 1 {
|
|||
|
|
t.Fatalf("подписок в базе %d (%v), ожидалась 1", len(subscriptions), err)
|
|||
|
|
}
|
|||
|
|
if subscriptions[0].Endpoint != endpoint {
|
|||
|
|
t.Fatalf("эндпоинт = %q", subscriptions[0].Endpoint)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Список для интерфейса отдаёт устройства и лимит.
|
|||
|
|
list := decodeResponse[struct {
|
|||
|
|
Subscriptions []struct {
|
|||
|
|
ID string `json:"id"`
|
|||
|
|
Endpoint string `json:"endpoint"`
|
|||
|
|
} `json:"subscriptions"`
|
|||
|
|
Limit int `json:"limit"`
|
|||
|
|
}](t, doJSON(t, srv, http.MethodGet, "/api/v1/push/subscriptions", "", cookie))
|
|||
|
|
if len(list.Subscriptions) != 1 || list.Limit != maxPushSubscriptionsPerUser {
|
|||
|
|
t.Fatalf("неожиданный список подписок: %+v", list)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Отписка устройства.
|
|||
|
|
rec = doJSON(t, srv, http.MethodDelete,
|
|||
|
|
"/api/v1/push/subscriptions?endpoint="+endpoint, "", cookie)
|
|||
|
|
if rec.Code != http.StatusOK {
|
|||
|
|
t.Fatalf("отписка = %d, body = %s", rec.Code, rec.Body.String())
|
|||
|
|
}
|
|||
|
|
if count, err := st.CountPushSubscriptions(t.Context(), user.ID); err != nil || count != 0 {
|
|||
|
|
t.Fatalf("после отписки подписок %d (%v)", count, err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestPushSubscribeValidation(t *testing.T) {
|
|||
|
|
srv, _ := newPushTestServer(t, true)
|
|||
|
|
cookie := registerAndLogin(t, srv, "push_bad", "push-bad@example.com")
|
|||
|
|
|
|||
|
|
cases := []struct {
|
|||
|
|
name string
|
|||
|
|
body string
|
|||
|
|
code string
|
|||
|
|
}{
|
|||
|
|
{
|
|||
|
|
name: "http вместо https",
|
|||
|
|
body: pushSubscribeBody(t, "http://fcm.googleapis.com/fcm/send/x"),
|
|||
|
|
code: "push.invalid_endpoint",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "внутренний адрес",
|
|||
|
|
body: pushSubscribeBody(t, "https://10.0.0.5/push"),
|
|||
|
|
code: "push.invalid_endpoint",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "метаданные облака",
|
|||
|
|
body: pushSubscribeBody(t, "https://169.254.169.254/latest/meta-data"),
|
|||
|
|
code: "push.invalid_endpoint",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "loopback",
|
|||
|
|
body: pushSubscribeBody(t, "https://127.0.0.1:8443/push"),
|
|||
|
|
code: "push.invalid_endpoint",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "p256dh не точка кривой",
|
|||
|
|
body: fmt.Sprintf(`{"endpoint":"https://fcm.googleapis.com/fcm/send/x","keys":{"p256dh":%q,"auth":%q}}`,
|
|||
|
|
base64.RawURLEncoding.EncodeToString(make([]byte, 65)), testAuthKey(t)),
|
|||
|
|
code: "push.invalid_keys",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "битый ключ шифрования",
|
|||
|
|
body: `{"endpoint":"https://fcm.googleapis.com/fcm/send/x","keys":{"p256dh":"AAAA","auth":"AAAA"}}`,
|
|||
|
|
code: "push.invalid_keys",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "пустой ключ",
|
|||
|
|
body: `{"endpoint":"https://fcm.googleapis.com/fcm/send/x","keys":{"p256dh":"","auth":""}}`,
|
|||
|
|
code: "push.invalid_keys",
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
for _, testCase := range cases {
|
|||
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|||
|
|
rec := doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions", testCase.body, cookie)
|
|||
|
|
if rec.Code != http.StatusUnprocessableEntity {
|
|||
|
|
t.Fatalf("код ответа = %d, ожидался 422 (body = %s)", rec.Code, rec.Body.String())
|
|||
|
|
}
|
|||
|
|
if code := errorCodeOf(t, rec); code != testCase.code {
|
|||
|
|
t.Fatalf("код ошибки = %q, ожидался %q", code, testCase.code)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestPushSubscribeLimit(t *testing.T) {
|
|||
|
|
srv, _ := newPushTestServer(t, true)
|
|||
|
|
cookie := registerAndLogin(t, srv, "push_limit", "push-limit@example.com")
|
|||
|
|
// Лимит частоты проверяется отдельно: здесь важно дойти до предела
|
|||
|
|
// устройств на пользователя.
|
|||
|
|
srv.pushLimiter = httpx.NewRateLimiterWindow(100, time.Minute, 100)
|
|||
|
|
|
|||
|
|
for i := 0; i < maxPushSubscriptionsPerUser; i++ {
|
|||
|
|
endpoint := fmt.Sprintf("https://fcm.googleapis.com/fcm/send/device-%d", i)
|
|||
|
|
rec := doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions",
|
|||
|
|
pushSubscribeBody(t, endpoint), cookie)
|
|||
|
|
if rec.Code != http.StatusOK {
|
|||
|
|
t.Fatalf("подписка %d = %d, body = %s", i, rec.Code, rec.Body.String())
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
rec := doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions",
|
|||
|
|
pushSubscribeBody(t, "https://fcm.googleapis.com/fcm/send/device-over-limit"), cookie)
|
|||
|
|
if rec.Code != http.StatusConflict {
|
|||
|
|
t.Fatalf("подписка сверх лимита = %d, ожидалось 409 (body = %s)", rec.Code, rec.Body.String())
|
|||
|
|
}
|
|||
|
|
if code := errorCodeOf(t, rec); code != "push.too_many_subscriptions" {
|
|||
|
|
t.Fatalf("код ошибки = %q", code)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Подписки ограничены и по частоте: перебор устройств не должен превращаться
|
|||
|
|
// в поток запросов (AGENT.md 8.6).
|
|||
|
|
func TestPushSubscribeRateLimited(t *testing.T) {
|
|||
|
|
srv, _ := newPushTestServer(t, true)
|
|||
|
|
cookie := registerAndLogin(t, srv, "push_flood", "push-flood@example.com")
|
|||
|
|
|
|||
|
|
var limited bool
|
|||
|
|
for i := 0; i < 40; i++ {
|
|||
|
|
endpoint := fmt.Sprintf("https://fcm.googleapis.com/fcm/send/flood-%d", i)
|
|||
|
|
rec := doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions",
|
|||
|
|
pushSubscribeBody(t, endpoint), cookie)
|
|||
|
|
if rec.Code == http.StatusTooManyRequests {
|
|||
|
|
limited = true
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if !limited {
|
|||
|
|
t.Fatal("лимит частоты подписок не сработал")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestPushRoutesRequireSession(t *testing.T) {
|
|||
|
|
srv, _ := newPushTestServer(t, true)
|
|||
|
|
body := pushSubscribeBody(t, "https://fcm.googleapis.com/fcm/send/no-session")
|
|||
|
|
for _, request := range []struct {
|
|||
|
|
method string
|
|||
|
|
path string
|
|||
|
|
body string
|
|||
|
|
}{
|
|||
|
|
{http.MethodGet, "/api/v1/push/config", ""},
|
|||
|
|
{http.MethodGet, "/api/v1/push/subscriptions", ""},
|
|||
|
|
{http.MethodPost, "/api/v1/push/subscriptions", body},
|
|||
|
|
{http.MethodDelete, "/api/v1/push/subscriptions", ""},
|
|||
|
|
} {
|
|||
|
|
rec := doJSON(t, srv, request.method, request.path, request.body)
|
|||
|
|
if rec.Code != http.StatusUnauthorized {
|
|||
|
|
t.Errorf("%s %s без сессии = %d, ожидалось 401", request.method, request.path, rec.Code)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Публичный ключ в /meta виден до входа: клиент решает, показывать ли раздел.
|
|||
|
|
func TestMetaExposesWebPushFlag(t *testing.T) {
|
|||
|
|
srv, _ := newPushTestServer(t, true)
|
|||
|
|
rec := doJSON(t, srv, http.MethodGet, "/api/v1/meta", "")
|
|||
|
|
if rec.Code != http.StatusOK {
|
|||
|
|
t.Fatalf("GET /meta = %d", rec.Code)
|
|||
|
|
}
|
|||
|
|
payload := decodeResponse[struct {
|
|||
|
|
Features struct {
|
|||
|
|
WebPushEnabled bool `json:"web_push_enabled"`
|
|||
|
|
} `json:"features"`
|
|||
|
|
}](t, rec)
|
|||
|
|
if !payload.Features.WebPushEnabled {
|
|||
|
|
t.Fatal("meta не сообщает о включённом Web Push")
|
|||
|
|
}
|
|||
|
|
}
|