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,127 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/buildinfo"
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
)
|
||||
|
||||
// SpecVersions is what GET /_matrix/client/versions reports.
|
||||
//
|
||||
// Keep it honest: a version belongs here once the endpoints a client
|
||||
// actually uses from it work. Phase 0 serves only /versions and
|
||||
// /.well-known, so the list stays minimal and grows as the C-S API lands in
|
||||
// Phases 1 to 4.
|
||||
var SpecVersions = []string{"v1.13", "v1.14"}
|
||||
|
||||
// UnstableFeatures advertises MSC support. Simplified Sliding Sync
|
||||
// (MSC4186) is Phase 10.
|
||||
var UnstableFeatures = map[string]bool{}
|
||||
|
||||
type versionsResponse struct {
|
||||
Versions []string `json:"versions"`
|
||||
UnstableFeatures map[string]bool `json:"unstable_features,omitempty"`
|
||||
}
|
||||
|
||||
// wellKnownClient is the /.well-known/matrix/client document.
|
||||
type wellKnownClient struct {
|
||||
Homeserver wellKnownBaseURL `json:"m.homeserver"`
|
||||
IdentityServer *wellKnownBaseURL `json:"m.identity_server,omitempty"`
|
||||
}
|
||||
|
||||
type wellKnownBaseURL struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
}
|
||||
|
||||
type wellKnownServer struct {
|
||||
Server string `json:"m.server"`
|
||||
}
|
||||
|
||||
type healthResponse struct {
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
Database string `json:"database"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// routes registers every endpoint Phase 0 serves.
|
||||
func (s *Server) routes() *httputil.Router {
|
||||
r := httputil.NewRouter(s.metrics.Instrument)
|
||||
|
||||
r.HandleFunc("GET /_matrix/client/versions", s.handleVersions)
|
||||
r.HandleFunc("GET /.well-known/matrix/client", s.handleWellKnownClient)
|
||||
|
||||
// The server-side delegation document is part of the federation
|
||||
// surface, so it goes through the gate and disappears with it.
|
||||
r.Handle("GET /.well-known/matrix/server", s.federation.ProtectFunc(s.handleWellKnownServer))
|
||||
|
||||
r.HandleFunc("GET /healthz", s.handleHealth)
|
||||
if s.cfg.Metrics.Enabled {
|
||||
r.Handle("GET "+s.cfg.Metrics.Path, s.metrics.Handler())
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// handleVersions implements GET /_matrix/client/versions.
|
||||
func (s *Server) handleVersions(w http.ResponseWriter, _ *http.Request) error {
|
||||
httputil.CacheFor(w, time.Hour)
|
||||
httputil.WriteJSON(w, http.StatusOK, versionsResponse{
|
||||
Versions: SpecVersions,
|
||||
UnstableFeatures: UnstableFeatures,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleWellKnownClient implements GET /.well-known/matrix/client, the
|
||||
// document a client fetches to turn "@me:warp.example" into an API URL.
|
||||
func (s *Server) handleWellKnownClient(w http.ResponseWriter, _ *http.Request) error {
|
||||
doc := wellKnownClient{
|
||||
Homeserver: wellKnownBaseURL{BaseURL: s.cfg.ClientBaseURL()},
|
||||
}
|
||||
if url := s.cfg.Global.WellKnown.IdentityServerBaseURL; url != "" {
|
||||
doc.IdentityServer = &wellKnownBaseURL{BaseURL: url}
|
||||
}
|
||||
httputil.CacheFor(w, time.Hour)
|
||||
httputil.WriteJSON(w, http.StatusOK, doc)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleWellKnownServer implements GET /.well-known/matrix/server. With no
|
||||
// address configured there is nothing to delegate and the right answer is
|
||||
// 404: other servers then fall back to SRV and A record resolution.
|
||||
func (s *Server) handleWellKnownServer(w http.ResponseWriter, _ *http.Request) error {
|
||||
address := s.cfg.Global.WellKnown.ServerAddress
|
||||
if address == "" {
|
||||
return httputil.NotFound("No server delegation is configured")
|
||||
}
|
||||
httputil.CacheFor(w, time.Hour)
|
||||
httputil.WriteJSON(w, http.StatusOK, wellKnownServer{Server: address})
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleHealth implements GET /healthz: a liveness and readiness check in
|
||||
// one. It reports 503 when the database is unreachable, which is what a
|
||||
// load balancer needs to stop sending traffic here.
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) error {
|
||||
resp := healthResponse{
|
||||
Status: "ok",
|
||||
Version: buildinfo.Version,
|
||||
Database: s.db.Driver(),
|
||||
}
|
||||
status := http.StatusOK
|
||||
|
||||
ctx, cancel := contextWithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := s.db.Ping(ctx); err != nil {
|
||||
resp.Status = "unavailable"
|
||||
resp.Error = "database unreachable"
|
||||
status = http.StatusServiceUnavailable
|
||||
httputil.LoggerFrom(r.Context()).Error("health check failed", "error", err)
|
||||
}
|
||||
|
||||
httputil.NoCache(w)
|
||||
httputil.WriteJSON(w, status, resp)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Package setup wires the components together into a running server: it
|
||||
// opens storage, loads the signing key, builds the runtime-settings service
|
||||
// and the HTTP stack, and owns the graceful shutdown.
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/buildinfo"
|
||||
"github.com/dariuszjaniak/warp/server/internal/config"
|
||||
"github.com/dariuszjaniak/warp/server/internal/federation"
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
"github.com/dariuszjaniak/warp/server/internal/matrix/signing"
|
||||
"github.com/dariuszjaniak/warp/server/internal/metrics"
|
||||
"github.com/dariuszjaniak/warp/server/internal/settings"
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
_ "github.com/dariuszjaniak/warp/server/internal/storage/drivers" // link in the storage drivers
|
||||
)
|
||||
|
||||
// Server is a configured, not yet listening homeserver.
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
log *slog.Logger
|
||||
|
||||
db storage.Database
|
||||
settings *settings.Service
|
||||
metrics *metrics.Registry
|
||||
federation *federation.Gate
|
||||
signingKey *signing.KeyPair
|
||||
|
||||
handler http.Handler
|
||||
|
||||
// addr is the address Run is listening on; empty until then.
|
||||
addr atomic.Pointer[string]
|
||||
|
||||
// ownsDB is false when the caller supplied the database and will close
|
||||
// it itself.
|
||||
ownsDB bool
|
||||
}
|
||||
|
||||
// Options are the knobs tests and commands need beyond the config file.
|
||||
type Options struct {
|
||||
// Logger receives everything the server logs.
|
||||
Logger *slog.Logger
|
||||
|
||||
// Database, when set, is used instead of opening one from the config.
|
||||
// Tests pass an in-memory SQLite database this way.
|
||||
Database storage.Database
|
||||
|
||||
// SkipSigningKey starts the server without loading the signing key.
|
||||
// Only tests and `warp migrate` use it: a real server must be able to
|
||||
// sign.
|
||||
SkipSigningKey bool
|
||||
}
|
||||
|
||||
// New builds the server: storage, migrations, settings, routes. It does not
|
||||
// listen; call Run for that.
|
||||
func New(ctx context.Context, cfg *config.Config, opts Options) (*Server, error) {
|
||||
log := opts.Logger
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
|
||||
s := &Server{cfg: cfg, log: log, metrics: metrics.NewRegistry()}
|
||||
|
||||
if opts.Database != nil {
|
||||
s.db = opts.Database
|
||||
} else {
|
||||
db, err := storage.Open(ctx, storage.Config{
|
||||
Driver: cfg.Database.Driver,
|
||||
ConnectionString: cfg.Database.ConnectionString,
|
||||
MaxOpenConns: cfg.Database.MaxOpenConns,
|
||||
MaxIdleConns: cfg.Database.MaxIdleConns,
|
||||
ConnMaxLifetime: cfg.Database.ConnMaxLifetime,
|
||||
}, log)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.db, s.ownsDB = db, true
|
||||
}
|
||||
|
||||
if err := s.prepareSchema(ctx); err != nil {
|
||||
_ = s.closeDB()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !opts.SkipSigningKey {
|
||||
key, err := signing.Load(cfg.Global.PrivateKeyPath)
|
||||
if err != nil {
|
||||
_ = s.closeDB()
|
||||
return nil, fmt.Errorf("%w\nrun `warp generate-keys --config <file>` to create one", err)
|
||||
}
|
||||
s.signingKey = key
|
||||
log.Info("loaded signing key", "key_id", key.KeyID(), "path", cfg.Global.PrivateKeyPath)
|
||||
}
|
||||
|
||||
settingsService, err := settings.New(ctx, s.db.Settings(), settings.Defaults{
|
||||
FederationEnabled: cfg.Federation.Enabled,
|
||||
FederationAllowRuntimeToggle: cfg.Federation.AllowRuntimeToggle,
|
||||
RegistrationEnabled: cfg.Registration.Enabled,
|
||||
RegistrationAllowRuntimeToggle: cfg.Registration.AllowRuntimeToggle,
|
||||
}, log)
|
||||
if err != nil {
|
||||
_ = s.closeDB()
|
||||
return nil, err
|
||||
}
|
||||
s.settings = settingsService
|
||||
|
||||
s.federation = federation.NewGate(
|
||||
settingsService.FederationEnabled,
|
||||
federation.NewPolicy(cfg.Federation.Allowlist, cfg.Federation.Denylist),
|
||||
)
|
||||
// Phase 5 replaces this with starting and stopping the federation
|
||||
// module; for now it is the audit trail for a runtime toggle.
|
||||
settingsService.Subscribe(func(snap settings.Snapshot) {
|
||||
log.Info("federation switch applied", "enabled", snap.FederationEnabled)
|
||||
})
|
||||
|
||||
s.handler = httputil.Chain(s.routes(),
|
||||
httputil.Recoverer,
|
||||
httputil.RequestLogger(log),
|
||||
s.metrics.InFlight(),
|
||||
httputil.CORS(httputil.CORSOptions{AllowedOrigins: cfg.ClientAPI.CORSAllowedOrigins}),
|
||||
)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// prepareSchema migrates the database, or refuses to start when migrations
|
||||
// are pending and auto-migration is off.
|
||||
func (s *Server) prepareSchema(ctx context.Context) error {
|
||||
if s.cfg.Database.AutoMigrate {
|
||||
if err := s.db.Migrate(ctx); err != nil {
|
||||
return fmt.Errorf("migrate database: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
pending, err := s.db.PendingMigrations(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check migrations: %w", err)
|
||||
}
|
||||
if len(pending) > 0 {
|
||||
return fmt.Errorf("database has %d pending migration(s): %v; run `warp migrate`", len(pending), pending)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Addr reports the address Run is listening on, or "" before it starts.
|
||||
// Tests and `warp serve` on port 0 use it to find the chosen port.
|
||||
func (s *Server) Addr() string {
|
||||
if p := s.addr.Load(); p != nil {
|
||||
return *p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Handler exposes the fully wrapped HTTP handler, for tests.
|
||||
func (s *Server) Handler() http.Handler { return s.handler }
|
||||
|
||||
// Settings exposes the runtime-settings service; the admin API in Phase 1
|
||||
// is its first user.
|
||||
func (s *Server) Settings() *settings.Service { return s.settings }
|
||||
|
||||
// Database exposes the storage handle.
|
||||
func (s *Server) Database() storage.Database { return s.db }
|
||||
|
||||
// SigningKey returns the server signing key, or nil when it was skipped.
|
||||
func (s *Server) SigningKey() *signing.KeyPair { return s.signingKey }
|
||||
|
||||
// Run listens and serves until ctx is cancelled, then shuts down
|
||||
// gracefully: it stops accepting connections and gives in-flight requests
|
||||
// up to listen.shutdown_timeout to finish.
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
listener, err := net.Listen("tcp", s.cfg.Listen.Address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen on %s: %w", s.cfg.Listen.Address, err)
|
||||
}
|
||||
|
||||
addr := listener.Addr().String()
|
||||
s.addr.Store(&addr)
|
||||
defer s.addr.Store(nil)
|
||||
|
||||
httpServer := &http.Server{
|
||||
Handler: s.handler,
|
||||
ReadHeaderTimeout: s.cfg.Listen.ReadHeaderTimeout,
|
||||
ReadTimeout: s.cfg.Listen.ReadTimeout,
|
||||
WriteTimeout: s.cfg.Listen.WriteTimeout,
|
||||
IdleTimeout: s.cfg.Listen.IdleTimeout,
|
||||
MaxHeaderBytes: s.cfg.Listen.MaxHeaderBytes,
|
||||
ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelDebug),
|
||||
BaseContext: func(net.Listener) context.Context { return ctx },
|
||||
}
|
||||
|
||||
s.log.Info("warp is listening",
|
||||
"address", addr,
|
||||
"server_name", s.cfg.Global.ServerName,
|
||||
"version", buildinfo.String(),
|
||||
"database", s.db.Driver(),
|
||||
"federation", s.settings.FederationEnabled(),
|
||||
"registration", s.settings.RegistrationEnabled(),
|
||||
)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
err := httpServer.Serve(listener)
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
err = nil
|
||||
}
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
}
|
||||
|
||||
s.log.Info("shutting down", "timeout", s.cfg.Listen.ShutdownTimeout)
|
||||
shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), s.cfg.Listen.ShutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
||||
s.log.Warn("graceful shutdown timed out; closing connections", "error", err)
|
||||
_ = httpServer.Close()
|
||||
}
|
||||
// Serve has returned by now; surface anything it reported.
|
||||
if err := <-errCh; err != nil {
|
||||
return err
|
||||
}
|
||||
s.log.Info("stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close releases the resources New acquired.
|
||||
func (s *Server) Close() error {
|
||||
return s.closeDB()
|
||||
}
|
||||
|
||||
func (s *Server) closeDB() error {
|
||||
if s.db == nil || !s.ownsDB {
|
||||
return nil
|
||||
}
|
||||
err := s.db.Close()
|
||||
s.db = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// contextWithTimeout is a thin wrapper so handlers do not import context
|
||||
// just for a deadline.
|
||||
func contextWithTimeout(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(ctx, d)
|
||||
}
|
||||
@@ -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