Phase 0: bootstrap the Warp homeserver
Scaffolds the server module and everything Phase 0 of PLAN.md calls for: a binary that boots, serves its discovery endpoints and nothing else yet. - config: one YAML file plus per-field WARP_* environment overrides, unknown fields rejected, validation that reports every problem at once - logging and metrics: slog, a Prometheus registry, per-route HTTP instrumentation labelled by pattern rather than path - httputil: the Matrix error envelope, error-returning handlers, CORS, request logging, panic recovery, and a router over http.ServeMux - endpoints: /_matrix/client/versions, /.well-known/matrix/client, /.well-known/matrix/server (gated), /healthz, /metrics, and M_UNRECOGNIZED for everything else - storage: driver registry, shared database/sql base, checksummed migration runner, sqlite and postgres drivers, pocketbase stub - settings: server_settings table behind a cached service, so the federation and registration switches change without a restart - federation: the gate that makes federation a runtime mode - cmd/warp: serve, migrate, generate-keys, version - Makefile, golangci-lint v2, GitHub Actions CI (Linux and Windows tests, race on Linux, storage tests against PostgreSQL, lint, boot smoke test), Dockerfile, example config and docs Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
b4fb60a8ab
50 files changed
+5672
No files matched your search
@@ -0,0 +1,350 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user