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", UnfurlEnabled: true, UnfurlTimeout: 5 * time.Second, } 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") } }