package setup_test import ( "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/dariuszjaniak/warp/server/internal/config" "github.com/dariuszjaniak/warp/server/internal/setup" ) // newTestServer builds a server on a throwaway SQLite database. func newTestServer(t *testing.T, tweak func(*config.Config)) *setup.Server { t.Helper() cfg := config.Default() cfg.Global.ServerName = "warp.example" cfg.Database.ConnectionString = "file:" + t.Name() + "?mode=memory&cache=shared" cfg.Metrics.Enabled = true if tweak != nil { tweak(cfg) } if err := cfg.Validate(); err != nil { t.Fatalf("test config is invalid: %v", err) } srv, err := setup.New(t.Context(), cfg, setup.Options{ Logger: slog.New(slog.DiscardHandler), SkipSigningKey: true, }) if err != nil { t.Fatalf("setup.New: %v", err) } t.Cleanup(func() { _ = srv.Close() }) return srv } func get(t *testing.T, srv *setup.Server, method, path string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(method, path, nil) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, req) return rec } func decode[T any](t *testing.T, rec *httptest.ResponseRecorder) T { t.Helper() var out T body, err := io.ReadAll(rec.Body) if err != nil { t.Fatal(err) } if err := json.Unmarshal(body, &out); err != nil { t.Fatalf("response is not JSON (%s): %v", body, err) } return out } func TestVersions(t *testing.T) { srv := newTestServer(t, nil) rec := get(t, srv, http.MethodGet, "/_matrix/client/versions") if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if ct := rec.Header().Get("Content-Type"); ct != "application/json" { t.Errorf("Content-Type = %q, want application/json", ct) } body := decode[struct { Versions []string `json:"versions"` }](t, rec) if len(body.Versions) == 0 { t.Error("versions must not be empty") } for _, v := range body.Versions { if !strings.HasPrefix(v, "v") && !strings.HasPrefix(v, "r") { t.Errorf("version %q does not look like a spec version", v) } } } func TestWellKnownClient(t *testing.T) { srv := newTestServer(t, func(c *config.Config) { c.Global.WellKnown.IdentityServerBaseURL = "https://identity.example" }) rec := get(t, srv, http.MethodGet, "/.well-known/matrix/client") if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } body := decode[map[string]map[string]string](t, rec) if got, want := body["m.homeserver"]["base_url"], "https://warp.example"; got != want { t.Errorf("base_url = %q, want %q", got, want) } if got, want := body["m.identity_server"]["base_url"], "https://identity.example"; got != want { t.Errorf("identity server = %q, want %q", got, want) } } func TestWellKnownClientOmitsIdentityServerByDefault(t *testing.T) { srv := newTestServer(t, nil) rec := get(t, srv, http.MethodGet, "/.well-known/matrix/client") body := decode[map[string]any](t, rec) if _, present := body["m.identity_server"]; present { t.Error("m.identity_server must be omitted when it is not configured") } } // TestFederationGate is the Phase 0 proof of PLAN.md section 3: the // federation surface is absent while the switch is off, appears when it is // flipped at runtime, and disappears again — with no restart. func TestFederationGate(t *testing.T) { srv := newTestServer(t, func(c *config.Config) { c.Global.WellKnown.ServerAddress = "warp.example:8448" }) rec := get(t, srv, http.MethodGet, "/.well-known/matrix/server") if rec.Code != http.StatusNotFound { t.Fatalf("with federation off, status = %d, want 404", rec.Code) } if code := decode[map[string]string](t, rec)["errcode"]; code != "M_UNRECOGNIZED" { t.Errorf("errcode = %q, want M_UNRECOGNIZED", code) } if err := srv.Settings().SetFederationEnabled(t.Context(), true); err != nil { t.Fatalf("SetFederationEnabled: %v", err) } rec = get(t, srv, http.MethodGet, "/.well-known/matrix/server") if rec.Code != http.StatusOK { t.Fatalf("with federation on, status = %d, want 200", rec.Code) } if got, want := decode[map[string]string](t, rec)["m.server"], "warp.example:8448"; got != want { t.Errorf("m.server = %q, want %q", got, want) } if err := srv.Settings().SetFederationEnabled(t.Context(), false); err != nil { t.Fatalf("SetFederationEnabled: %v", err) } if rec = get(t, srv, http.MethodGet, "/.well-known/matrix/server"); rec.Code != http.StatusNotFound { t.Errorf("after switching off again, status = %d, want 404", rec.Code) } } func TestWellKnownServerWithoutDelegation(t *testing.T) { srv := newTestServer(t, func(c *config.Config) { c.Federation.Enabled = true }) rec := get(t, srv, http.MethodGet, "/.well-known/matrix/server") if rec.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404 when no delegation is configured", rec.Code) } if code := decode[map[string]string](t, rec)["errcode"]; code != "M_NOT_FOUND" { t.Errorf("errcode = %q, want M_NOT_FOUND", code) } } func TestUnknownMatrixEndpoint(t *testing.T) { srv := newTestServer(t, nil) rec := get(t, srv, http.MethodGet, "/_matrix/client/v3/rooms/!a:b/messages") if rec.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", rec.Code) } body := decode[map[string]string](t, rec) if body["errcode"] != "M_UNRECOGNIZED" { t.Errorf("errcode = %q, want M_UNRECOGNIZED", body["errcode"]) } if body["error"] == "" { t.Error("the error envelope must carry a human-readable message") } } func TestCORSPreflight(t *testing.T) { srv := newTestServer(t, nil) req := httptest.NewRequest(http.MethodOptions, "/_matrix/client/versions", nil) req.Header.Set("Origin", "https://app.example") rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("preflight status = %d, want 200", rec.Code) } if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" { t.Errorf("Access-Control-Allow-Origin = %q, want *", got) } for _, header := range []string{"Authorization", "Content-Type", "X-Requested-With"} { if !strings.Contains(rec.Header().Get("Access-Control-Allow-Headers"), header) { t.Errorf("Access-Control-Allow-Headers is missing %s", header) } } } func TestCORSRestrictedOrigins(t *testing.T) { srv := newTestServer(t, func(c *config.Config) { c.ClientAPI.CORSAllowedOrigins = []string{"https://app.example"} }) req := httptest.NewRequest(http.MethodGet, "/_matrix/client/versions", nil) req.Header.Set("Origin", "https://app.example") rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, req) if got, want := rec.Header().Get("Access-Control-Allow-Origin"), "https://app.example"; got != want { t.Errorf("allowed origin = %q, want %q", got, want) } req = httptest.NewRequest(http.MethodGet, "/_matrix/client/versions", nil) req.Header.Set("Origin", "https://evil.example") rec = httptest.NewRecorder() srv.Handler().ServeHTTP(rec, req) if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { t.Errorf("unlisted origin got %q, want no allow-origin header", got) } } func TestHealthz(t *testing.T) { srv := newTestServer(t, nil) rec := get(t, srv, http.MethodGet, "/healthz") if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } body := decode[map[string]string](t, rec) if body["status"] != "ok" { t.Errorf("status = %q, want ok", body["status"]) } if body["database"] != "sqlite" { t.Errorf("database = %q, want sqlite", body["database"]) } } func TestMetrics(t *testing.T) { srv := newTestServer(t, nil) // Generate one request so the counters exist. get(t, srv, http.MethodGet, "/_matrix/client/versions") rec := get(t, srv, http.MethodGet, "/metrics") if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } body := rec.Body.String() for _, want := range []string{"warp_build_info", "warp_http_requests_total"} { if !strings.Contains(body, want) { t.Errorf("metrics output is missing %s", want) } } // The route label must be the pattern, not the path, or the metric // explodes once rooms and users appear in URLs. if !strings.Contains(body, `route="GET /_matrix/client/versions"`) { t.Error("requests are not labelled with their route pattern") } } func TestMetricsCanBeDisabled(t *testing.T) { srv := newTestServer(t, func(c *config.Config) { c.Metrics.Enabled = false }) if rec := get(t, srv, http.MethodGet, "/metrics"); rec.Code != http.StatusNotFound { t.Errorf("status = %d, want 404 when metrics are off", rec.Code) } } func TestPendingMigrationsBlockStartup(t *testing.T) { cfg := config.Default() cfg.Global.ServerName = "warp.example" cfg.Database.ConnectionString = "file:" + t.Name() + "?mode=memory&cache=shared" cfg.Database.AutoMigrate = false _, err := setup.New(t.Context(), cfg, setup.Options{ Logger: slog.New(slog.DiscardHandler), SkipSigningKey: true, }) if err == nil { t.Fatal("expected startup to refuse a database with pending migrations") } if !strings.Contains(err.Error(), "warp migrate") { t.Errorf("the error should say how to fix it, got: %v", err) } } // TestRunServesAndShutsDownGracefully covers the lifecycle the signal // handler drives: Run listens, serves, and returns cleanly once its // context is cancelled. func TestRunServesAndShutsDownGracefully(t *testing.T) { srv := newTestServer(t, func(c *config.Config) { c.Listen.Address = "127.0.0.1:0" c.Listen.ShutdownTimeout = 5 * time.Second }) ctx, cancel := context.WithCancel(t.Context()) done := make(chan error, 1) go func() { done <- srv.Run(ctx) }() var addr string for range 100 { if addr = srv.Addr(); addr != "" { break } time.Sleep(10 * time.Millisecond) } if addr == "" { cancel() t.Fatal("the server never started listening") } resp, err := http.Get("http://" + addr + "/healthz") if err != nil { cancel() t.Fatalf("GET /healthz: %v", err) } _ = resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("status = %d, want 200", resp.StatusCode) } cancel() select { case err := <-done: if err != nil { t.Fatalf("Run returned %v, want nil after a cancelled context", err) } case <-time.After(10 * time.Second): t.Fatal("Run did not return after its context was cancelled") } if resp, err := http.Get("http://" + addr + "/healthz"); err == nil { _ = resp.Body.Close() t.Error("the listener is still accepting connections after shutdown") } } func TestRunReportsListenFailure(t *testing.T) { // Not a valid "host:port", so net.Listen fails before blocking. srv := newTestServer(t, func(c *config.Config) { c.Listen.Address = "nonsense" }) err := srv.Run(t.Context()) if err == nil { t.Fatal("expected Run to fail on an unusable address") } if !strings.Contains(err.Error(), "listen on") { t.Errorf("error should name the address, got: %v", err) } }