package server import ( "context" "encoding/json" "log/slog" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "time" "github.com/coder/websocket" "glchat/internal/auth" "glchat/internal/config" "glchat/internal/database" "glchat/internal/gateway" "glchat/internal/permissions" "glchat/internal/source" "glchat/internal/store" ) func newTestServer(t *testing.T) (*Server, *database.DB) { 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", } 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, }), db } // TestGatewayRouteUpgrades проверяет связку: маршрут /gateway доступен через // полный стек middleware, HELLO приходит до IDENTIFY (AGENT.md 8.3). func TestGatewayRouteUpgrades(t *testing.T) { srv, _ := newTestServer(t) httpServer := httptest.NewServer(srv.Handler()) t.Cleanup(httpServer.Close) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() url := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/gateway" // coder/websocket закрывает тело ответа сам (dial.go: "You never need to // close resp.Body yourself") — отсюда nolint:bodyclose. conn, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{ //nolint:bodyclose // тело закрывает библиотека HTTPHeader: map[string][]string{"Origin": {"https://gl.mhspx.su"}}, }) if err != nil { t.Fatalf("dial /gateway: %v", err) } t.Cleanup(func() { _ = conn.CloseNow() }) _, data, err := conn.Read(ctx) if err != nil { t.Fatalf("read HELLO: %v", err) } var envelope struct { Op int `json:"op"` } if err := json.Unmarshal(data, &envelope); err != nil { t.Fatalf("decode HELLO: %v", err) } if envelope.Op != gateway.OpHello { t.Fatalf("op = %d, want HELLO (%d)", envelope.Op, gateway.OpHello) } } // TestGatewayRejectsForeignOrigin: подключение с чужого домена отклоняется // (AGENT.md 9.7). func TestGatewayRejectsForeignOrigin(t *testing.T) { srv, _ := newTestServer(t) httpServer := httptest.NewServer(srv.Handler()) t.Cleanup(httpServer.Close) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() url := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/gateway" conn, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{ //nolint:bodyclose // тело закрывает библиотека HTTPHeader: map[string][]string{"Origin": {"https://evil.example"}}, }) if err == nil { _ = conn.CloseNow() t.Fatal("connection from a foreign origin must be rejected") } } func TestHealthz(t *testing.T) { srv, _ := newTestServer(t) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/healthz", nil)) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("decode response: %v", err) } if body["status"] != "ok" { t.Errorf("status field = %v, want ok", body["status"]) } if body["version"] != "v0.1.0-test" { t.Errorf("version field = %v", body["version"]) } if rec.Header().Get("X-Request-Id") == "" { t.Error("X-Request-Id header is missing") } if rec.Header().Get("X-Content-Type-Options") != "nosniff" { t.Error("security headers are missing") } } func TestReadyzReportsDatabase(t *testing.T) { srv, _ := newTestServer(t) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/readyz", nil)) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } var body struct { Status string `json:"status"` Checks map[string]string `json:"checks"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("decode response: %v", err) } if body.Status != "ready" { t.Errorf("status = %q, want ready", body.Status) } if body.Checks["database"] != "ok" { t.Errorf("database check = %q, want ok", body.Checks["database"]) } } func TestReadyzFailsWhenDatabaseClosed(t *testing.T) { srv, db := newTestServer(t) if err := db.Close(); err != nil { t.Fatalf("close database: %v", err) } rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/readyz", nil)) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("status = %d, want 503", rec.Code) } } func TestMetaEndpoint(t *testing.T) { srv, _ := newTestServer(t) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/v1/meta", nil)) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } var body struct { APIVersion string `json:"api_version"` BaseURL string `json:"base_url"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("decode response: %v", err) } if body.APIVersion != "v1" { t.Errorf("api_version = %q, want v1", body.APIVersion) } if body.BaseURL != "https://gl.mhspx.su" { t.Errorf("base_url = %q", body.BaseURL) } } func TestOpenAPIDocumentIsServed(t *testing.T) { srv, _ := newTestServer(t) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/v1/openapi.json", nil)) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } var doc map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil { t.Fatalf("openapi document is not valid json: %v", err) } if doc["openapi"] != "3.1.0" { t.Errorf("openapi version = %v, want 3.1.0", doc["openapi"]) } if _, ok := doc["paths"].(map[string]any)["/meta"]; !ok { t.Error("openapi document does not describe /meta") } } func TestUnknownAPIRouteUsesErrorEnvelope(t *testing.T) { srv, _ := newTestServer(t) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/v1/does-not-exist", nil)) if rec.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", rec.Code) } var body struct { Error struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("decode error envelope: %v", err) } if body.Error.Code == "" || body.Error.Message == "" { t.Errorf("error envelope is incomplete: %s", rec.Body.String()) } } func TestServesWebClientWithSPAFallback(t *testing.T) { srv, _ := newTestServer(t) cases := []struct { path string wantStatus int wantSubstr string wantHeaders map[string]string }{ {path: "/", wantStatus: http.StatusOK, wantSubstr: "glchat"}, {path: "/app/guild/1/channel/2", wantStatus: http.StatusOK, wantSubstr: "glchat"}, {path: "/assets/app.js", wantStatus: http.StatusOK, wantSubstr: "console.log"}, } for _, tc := range cases { rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, tc.path, nil)) if rec.Code != tc.wantStatus { t.Errorf("GET %s status = %d, want %d", tc.path, rec.Code, tc.wantStatus) } if !strings.Contains(rec.Body.String(), tc.wantSubstr) { t.Errorf("GET %s body = %q, want it to contain %q", tc.path, rec.Body.String(), tc.wantSubstr) } } rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/assets/app.js", nil)) if got := rec.Header().Get("Cache-Control"); !strings.Contains(got, "immutable") { t.Errorf("assets Cache-Control = %q, want immutable", got) } } func TestServeWebClientReportsMissingBundle(t *testing.T) { 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() { _ = db.Close() }) cfg := config.Config{Domain: "localhost", WebRoot: t.TempDir(), LogFormat: "json", LogLevel: "error"} logger := slog.New(slog.DiscardHandler) srv := New(cfg, db, logger, Deps{}) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil)) if rec.Code != http.StatusServiceUnavailable { t.Errorf("status = %d, want 503 when the bundle is absent", rec.Code) } } func TestMethodNotAllowedOnMeta(t *testing.T) { srv, _ := newTestServer(t) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/v1/meta", nil)) if rec.Code != http.StatusMethodNotAllowed { t.Fatalf("status = %d, want 405", rec.Code) } }