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
`POST /api/v1/messages` are limited to `MAX_MESSAGE_SIZE` bytes (default
`4096`). A larger body gets `400` `invalid request body`.
- `GET /api/v1/server` and `GET /.well-known/healthcheck.json` answer a request
from any origin with `Access-Control-Allow-Origin: *`. Every other route sends
no `Access-Control-Allow-*` header, so a page on another origin cannot read
its responses.
- The server allows cross-origin requests from any origin, with credentials. The
cookie is `SameSite=Strict`, so a browser sends it only from the same site.
### The auth cookie
@@ -1724,8 +1722,7 @@ exceptions:
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
error names the setting. The same holds for a setting given a list or a map in
`neoirc.yaml`.
error names the setting.
- `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
@@ -1895,14 +1892,14 @@ The IRC listener has no TLS.
seconds. A request still running after 60 seconds is cancelled, and the
connection closes without a response. Headers may be up to 1 MiB.
- A long poll waits at most 30 seconds.
- Every response to a well-formed request carries a `Content-Security-Policy`
header:
- Every response to a well-formed request, except to a cross-origin preflight,
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'`.
The web client works within it.
- `GET /api/v1/server` and `GET /.well-known/healthcheck.json` answer a request
from any origin with `Access-Control-Allow-Origin: *`. Every other route sends
no `Access-Control-Allow-*` header, so a page on another origin cannot read
its responses.
- Cross-origin requests are allowed from any origin, with credentials. The
allowed methods are `GET`, `POST`, `PUT`, `DELETE` and `OPTIONS`. The allowed
request headers are `Accept`, `Content-Type` and `X-CSRF-Token`. The cookie's
`SameSite=Strict` still keeps it to the same site.
### Logging
+1 -1
View File
@@ -13,7 +13,6 @@ require (
github.com/prometheus/client_golang v1.23.2
github.com/rivo/tview v0.42.0
github.com/slok/go-http-metrics v0.13.0
github.com/spf13/cast v1.10.0
github.com/spf13/viper v1.21.0
go.uber.org/fx v1.24.0
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb
@@ -42,6 +41,7 @@ require (
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // 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/subosito/gotenv v1.6.0 // indirect
go.uber.org/dig v1.19.0 // indirect
+57 -99
View File
@@ -12,7 +12,6 @@ import (
"strings"
"time"
"github.com/spf13/cast"
"github.com/spf13/viper"
"go.uber.org/fx"
"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 errListOrMap = errors.New("a list or a map")
var errNotString = errors.New("not a string")
// Params defines the dependencies for creating a Config.
type Params struct {
@@ -82,9 +81,8 @@ type Config struct {
}
// 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
// gives a setting a list or a map, and when a number, true/false,
// duration or address setting does not parse or is out of range.
// It returns an error when a number, true/false, duration or address
// setting does not parse or is out of range.
func New(
_ fx.Lifecycle, params Params,
) (*Config, error) {
@@ -123,14 +121,27 @@ func New(
if err != nil {
var notFound viper.ConfigFileNotFoundError
if !errors.As(err, &notFound) {
return nil, fmt.Errorf("cannot read %s: %w",
viper.ConfigFileUsed(), err)
log.Error("config file malformed", "error", err)
panic(err)
}
}
cfg := &Config{
log: log,
params: &params,
DBURL: viper.GetString("DBURL"),
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)
@@ -138,6 +149,13 @@ func New(
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 {
params.Logger.EnableDebugLogging()
cfg.log = params.Logger.Get()
@@ -146,24 +164,13 @@ func New(
return cfg, nil
}
// parseSettings reads every setting into cfg. Each number and duration
// must be above zero. PORT and NEOIRC_HASHCASH_BITS may also be zero:
// port 0 lets the system pick a port, and 0 bits turns proof of work off.
// The error names each setting that is a list or a map, does not parse or
// is out of range.
// parseSettings reads the number, true/false and address settings into
// cfg and checks the duration settings. Each number and duration must be
// above zero. PORT and NEOIRC_HASHCASH_BITS may also be zero: port 0 lets
// the system pick a port, and 0 bits turns proof of work off. The error
// names each setting that does not parse or is out of range.
func parseSettings(cfg *Config) error {
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("MAX_MESSAGE_SIZE", 1, &cfg.MaxMessageSize),
parseInt("NEOIRC_HASHCASH_BITS", 0, &cfg.HashcashBits),
@@ -171,61 +178,30 @@ func parseSettings(cfg *Config) error {
parseFloat("LOGIN_RATE_LIMIT", &cfg.LoginRateLimit),
parseBool("DEBUG", &cfg.Debug),
parseBool("MAINTENANCE_MODE", &cfg.MaintenanceMode),
parseDuration("MESSAGE_MAX_AGE", &cfg.MessageMaxAge),
parseDuration("QUEUE_MAX_AGE", &cfg.QueueMaxAge),
parseDuration("SESSION_IDLE_TIMEOUT", &cfg.SessionIdleTimeout),
checkDuration("MESSAGE_MAX_AGE", cfg.MessageMaxAge),
checkDuration("QUEUE_MAX_AGE", cfg.QueueMaxAge),
checkDuration("SESSION_IDLE_TIMEOUT", cfg.SessionIdleTimeout),
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.
// Viper counts an empty environment variable as unset, so the
// environment is read directly as well.
func parseTrustedProxies(value **netipx.IPSet) error {
var raw string
setting := viper.Get("TRUSTED_PROXIES")
err := parseStringAllowEmptyEnv("TRUSTED_PROXIES", &raw)
if err != nil {
return err
fromEnv, set := os.LookupEnv("TRUSTED_PROXIES")
if set {
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)
@@ -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
// returns an error when the value does not parse or is below minimum.
func parseInt(key string, minimum int, value *int) error {
raw, err := getString(key)
if err != nil {
return err
}
raw := viper.GetString(key)
parsed, err := strconv.Atoi(raw)
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
// finite number above zero.
func parseFloat(key string, value *float64) error {
raw, err := getString(key)
if err != nil {
return err
}
raw := viper.GetString(key)
parsed, err := strconv.ParseFloat(raw, 64)
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.
func parseBool(key string, value *bool) error {
raw, err := getString(key)
if err != nil {
return err
}
parsed, err := strconv.ParseBool(raw)
parsed, err := strconv.ParseBool(viper.GetString(key))
if err != nil {
return fmt.Errorf("cannot parse %s: %w", key, err)
}
@@ -359,25 +324,18 @@ func parseBool(key string, value *bool) error {
return nil
}
// parseDuration sets *value to the setting key. It returns an error when
// the value is not a Go duration above zero.
func parseDuration(key string, value *string) error {
raw, err := getString(key)
if err != nil {
return err
}
duration, err := time.ParseDuration(raw)
// checkDuration returns an error naming the setting key when value is
// not a Go duration above zero.
func checkDuration(key, value string) error {
duration, err := time.ParseDuration(value)
if err != nil {
return fmt.Errorf("cannot parse %s: %w", key, err)
}
if duration <= 0 {
return fmt.Errorf("%w: %s is %s, but must be above zero",
errOutOfRange, key, raw)
errOutOfRange, key, value)
}
*value = raw
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
// 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) {
func TestTrustedProxiesNotAStringInYAMLStopsStartup(t *testing.T) {
// YAML reads the first value as a list and the second as a map.
values := []string{`[":6667"]`, `{addr: ":6667"}`}
keys := []string{"IRC_LISTEN_ADDR", "MOTD", "TRUSTED_PROXIES"}
values := []string{"[10.0.0.1]", "{proxy: 10.0.0.1}"}
for _, key := range keys {
for _, value := range values {
t.Run(key+"="+value, func(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
unsetEnv(t, key)
writeConfigFile(t, home, key+": "+value+"\n")
for _, value := range values {
t.Run(value, func(t *testing.T) {
// t.Setenv puts back whatever value was there after the
// test, including when Unsetenv removes it.
t.Setenv("TRUSTED_PROXIES", "")
_, err := loadConfig(t)
if err == nil || !strings.Contains(err.Error(), key) {
t.Errorf("error = %v, want one naming %s", err, key)
}
})
}
}
}
err := os.Unsetenv("TRUSTED_PROXIES")
if err != nil {
t.Fatal(err)
}
func TestListOrMapErrorLeavesOutValue(t *testing.T) {
const key = "NEOIRC_OPER_PASSWORD"
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) {
// loadConfig names the app neoirc-test, so config.New reads
// $HOME/.config/neoirc-test/neoirc-test.yaml.
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv(test.key, "")
writeConfigFile(t, home, test.key+": [\":6667\"]\n")
cfg := newConfig(t)
if !test.empty(cfg) {
t.Errorf("%s is not the empty string", test.key)
dir := filepath.Join(home, ".config", "neoirc-test")
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 COUNT(*) FROM n`
// endlessQueryTimeout is how long the endless query runs
// 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
endlessQueryTimeout = 100 * time.Millisecond
)
// TestDefaultDBURLSetsWALAndBusyTimeout opens a file database
+27 -67
View File
@@ -145,19 +145,9 @@ type testServer struct {
handlers *handlers.Handlers
}
const (
// maxMessageSize is MAX_MESSAGE_SIZE on every test server, the
// setting's default.
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
)
// maxMessageSize is MAX_MESSAGE_SIZE on every test server, the
// setting's default.
const maxMessageSize = 4096
func newTestServer(
t *testing.T,
@@ -198,7 +188,7 @@ func newTestServerWith(
logger.New,
// Built here rather than by config.New, which writes
// the global viper instance, so servers start in
// parallel.
// parallel. Settings left out take their defaults.
func() *config.Config {
return &config.Config{
DBURL: dbURL,
@@ -211,8 +201,6 @@ func newTestServerWith(
MessageMaxAge: thirtyDays,
QueueMaxAge: thirtyDays,
SessionIdleTimeout: thirtyDays,
LoginRateLimit: loginRateLimit,
LoginRateBurst: loginRateBurst,
TrustedProxies: trusted,
}
},
@@ -3041,18 +3029,18 @@ func TestGetMessagesRemovesWaitChannel(t *testing.T) {
longTimeout = 10
// shortTimeout, in seconds, is the timeout of the
// request that waits it out.
// requests that wait it out.
shortTimeout = 1
)
// queued is whether a message is queued before the request,
// whose timeout is in seconds. failingRead and notified are
// as for getMessagesFailingRead. wantReads is how many times
// the handler reads the queue: once if it answers at once,
// whose timeout is in seconds. failingRead is as for
// getMessagesFailingRead. wantReads is how many times the
// handler reads the queue: once if it answers at once,
// twice if it waits first.
tests := []struct {
name string
queued, notified bool
queued bool
timeout, failingRead int
wantReads, wantStatus int
}{
@@ -3073,9 +3061,9 @@ func TestGetMessagesRemovesWaitChannel(t *testing.T) {
wantReads: 1, wantStatus: http.StatusInternalServerError,
},
{
name: "read after the wait failed", notified: true,
timeout: handlers.MaxLongPollTimeout, failingRead: 2,
wantReads: 2, wantStatus: http.StatusInternalServerError,
name: "read after the wait failed", timeout: shortTimeout,
failingRead: 2, wantReads: 2,
wantStatus: http.StatusInternalServerError,
},
}
@@ -3107,7 +3095,7 @@ func TestGetMessagesRemovesWaitChannel(t *testing.T) {
}
status, reads := tserver.getMessagesFailingRead(
token, sessionID, lastID, tc.timeout, tc.failingRead, tc.notified,
token, lastID, tc.timeout, tc.failingRead,
)
if 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
// client token of the session sessionID, with timeout and
// after afterID. The handler's read of the queue numbered
// failingRead, counting from 1, fails; with 0, none does.
// With notified, it notifies the broker once the request
// waits. It returns the response status and how many times
// the handler read the queue.
// client token with timeout and after afterID. The handler's
// read of the queue numbered failingRead, counting from 1,
// fails; with 0, none does. It returns the response status
// and how many times the handler read the queue.
func (tserver *testServer) getMessagesFailingRead(
token string,
sessionID, afterID int64,
afterID int64,
timeout, failingRead int,
notified bool,
) (int, int) {
tserver.t.Helper()
@@ -3156,45 +3141,20 @@ func (tserver *testServer) getMessagesFailingRead(
)
})
var (
status int
err error
resp, err := doRequestAuth(
tserver.t, http.MethodGet,
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 {
tserver.t.Fatalf("get messages: %v", err)
}
return status, reads
_ = resp.Body.Close()
return resp.StatusCode, reads
}
// TestLongPollRemovesWaitChannelWhenClientGivesUp checks that
-4
View File
@@ -6,10 +6,6 @@ import (
"sneak.berlin/go/neoirc/internal/db"
)
// MaxLongPollTimeout is the longest HandleGetMessages waits, in
// seconds.
const MaxLongPollTimeout = maxLongPollTimeout
// SetPollMessages makes HandleGetMessages read the client
// output queue with poll in place of Database.PollMessages.
func (hdlr *Handlers) SetPollMessages(
+10 -35
View File
@@ -31,10 +31,7 @@ import (
_ "modernc.org/sqlite"
)
// testTimeout is how long a test waits for something before it
// fails. A passing test never waits this long, so it is generous
// enough for a heavily loaded host.
const testTimeout = 30 * time.Second
const testTimeout = 5 * time.Second
// testPollInterval is how often the relay of a test server
// checks the client output queue without a broker
@@ -568,49 +565,27 @@ func TestQuietClientAnsweringPingStaysConnected(t *testing.T) {
func TestQuietClientNotAnsweringPingIsClosed(t *testing.T) {
t.Parallel()
// A read deadline of two ping intervals would close the
// connection a second after latest. The ping interval is
// longer than testPingInterval, so that the time between
// the expected close and latest leaves room for a loaded
// host.
// latest leaves room for a loaded host, but is sooner than
// a read deadline of two ping intervals would close it.
const (
interval = 4 * time.Second
earliest = interval + testPongDeadline
latest = 2*interval - time.Second
earliest = testPingInterval + testPongDeadline
latest = 2 * testPingInterval
)
env := newTestEnvWithPingTimes(
t,
&config.Config{ServerName: testServerName},
interval, testPongDeadline,
)
env := newTestEnvWithShortPings(t)
client := env.dial(t)
lastSent := time.Now()
client.register("silent")
// 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.readServerPing(lastSent)
client.readUntilClosed()
waited := time.Since(lastSent)
if waited < earliest {
if waited < earliest || waited >= latest {
t.Errorf(
"closed %v after the client's last line, want at least %v",
waited, earliest,
)
}
sinceRegistered := time.Since(registered)
if sinceRegistered >= latest {
t.Errorf(
"closed %v after registration, want less than %v",
sinceRegistered, latest,
"closed %v after the client's last line, want %v to %v",
waited, earliest, 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
// response. Use it only on public, read-only routes.
// CORS returns middleware that handles Cross-Origin Resource Sharing.
// AllowCredentials is true so browsers include cookies in
// cross-origin API requests.
func (mware *Middleware) CORS() func(http.Handler) http.Handler {
return cors.Handler(cors.Options{ //nolint:exhaustruct // optional fields
AllowedOrigins: []string{"*"},
AllowOriginFunc: func(
_ *http.Request, _ string,
) bool {
return true
},
AllowedMethods: []string{
"GET", "POST", "PUT", "DELETE", "OPTIONS",
},
AllowedHeaders: []string{
"Accept", "Authorization", "Content-Type", "X-CSRF-Token",
"Accept", "Content-Type", "X-CSRF-Token",
},
ExposedHeaders: []string{"Link"},
AllowCredentials: false,
AllowCredentials: true,
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.CORS())
srv.router.Use(srv.mw.CSP())
srv.router.Use(middleware.Timeout(routeTimeout))
@@ -44,7 +45,7 @@ func (srv *Server) SetupRoutes() {
}
// Health check.
srv.router.With(srv.mw.CORS()).Get(
srv.router.Get(
"/.well-known/healthcheck.json",
srv.handlers.HandleHealthCheck(),
)
@@ -68,7 +69,7 @@ func (srv *Server) SetupRoutes() {
}
func (srv *Server) setupAPIv1(router chi.Router) {
router.With(srv.mw.CORS()).Get(
router.Get(
"/server",
srv.handlers.HandleServerInfo(),
)
-84
View File
@@ -6,10 +6,8 @@ package server_test
import (
"fmt"
"maps"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"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
// set to 0, so the system gives the server a free port.
func startApp(