Files
glchat/internal/server/api_push_test.go
T

357 lines
12 KiB
Go
Raw Normal View History

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")
}
}