1 Commits
Author SHA1 Message Date
sneak 6c555958d6 Stop the slow test packages waiting (closes #263)
check / check (push) Failing after 3s
- Tests hash passwords at bcrypt.MinCost, set through the
  PasswordCost field of db.Params. TestPasswordCost checks the
  cost with and without it.
- Test IRC servers check the queue only on broker notification;
  one test keeps the 100 ms fallback poll.
- The stop-timeout test shortens the timeout through a test-only
  setter.
- service and handlers tests build config.Config directly instead
  of through config.New, so they run in parallel. config exports
  DefaultTrustedProxies and ParseTrustedProxies for the handlers
  test config.
- Repeated race trials run as parallel subtests.
- Fixed sleeps become waits on the broker.
- A new test covers the relay's failed queue read, which only the
  100 ms poll had reached.

Model: opus-5-5
2026-11-10 22:12:13 +00:00
11 changed files with 153 additions and 453 deletions

No files matched your search

+9 -12
View File
@@ -168,10 +168,8 @@ either.
- Request bodies for `POST /api/v1/session`, `POST /api/v1/login` and - Request bodies for `POST /api/v1/session`, `POST /api/v1/login` and
`POST /api/v1/messages` are limited to `MAX_MESSAGE_SIZE` bytes (default `POST /api/v1/messages` are limited to `MAX_MESSAGE_SIZE` bytes (default
`4096`). A larger body gets `400` `invalid request body`. `4096`). A larger body gets `400` `invalid request body`.
- `GET /api/v1/server` and `GET /.well-known/healthcheck.json` answer a request - The server allows cross-origin requests from any origin, with credentials. The
from any origin with `Access-Control-Allow-Origin: *`. Every other route sends cookie is `SameSite=Strict`, so a browser sends it only from the same site.
no `Access-Control-Allow-*` header, so a page on another origin cannot read
its responses.
### The auth cookie ### The auth cookie
@@ -1724,8 +1722,7 @@ exceptions:
A `neoirc.yaml` that is not valid YAML stops the server at startup. A setting A `neoirc.yaml` that is not valid YAML stops the server at startup. A setting
that does not parse or is out of range also stops the server at startup, and the that does not parse or is out of range also stops the server at startup, and the
error names the setting. The same holds for a setting given a list or a map in error names the setting.
`neoirc.yaml`.
- `PORT` (default `8080`): the HTTP port. `0` lets the system pick a free port. - `PORT` (default `8080`): the HTTP port. `0` lets the system pick a free port.
- `IRC_LISTEN_ADDR` (default `:6667`): the address of the IRC listener. The - `IRC_LISTEN_ADDR` (default `:6667`): the address of the IRC listener. The
@@ -1895,14 +1892,14 @@ The IRC listener has no TLS.
seconds. A request still running after 60 seconds is cancelled, and the seconds. A request still running after 60 seconds is cancelled, and the
connection closes without a response. Headers may be up to 1 MiB. connection closes without a response. Headers may be up to 1 MiB.
- A long poll waits at most 30 seconds. - A long poll waits at most 30 seconds.
- Every response to a well-formed request carries a `Content-Security-Policy` - Every response to a well-formed request, except to a cross-origin preflight,
header: carries a `Content-Security-Policy` header:
`default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; font-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'`. `default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; font-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'`.
The web client works within it. The web client works within it.
- `GET /api/v1/server` and `GET /.well-known/healthcheck.json` answer a request - Cross-origin requests are allowed from any origin, with credentials. The
from any origin with `Access-Control-Allow-Origin: *`. Every other route sends allowed methods are `GET`, `POST`, `PUT`, `DELETE` and `OPTIONS`. The allowed
no `Access-Control-Allow-*` header, so a page on another origin cannot read request headers are `Accept`, `Content-Type` and `X-CSRF-Token`. The cookie's
its responses. `SameSite=Strict` still keeps it to the same site.
### Logging ### Logging
+1 -1
View File
@@ -13,7 +13,6 @@ require (
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/rivo/tview v0.42.0 github.com/rivo/tview v0.42.0
github.com/slok/go-http-metrics v0.13.0 github.com/slok/go-http-metrics v0.13.0
github.com/spf13/cast v1.10.0
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
go.uber.org/fx v1.24.0 go.uber.org/fx v1.24.0
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb go4.org/netipx v0.0.0-20260823151212-3075585bcbeb
@@ -42,6 +41,7 @@ require (
github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/dig v1.19.0 // indirect go.uber.org/dig v1.19.0 // indirect
+57 -99
View File
@@ -12,7 +12,6 @@ import (
"strings" "strings"
"time" "time"
"github.com/spf13/cast"
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/fx" "go.uber.org/fx"
"go4.org/netipx" "go4.org/netipx"
@@ -44,7 +43,7 @@ var errOutOfRange = errors.New("setting out of range")
var errNeverMatches = errors.New("entry can never match a connection") var errNeverMatches = errors.New("entry can never match a connection")
var errListOrMap = errors.New("a list or a map") var errNotString = errors.New("not a string")
// Params defines the dependencies for creating a Config. // Params defines the dependencies for creating a Config.
type Params struct { type Params struct {
@@ -82,9 +81,8 @@ type Config struct {
} }
// New creates a new Config by reading from files and environment variables. // New creates a new Config by reading from files and environment variables.
// It returns an error when the config file does not parse, when the file // It returns an error when a number, true/false, duration or address
// gives a setting a list or a map, and when a number, true/false, // setting does not parse or is out of range.
// duration or address setting does not parse or is out of range.
func New( func New(
_ fx.Lifecycle, params Params, _ fx.Lifecycle, params Params,
) (*Config, error) { ) (*Config, error) {
@@ -123,14 +121,27 @@ func New(
if err != nil { if err != nil {
var notFound viper.ConfigFileNotFoundError var notFound viper.ConfigFileNotFoundError
if !errors.As(err, &notFound) { if !errors.As(err, &notFound) {
return nil, fmt.Errorf("cannot read %s: %w", log.Error("config file malformed", "error", err)
viper.ConfigFileUsed(), err) panic(err)
} }
} }
cfg := &Config{ cfg := &Config{
log: log, DBURL: viper.GetString("DBURL"),
params: &params, SentryDSN: viper.GetString("SENTRY_DSN"),
MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
MessageMaxAge: viper.GetString("MESSAGE_MAX_AGE"),
QueueMaxAge: viper.GetString("QUEUE_MAX_AGE"),
MOTD: viper.GetString("MOTD"),
ServerName: viper.GetString("SERVER_NAME"),
FederationKey: viper.GetString("FEDERATION_KEY"),
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
OperName: viper.GetString("NEOIRC_OPER_NAME"),
OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"),
IRCListenAddr: viper.GetString("IRC_LISTEN_ADDR"),
log: log,
params: &params,
} }
err = parseSettings(cfg) err = parseSettings(cfg)
@@ -138,6 +149,13 @@ func New(
return nil, err return nil, err
} }
// Viper counts an empty environment variable as unset, but an
// empty IRC_LISTEN_ADDR must turn the IRC listener off.
ircListenAddr, set := os.LookupEnv("IRC_LISTEN_ADDR")
if set {
cfg.IRCListenAddr = ircListenAddr
}
if cfg.Debug { if cfg.Debug {
params.Logger.EnableDebugLogging() params.Logger.EnableDebugLogging()
cfg.log = params.Logger.Get() cfg.log = params.Logger.Get()
@@ -146,24 +164,13 @@ func New(
return cfg, nil return cfg, nil
} }
// parseSettings reads every setting into cfg. Each number and duration // parseSettings reads the number, true/false and address settings into
// must be above zero. PORT and NEOIRC_HASHCASH_BITS may also be zero: // cfg and checks the duration settings. Each number and duration must be
// port 0 lets the system pick a port, and 0 bits turns proof of work off. // above zero. PORT and NEOIRC_HASHCASH_BITS may also be zero: port 0 lets
// The error names each setting that is a list or a map, does not parse or // the system pick a port, and 0 bits turns proof of work off. The error
// is out of range. // names each setting that does not parse or is out of range.
func parseSettings(cfg *Config) error { func parseSettings(cfg *Config) error {
return errors.Join( return errors.Join(
parseString("DBURL", &cfg.DBURL),
parseString("SENTRY_DSN", &cfg.SentryDSN),
parseString("METRICS_USERNAME", &cfg.MetricsUsername),
parseString("METRICS_PASSWORD", &cfg.MetricsPassword),
parseString("MOTD", &cfg.MOTD),
parseString("SERVER_NAME", &cfg.ServerName),
parseString("FEDERATION_KEY", &cfg.FederationKey),
parseString("NEOIRC_OPER_NAME", &cfg.OperName),
parseString("NEOIRC_OPER_PASSWORD", &cfg.OperPassword),
// The empty string turns the IRC listener off.
parseStringAllowEmptyEnv("IRC_LISTEN_ADDR", &cfg.IRCListenAddr),
parseInt("PORT", 0, &cfg.Port), parseInt("PORT", 0, &cfg.Port),
parseInt("MAX_MESSAGE_SIZE", 1, &cfg.MaxMessageSize), parseInt("MAX_MESSAGE_SIZE", 1, &cfg.MaxMessageSize),
parseInt("NEOIRC_HASHCASH_BITS", 0, &cfg.HashcashBits), parseInt("NEOIRC_HASHCASH_BITS", 0, &cfg.HashcashBits),
@@ -171,61 +178,30 @@ func parseSettings(cfg *Config) error {
parseFloat("LOGIN_RATE_LIMIT", &cfg.LoginRateLimit), parseFloat("LOGIN_RATE_LIMIT", &cfg.LoginRateLimit),
parseBool("DEBUG", &cfg.Debug), parseBool("DEBUG", &cfg.Debug),
parseBool("MAINTENANCE_MODE", &cfg.MaintenanceMode), parseBool("MAINTENANCE_MODE", &cfg.MaintenanceMode),
parseDuration("MESSAGE_MAX_AGE", &cfg.MessageMaxAge), checkDuration("MESSAGE_MAX_AGE", cfg.MessageMaxAge),
parseDuration("QUEUE_MAX_AGE", &cfg.QueueMaxAge), checkDuration("QUEUE_MAX_AGE", cfg.QueueMaxAge),
parseDuration("SESSION_IDLE_TIMEOUT", &cfg.SessionIdleTimeout), checkDuration("SESSION_IDLE_TIMEOUT", cfg.SessionIdleTimeout),
parseTrustedProxies(&cfg.TrustedProxies), parseTrustedProxies(&cfg.TrustedProxies),
) )
} }
// getString returns the setting key as a string. A number or a true/false
// value in the config file becomes its text, as with viper.GetString. A
// list or a map in the config file is an error, where viper.GetString
// would return the empty string. The error leaves out the value, which
// may be a password.
func getString(key string) (string, error) {
raw, err := cast.ToStringE(viper.Get(key))
if err != nil {
return "", fmt.Errorf("%s is %w", key, errListOrMap)
}
return raw, nil
}
// parseString sets *value to the setting key.
func parseString(key string, value *string) error {
raw, err := getString(key)
if err != nil {
return err
}
*value = raw
return nil
}
// parseStringAllowEmptyEnv sets *value to the setting key, as parseString
// does, but an environment variable set to the empty string counts as
// set. Viper counts such a variable as unset and uses the config file or
// the default instead.
func parseStringAllowEmptyEnv(key string, value *string) error {
fromEnv, set := os.LookupEnv(key)
if set {
*value = fromEnv
return nil
}
return parseString(key, value)
}
// parseTrustedProxies sets *value to the addresses in TRUSTED_PROXIES. // parseTrustedProxies sets *value to the addresses in TRUSTED_PROXIES.
// Viper counts an empty environment variable as unset, so the
// environment is read directly as well.
func parseTrustedProxies(value **netipx.IPSet) error { func parseTrustedProxies(value **netipx.IPSet) error {
var raw string setting := viper.Get("TRUSTED_PROXIES")
err := parseStringAllowEmptyEnv("TRUSTED_PROXIES", &raw) fromEnv, set := os.LookupEnv("TRUSTED_PROXIES")
if err != nil { if set {
return err setting = fromEnv
}
// A YAML list or map in the config file is not a string, and
// viper.GetString would turn it into the empty string.
raw, isString := setting.(string)
if !isString {
return fmt.Errorf("cannot parse TRUSTED_PROXIES: %v is %w",
setting, errNotString)
} }
ipSet, err := ParseTrustedProxies(raw) ipSet, err := ParseTrustedProxies(raw)
@@ -297,10 +273,7 @@ func addTrustedProxy(builder *netipx.IPSetBuilder, entry string) error {
// parseInt sets *value to the setting key read as a whole number. It // parseInt sets *value to the setting key read as a whole number. It
// returns an error when the value does not parse or is below minimum. // returns an error when the value does not parse or is below minimum.
func parseInt(key string, minimum int, value *int) error { func parseInt(key string, minimum int, value *int) error {
raw, err := getString(key) raw := viper.GetString(key)
if err != nil {
return err
}
parsed, err := strconv.Atoi(raw) parsed, err := strconv.Atoi(raw)
if err != nil { if err != nil {
@@ -321,10 +294,7 @@ func parseInt(key string, minimum int, value *int) error {
// number. It returns an error when the value does not parse or is not a // number. It returns an error when the value does not parse or is not a
// finite number above zero. // finite number above zero.
func parseFloat(key string, value *float64) error { func parseFloat(key string, value *float64) error {
raw, err := getString(key) raw := viper.GetString(key)
if err != nil {
return err
}
parsed, err := strconv.ParseFloat(raw, 64) parsed, err := strconv.ParseFloat(raw, 64)
if err != nil { if err != nil {
@@ -344,12 +314,7 @@ func parseFloat(key string, value *float64) error {
// parseBool sets *value to the setting key read as true or false. // parseBool sets *value to the setting key read as true or false.
func parseBool(key string, value *bool) error { func parseBool(key string, value *bool) error {
raw, err := getString(key) parsed, err := strconv.ParseBool(viper.GetString(key))
if err != nil {
return err
}
parsed, err := strconv.ParseBool(raw)
if err != nil { if err != nil {
return fmt.Errorf("cannot parse %s: %w", key, err) return fmt.Errorf("cannot parse %s: %w", key, err)
} }
@@ -359,25 +324,18 @@ func parseBool(key string, value *bool) error {
return nil return nil
} }
// parseDuration sets *value to the setting key. It returns an error when // checkDuration returns an error naming the setting key when value is
// the value is not a Go duration above zero. // not a Go duration above zero.
func parseDuration(key string, value *string) error { func checkDuration(key, value string) error {
raw, err := getString(key) duration, err := time.ParseDuration(value)
if err != nil {
return err
}
duration, err := time.ParseDuration(raw)
if err != nil { if err != nil {
return fmt.Errorf("cannot parse %s: %w", key, err) return fmt.Errorf("cannot parse %s: %w", key, err)
} }
if duration <= 0 { if duration <= 0 {
return fmt.Errorf("%w: %s is %s, but must be above zero", return fmt.Errorf("%w: %s is %s, but must be above zero",
errOutOfRange, key, raw) errOutOfRange, key, value)
} }
*value = raw
return nil return nil
} }
+35 -139
View File
@@ -287,153 +287,49 @@ func TestTrustedProxies(t *testing.T) {
} }
} }
// unsetEnv removes the environment variable key for the rest of the func TestTrustedProxiesNotAStringInYAMLStopsStartup(t *testing.T) {
// test, so that config.New reads key from the config file.
func unsetEnv(t *testing.T, key string) {
t.Helper()
// t.Setenv puts back whatever value was there after the test,
// including when Unsetenv removes it.
t.Setenv(key, "")
err := os.Unsetenv(key)
if err != nil {
t.Fatal(err)
}
}
// writeConfigFile writes content to the config file config.New reads
// when HOME is home, and returns the file's path.
func writeConfigFile(t *testing.T, home, content string) string {
t.Helper()
// loadConfig names the app neoirc-test, so config.New reads
// $HOME/.config/neoirc-test/neoirc-test.yaml.
dir := filepath.Join(home, ".config", "neoirc-test")
err := os.MkdirAll(dir, 0o700)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "neoirc-test.yaml")
err = os.WriteFile(path, []byte(content), 0o600)
if err != nil {
t.Fatal(err)
}
// Viper keeps the file and the values it read from it after the
// file is gone. Reset clears them for later tests.
t.Cleanup(viper.Reset)
return path
}
func TestListOrMapInConfigFileStopsStartup(t *testing.T) {
// YAML reads the first value as a list and the second as a map. // YAML reads the first value as a list and the second as a map.
values := []string{`[":6667"]`, `{addr: ":6667"}`} values := []string{"[10.0.0.1]", "{proxy: 10.0.0.1}"}
keys := []string{"IRC_LISTEN_ADDR", "MOTD", "TRUSTED_PROXIES"}
for _, key := range keys { for _, value := range values {
for _, value := range values { t.Run(value, func(t *testing.T) {
t.Run(key+"="+value, func(t *testing.T) { // t.Setenv puts back whatever value was there after the
home := t.TempDir() // test, including when Unsetenv removes it.
t.Setenv("HOME", home) t.Setenv("TRUSTED_PROXIES", "")
unsetEnv(t, key)
writeConfigFile(t, home, key+": "+value+"\n")
_, err := loadConfig(t) err := os.Unsetenv("TRUSTED_PROXIES")
if err == nil || !strings.Contains(err.Error(), key) { if err != nil {
t.Errorf("error = %v, want one naming %s", err, key) t.Fatal(err)
} }
})
}
}
}
func TestListOrMapErrorLeavesOutValue(t *testing.T) { // loadConfig names the app neoirc-test, so config.New reads
const key = "NEOIRC_OPER_PASSWORD" // $HOME/.config/neoirc-test/neoirc-test.yaml.
home := t.TempDir()
t.Setenv("HOME", home)
unsetEnv(t, key)
// YAML reads an unquoted {hunter2} as a map.
writeConfigFile(t, home, key+": {hunter2}\n")
_, err := loadConfig(t)
if err == nil || !strings.Contains(err.Error(), key) {
t.Fatalf("error = %v, want one naming %s", err, key)
}
if strings.Contains(err.Error(), "hunter2") {
t.Errorf("error %q shows the password", err)
}
}
func TestEmptyEnvironmentValueWinsOverConfigFile(t *testing.T) {
const (
ircListenAddr = "IRC_LISTEN_ADDR"
trustedProxies = "TRUSTED_PROXIES"
)
// Each setting is the empty string in the environment and a list in
// the config file. The environment wins, so the list is never read.
tests := []struct {
key string
empty func(cfg *config.Config) bool
}{
{
key: ircListenAddr,
empty: func(cfg *config.Config) bool {
return cfg.IRCListenAddr == ""
},
},
{
key: trustedProxies,
empty: func(cfg *config.Config) bool {
return len(cfg.TrustedProxies.Prefixes()) == 0
},
},
}
for _, test := range tests {
t.Run(test.key, func(t *testing.T) {
home := t.TempDir() home := t.TempDir()
t.Setenv("HOME", home) t.Setenv("HOME", home)
t.Setenv(test.key, "")
writeConfigFile(t, home, test.key+": [\":6667\"]\n")
cfg := newConfig(t) dir := filepath.Join(home, ".config", "neoirc-test")
if !test.empty(cfg) {
t.Errorf("%s is not the empty string", test.key) err = os.MkdirAll(dir, 0o700)
if err != nil {
t.Fatal(err)
}
err = os.WriteFile(
filepath.Join(dir, "neoirc-test.yaml"),
[]byte("TRUSTED_PROXIES: "+value+"\n"), 0o600,
)
if err != nil {
t.Fatal(err)
}
// Viper keeps the values it read from this file after the
// file is gone. Reset clears them for later tests.
t.Cleanup(viper.Reset)
_, err = loadConfig(t)
if err == nil || !strings.Contains(err.Error(), "TRUSTED_PROXIES") {
t.Errorf("error = %v, want one naming TRUSTED_PROXIES", err)
} }
}) })
} }
} }
func TestSingleValuesInConfigFileAreUsed(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
unsetEnv(t, "PORT")
unsetEnv(t, "DEBUG")
unsetEnv(t, "MOTD")
writeConfigFile(t, home, "PORT: 9000\nDEBUG: true\nMOTD: hello\n")
cfg := newConfig(t)
if cfg.Port != 9000 || !cfg.Debug || cfg.MOTD != "hello" {
t.Errorf("Port, Debug, MOTD = %d, %t, %q, want 9000, true, %q",
cfg.Port, cfg.Debug, cfg.MOTD, "hello")
}
}
func TestConfigFileThatDoesNotParseStopsStartup(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
path := writeConfigFile(t, home, "MOTD: [unclosed\n")
_, err := loadConfig(t)
if err == nil || !strings.Contains(err.Error(), path) {
t.Errorf("error = %v, want one naming %s", err, path)
}
}
+1 -5
View File
@@ -27,11 +27,7 @@ const (
(SELECT 1 UNION ALL SELECT x + 1 FROM n) (SELECT 1 UNION ALL SELECT x + 1 FROM n)
SELECT COUNT(*) FROM n` SELECT COUNT(*) FROM n`
// endlessQueryTimeout is how long the endless query runs endlessQueryTimeout = 100 * time.Millisecond
// before its context ends. A query whose context ends before
// it starts is never run, and the pool keeps its connection,
// so the timeout leaves a loaded host time to start it.
endlessQueryTimeout = 2 * time.Second
) )
// TestDefaultDBURLSetsWALAndBusyTimeout opens a file database // TestDefaultDBURLSetsWALAndBusyTimeout opens a file database
+27 -67
View File
@@ -145,19 +145,9 @@ type testServer struct {
handlers *handlers.Handlers handlers *handlers.Handlers
} }
const ( // maxMessageSize is MAX_MESSAGE_SIZE on every test server, the
// maxMessageSize is MAX_MESSAGE_SIZE on every test server, the // setting's default.
// setting's default. const maxMessageSize = 4096
maxMessageSize = 4096
// loginRateLimit is LOGIN_RATE_LIMIT on every test server, the
// setting's default.
loginRateLimit = 1
// loginRateBurst is LOGIN_RATE_BURST on every test server, the
// setting's default.
loginRateBurst = 5
)
func newTestServer( func newTestServer(
t *testing.T, t *testing.T,
@@ -198,7 +188,7 @@ func newTestServerWith(
logger.New, logger.New,
// Built here rather than by config.New, which writes // Built here rather than by config.New, which writes
// the global viper instance, so servers start in // the global viper instance, so servers start in
// parallel. // parallel. Settings left out take their defaults.
func() *config.Config { func() *config.Config {
return &config.Config{ return &config.Config{
DBURL: dbURL, DBURL: dbURL,
@@ -211,8 +201,6 @@ func newTestServerWith(
MessageMaxAge: thirtyDays, MessageMaxAge: thirtyDays,
QueueMaxAge: thirtyDays, QueueMaxAge: thirtyDays,
SessionIdleTimeout: thirtyDays, SessionIdleTimeout: thirtyDays,
LoginRateLimit: loginRateLimit,
LoginRateBurst: loginRateBurst,
TrustedProxies: trusted, TrustedProxies: trusted,
} }
}, },
@@ -3041,18 +3029,18 @@ func TestGetMessagesRemovesWaitChannel(t *testing.T) {
longTimeout = 10 longTimeout = 10
// shortTimeout, in seconds, is the timeout of the // shortTimeout, in seconds, is the timeout of the
// request that waits it out. // requests that wait it out.
shortTimeout = 1 shortTimeout = 1
) )
// queued is whether a message is queued before the request, // queued is whether a message is queued before the request,
// whose timeout is in seconds. failingRead and notified are // whose timeout is in seconds. failingRead is as for
// as for getMessagesFailingRead. wantReads is how many times // getMessagesFailingRead. wantReads is how many times the
// the handler reads the queue: once if it answers at once, // handler reads the queue: once if it answers at once,
// twice if it waits first. // twice if it waits first.
tests := []struct { tests := []struct {
name string name string
queued, notified bool queued bool
timeout, failingRead int timeout, failingRead int
wantReads, wantStatus int wantReads, wantStatus int
}{ }{
@@ -3073,9 +3061,9 @@ func TestGetMessagesRemovesWaitChannel(t *testing.T) {
wantReads: 1, wantStatus: http.StatusInternalServerError, wantReads: 1, wantStatus: http.StatusInternalServerError,
}, },
{ {
name: "read after the wait failed", notified: true, name: "read after the wait failed", timeout: shortTimeout,
timeout: handlers.MaxLongPollTimeout, failingRead: 2, failingRead: 2, wantReads: 2,
wantReads: 2, wantStatus: http.StatusInternalServerError, wantStatus: http.StatusInternalServerError,
}, },
} }
@@ -3107,7 +3095,7 @@ func TestGetMessagesRemovesWaitChannel(t *testing.T) {
} }
status, reads := tserver.getMessagesFailingRead( status, reads := tserver.getMessagesFailingRead(
token, sessionID, lastID, tc.timeout, tc.failingRead, tc.notified, token, lastID, tc.timeout, tc.failingRead,
) )
if status != tc.wantStatus { if status != tc.wantStatus {
t.Errorf("status %d, want %d", status, tc.wantStatus) t.Errorf("status %d, want %d", status, tc.wantStatus)
@@ -3124,17 +3112,14 @@ func TestGetMessagesRemovesWaitChannel(t *testing.T) {
} }
// getMessagesFailingRead sends GET /api/v1/messages for the // getMessagesFailingRead sends GET /api/v1/messages for the
// client token of the session sessionID, with timeout and // client token with timeout and after afterID. The handler's
// after afterID. The handler's read of the queue numbered // read of the queue numbered failingRead, counting from 1,
// failingRead, counting from 1, fails; with 0, none does. // fails; with 0, none does. It returns the response status
// With notified, it notifies the broker once the request // and how many times the handler read the queue.
// waits. It returns the response status and how many times
// the handler read the queue.
func (tserver *testServer) getMessagesFailingRead( func (tserver *testServer) getMessagesFailingRead(
token string, token string,
sessionID, afterID int64, afterID int64,
timeout, failingRead int, timeout, failingRead int,
notified bool,
) (int, int) { ) (int, int) {
tserver.t.Helper() tserver.t.Helper()
@@ -3156,45 +3141,20 @@ func (tserver *testServer) getMessagesFailingRead(
) )
}) })
var ( resp, err := doRequestAuth(
status int tserver.t, http.MethodGet,
err error tserver.url(fmt.Sprintf(
"%s?timeout=%d&after=%d", apiMessages, timeout, afterID,
)),
token, nil,
) )
answered := make(chan struct{})
go func() {
defer close(answered)
var resp *http.Response
resp, err = doRequestAuth(
tserver.t, http.MethodGet,
tserver.url(fmt.Sprintf(
"%s?timeout=%d&after=%d", apiMessages, timeout, afterID,
)),
token, nil,
)
if err != nil {
return
}
status = resp.StatusCode
_ = resp.Body.Close()
}()
if notified {
tserver.waitForLongPoll(sessionID)
tserver.broker.Notify(sessionID)
}
<-answered
if err != nil { if err != nil {
tserver.t.Fatalf("get messages: %v", err) tserver.t.Fatalf("get messages: %v", err)
} }
return status, reads _ = resp.Body.Close()
return resp.StatusCode, reads
} }
// TestLongPollRemovesWaitChannelWhenClientGivesUp checks that // TestLongPollRemovesWaitChannelWhenClientGivesUp checks that
-4
View File
@@ -6,10 +6,6 @@ import (
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
) )
// MaxLongPollTimeout is the longest HandleGetMessages waits, in
// seconds.
const MaxLongPollTimeout = maxLongPollTimeout
// SetPollMessages makes HandleGetMessages read the client // SetPollMessages makes HandleGetMessages read the client
// output queue with poll in place of Database.PollMessages. // output queue with poll in place of Database.PollMessages.
func (hdlr *Handlers) SetPollMessages( func (hdlr *Handlers) SetPollMessages(
+10 -35
View File
@@ -31,10 +31,7 @@ import (
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
// testTimeout is how long a test waits for something before it const testTimeout = 5 * time.Second
// fails. A passing test never waits this long, so it is generous
// enough for a heavily loaded host.
const testTimeout = 30 * time.Second
// testPollInterval is how often the relay of a test server // testPollInterval is how often the relay of a test server
// checks the client output queue without a broker // checks the client output queue without a broker
@@ -568,49 +565,27 @@ func TestQuietClientAnsweringPingStaysConnected(t *testing.T) {
func TestQuietClientNotAnsweringPingIsClosed(t *testing.T) { func TestQuietClientNotAnsweringPingIsClosed(t *testing.T) {
t.Parallel() t.Parallel()
// A read deadline of two ping intervals would close the // latest leaves room for a loaded host, but is sooner than
// connection a second after latest. The ping interval is // a read deadline of two ping intervals would close it.
// longer than testPingInterval, so that the time between
// the expected close and latest leaves room for a loaded
// host.
const ( const (
interval = 4 * time.Second earliest = testPingInterval + testPongDeadline
earliest = interval + testPongDeadline latest = 2 * testPingInterval
latest = 2*interval - time.Second
) )
env := newTestEnvWithPingTimes( env := newTestEnvWithShortPings(t)
t,
&config.Config{ServerName: testServerName},
interval, testPongDeadline,
)
client := env.dial(t) client := env.dial(t)
lastSent := time.Now() lastSent := time.Now()
client.register("silent") client.register("silent")
client.readServerPing(lastSent)
// The server starts both waits once it has handled the
// registration, so the time registration took does not
// count against latest.
registered := time.Now()
client.readUntil(isServerPing)
client.readUntilClosed() client.readUntilClosed()
waited := time.Since(lastSent) waited := time.Since(lastSent)
if waited < earliest { if waited < earliest || waited >= latest {
t.Errorf( t.Errorf(
"closed %v after the client's last line, want at least %v", "closed %v after the client's last line, want %v to %v",
waited, earliest, waited, earliest, latest,
)
}
sinceRegistered := time.Since(registered)
if sinceRegistered >= latest {
t.Errorf(
"closed %v after registration, want less than %v",
sinceRegistered, latest,
) )
} }
} }
+10 -5
View File
@@ -200,19 +200,24 @@ func (mware *Middleware) Logging() func(http.Handler) http.Handler {
} }
} }
// CORS returns middleware that lets a page on any origin read the // CORS returns middleware that handles Cross-Origin Resource Sharing.
// response. Use it only on public, read-only routes. // AllowCredentials is true so browsers include cookies in
// cross-origin API requests.
func (mware *Middleware) CORS() func(http.Handler) http.Handler { func (mware *Middleware) CORS() func(http.Handler) http.Handler {
return cors.Handler(cors.Options{ //nolint:exhaustruct // optional fields return cors.Handler(cors.Options{ //nolint:exhaustruct // optional fields
AllowedOrigins: []string{"*"}, AllowOriginFunc: func(
_ *http.Request, _ string,
) bool {
return true
},
AllowedMethods: []string{ AllowedMethods: []string{
"GET", "POST", "PUT", "DELETE", "OPTIONS", "GET", "POST", "PUT", "DELETE", "OPTIONS",
}, },
AllowedHeaders: []string{ AllowedHeaders: []string{
"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "Accept", "Content-Type", "X-CSRF-Token",
}, },
ExposedHeaders: []string{"Link"}, ExposedHeaders: []string{"Link"},
AllowCredentials: false, AllowCredentials: true,
MaxAge: corsMaxAge, MaxAge: corsMaxAge,
}) })
} }
+3 -2
View File
@@ -30,6 +30,7 @@ func (srv *Server) SetupRoutes() {
srv.router.Use(srv.mw.Metrics()) srv.router.Use(srv.mw.Metrics())
} }
srv.router.Use(srv.mw.CORS())
srv.router.Use(srv.mw.CSP()) srv.router.Use(srv.mw.CSP())
srv.router.Use(middleware.Timeout(routeTimeout)) srv.router.Use(middleware.Timeout(routeTimeout))
@@ -44,7 +45,7 @@ func (srv *Server) SetupRoutes() {
} }
// Health check. // Health check.
srv.router.With(srv.mw.CORS()).Get( srv.router.Get(
"/.well-known/healthcheck.json", "/.well-known/healthcheck.json",
srv.handlers.HandleHealthCheck(), srv.handlers.HandleHealthCheck(),
) )
@@ -68,7 +69,7 @@ func (srv *Server) SetupRoutes() {
} }
func (srv *Server) setupAPIv1(router chi.Router) { func (srv *Server) setupAPIv1(router chi.Router) {
router.With(srv.mw.CORS()).Get( router.Get(
"/server", "/server",
srv.handlers.HandleServerInfo(), srv.handlers.HandleServerInfo(),
) )
-84
View File
@@ -6,10 +6,8 @@ package server_test
import ( import (
"fmt" "fmt"
"maps"
"net" "net"
"net/http" "net/http"
"net/http/httptest"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
@@ -190,88 +188,6 @@ func TestServeAfterStopDoesNotStopApp(t *testing.T) {
} }
} }
// TestCORSHeaders checks the Access-Control-Allow-* headers on the
// answers to requests from another origin. The public, read-only
// routes answer with Access-Control-Allow-Origin: * alone. Routes
// that need the cookie, and a preflight to one, answer with none.
func TestCORSHeaders(t *testing.T) {
var srv *server.Server
startApp(t, fx.Populate(&srv))
tests := []struct {
name string
method string
path string
requestMethod string
wantHeaders map[string]string
}{
{
name: "server information",
method: http.MethodGet,
path: "/api/v1/server",
wantHeaders: map[string]string{"Access-Control-Allow-Origin": "*"},
},
{
name: "health check",
method: http.MethodGet,
path: "/.well-known/healthcheck.json",
wantHeaders: map[string]string{"Access-Control-Allow-Origin": "*"},
},
{
name: "send",
method: http.MethodPost,
path: "/api/v1/messages",
},
{
name: "state",
method: http.MethodGet,
path: "/api/v1/state",
},
{
name: "preflight for send",
method: http.MethodOptions,
path: "/api/v1/messages",
requestMethod: http.MethodPost,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
request := httptest.NewRequestWithContext(
t.Context(), test.method,
"http://chat.example.com"+test.path, nil,
)
request.Header.Set("Origin", "http://blog.example.com")
if test.requestMethod != "" {
request.Header.Set(
"Access-Control-Request-Method", test.requestMethod,
)
}
recorder := httptest.NewRecorder()
srv.ServeHTTP(recorder, request)
gotHeaders := map[string]string{}
for name := range recorder.Header() {
if strings.HasPrefix(name, "Access-Control-Allow-") {
gotHeaders[name] = recorder.Header().Get(name)
}
}
if !maps.Equal(gotHeaders, test.wantHeaders) {
t.Errorf(
"Access-Control-Allow-* headers are %v, want %v",
gotHeaders, test.wantHeaders,
)
}
})
}
}
// startApp starts the app newApp builds, with the HTTP port // startApp starts the app newApp builds, with the HTTP port
// set to 0, so the system gives the server a free port. // set to 0, so the system gives the server a free port.
func startApp( func startApp(