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>
201 lines
5.1 KiB
Go
201 lines
5.1 KiB
Go
package settings_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/settings"
|
|
)
|
|
|
|
// memStore is an in-memory storage.SettingsStore.
|
|
type memStore struct {
|
|
mu sync.Mutex
|
|
values map[string]string
|
|
failSet error
|
|
}
|
|
|
|
func newMemStore(initial map[string]string) *memStore {
|
|
values := map[string]string{}
|
|
for k, v := range initial {
|
|
values[k] = v
|
|
}
|
|
return &memStore{values: values}
|
|
}
|
|
|
|
func (m *memStore) Get(_ context.Context, key string) (string, bool, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
v, ok := m.values[key]
|
|
return v, ok, nil
|
|
}
|
|
|
|
func (m *memStore) GetAll(context.Context) (map[string]string, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
out := map[string]string{}
|
|
for k, v := range m.values {
|
|
out[k] = v
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (m *memStore) Set(_ context.Context, key, value string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.failSet != nil {
|
|
return m.failSet
|
|
}
|
|
m.values[key] = value
|
|
return nil
|
|
}
|
|
|
|
func (m *memStore) Delete(_ context.Context, key string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
delete(m.values, key)
|
|
return nil
|
|
}
|
|
|
|
func togglableDefaults() settings.Defaults {
|
|
return settings.Defaults{
|
|
FederationEnabled: false,
|
|
FederationAllowRuntimeToggle: true,
|
|
RegistrationEnabled: false,
|
|
RegistrationAllowRuntimeToggle: true,
|
|
}
|
|
}
|
|
|
|
func TestStoredValueOverridesConfig(t *testing.T) {
|
|
store := newMemStore(map[string]string{settings.KeyFederationEnabled: "true"})
|
|
|
|
svc, err := settings.New(t.Context(), store, togglableDefaults(), nil)
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
if !svc.FederationEnabled() {
|
|
t.Error("the persisted value should win over the config default")
|
|
}
|
|
}
|
|
|
|
func TestPinnedSettingIgnoresStoredValue(t *testing.T) {
|
|
store := newMemStore(map[string]string{settings.KeyFederationEnabled: "true"})
|
|
defaults := togglableDefaults()
|
|
defaults.FederationAllowRuntimeToggle = false
|
|
|
|
svc, err := settings.New(t.Context(), store, defaults, nil)
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
if svc.FederationEnabled() {
|
|
t.Error("with allow_runtime_toggle false the config file is authoritative")
|
|
}
|
|
if err := svc.SetFederationEnabled(t.Context(), true); !errors.Is(err, settings.ErrNotToggleable) {
|
|
t.Errorf("SetFederationEnabled = %v, want ErrNotToggleable", err)
|
|
}
|
|
if _, found, _ := store.Get(t.Context(), settings.KeyFederationEnabled); !found {
|
|
t.Error("the stored value should be kept for when the toggle is re-enabled")
|
|
}
|
|
}
|
|
|
|
func TestUnparseableStoredValueFallsBackToConfig(t *testing.T) {
|
|
store := newMemStore(map[string]string{settings.KeyRegistrationEnabled: "maybe"})
|
|
defaults := togglableDefaults()
|
|
defaults.RegistrationEnabled = true
|
|
|
|
svc, err := settings.New(t.Context(), store, defaults, nil)
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
if !svc.RegistrationEnabled() {
|
|
t.Error("a corrupt stored value must fall back to the config value, not to false")
|
|
}
|
|
}
|
|
|
|
func TestSetPersistsAndNotifies(t *testing.T) {
|
|
store := newMemStore(nil)
|
|
svc, err := settings.New(t.Context(), store, togglableDefaults(), nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var got []settings.Snapshot
|
|
svc.Subscribe(func(s settings.Snapshot) { got = append(got, s) })
|
|
|
|
if err := svc.SetFederationEnabled(t.Context(), true); err != nil {
|
|
t.Fatalf("SetFederationEnabled: %v", err)
|
|
}
|
|
if !svc.FederationEnabled() {
|
|
t.Error("the cached value was not updated")
|
|
}
|
|
if v, _, _ := store.Get(t.Context(), settings.KeyFederationEnabled); v != "true" {
|
|
t.Errorf("stored value = %q, want \"true\"", v)
|
|
}
|
|
if len(got) != 1 || !got[0].FederationEnabled {
|
|
t.Fatalf("subscriber saw %v, want one snapshot with federation on", got)
|
|
}
|
|
|
|
// Setting the same value again changes nothing and must not notify.
|
|
if err := svc.SetFederationEnabled(t.Context(), true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Errorf("a no-op change notified subscribers: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestSetKeepsCacheOnStoreFailure(t *testing.T) {
|
|
store := newMemStore(nil)
|
|
store.failSet = errors.New("disk on fire")
|
|
|
|
svc, err := settings.New(t.Context(), store, togglableDefaults(), nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := svc.SetFederationEnabled(t.Context(), true); err == nil {
|
|
t.Fatal("expected the store failure to surface")
|
|
}
|
|
if svc.FederationEnabled() {
|
|
t.Error("the cached value must not change when persistence fails")
|
|
}
|
|
}
|
|
|
|
func TestSnapshotIsConsistent(t *testing.T) {
|
|
store := newMemStore(map[string]string{
|
|
settings.KeyFederationEnabled: "true",
|
|
settings.KeyRegistrationEnabled: "true",
|
|
})
|
|
svc, err := settings.New(t.Context(), store, togglableDefaults(), nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
snap := svc.Snapshot()
|
|
if !snap.FederationEnabled || !snap.RegistrationEnabled {
|
|
t.Errorf("Snapshot() = %+v, want both enabled", snap)
|
|
}
|
|
}
|
|
|
|
func TestConcurrentReadsAndWrites(t *testing.T) {
|
|
store := newMemStore(nil)
|
|
svc, err := settings.New(t.Context(), store, togglableDefaults(), nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
for i := range 8 {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
if i%2 == 0 {
|
|
_ = svc.SetFederationEnabled(t.Context(), i%4 == 0)
|
|
return
|
|
}
|
|
_ = svc.FederationEnabled()
|
|
_ = svc.Snapshot()
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
}
|