Phase 0: bootstrap the Warp homeserver
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s

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:
tajniak81andClaude Opus 5 committed 2026-10-15 17:01:13 +02:00
commit b4fb60a8ab
50 files changed
+5672

No files matched your search

+350
View File
@@ -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)
}
}