1 Commits
Author SHA1 Message Date
sneak f5dd70047e Honour forwarded headers only from trusted proxies (closes #259)
check / check (push) Failing after 7s
A new ClientIP middleware sets request.RemoteAddr to the client's
address before the request is logged. X-Forwarded-For and X-Real-IP
count only when the peer is in TRUSTED_PROXIES, which defaults to the
RFC 1918 ranges. The login rate limit, the session's IP address and
hostname, and the request log all use that address.

Model: opus-5-5
2026-11-10 20:39:11 +00:00
23 changed files with 357 additions and 1011 deletions

No files matched your search

+6 -7
View File
@@ -83,15 +83,14 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /neoirc-cli ./cmd/neoir
# stage's chain and nothing else. # stage's chain and nothing else.
# alpine:3.21, 2026-02-26 # alpine:3.21, 2026-02-26
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
# su-exec 0.2-r3, 2026-11-10 RUN apk add --no-cache ca-certificates \
RUN apk add --no-cache ca-certificates su-exec=0.2-r3 \ && addgroup -S neoirc && adduser -S neoirc -G neoirc \
&& addgroup -S neoirc && adduser -S neoirc -G neoirc && mkdir -p /var/lib/neoirc \
&& chown neoirc:neoirc /var/lib/neoirc
COPY --from=builder /neoircd /usr/local/bin/neoircd COPY --from=builder /neoircd /usr/local/bin/neoircd
COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
# No USER: the entrypoint starts as root to give /var/lib/neoirc to neoirc, USER neoirc
# then runs neoircd as neoirc.
EXPOSE 8080 6667 EXPOSE 8080 6667
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:8080/.well-known/healthcheck.json || exit 1 CMD wget -qO- http://localhost:8080/.well-known/healthcheck.json || exit 1
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] ENTRYPOINT ["neoircd"]
+16 -47
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
@@ -1793,17 +1790,13 @@ NEOIRC_HASHCASH_BITS=20
the linter first and stops when either fails. The image: the linter first and stops when either fails. The image:
- is Alpine with the `neoircd` binary and CA certificates; - is Alpine with the `neoircd` binary and CA certificates;
- runs `neoircd` as the user `neoirc`; - runs as the user `neoirc`;
- exposes ports 8080 and 6667; - exposes ports 8080 and 6667;
- keeps its database in `/var/lib/neoirc`; - keeps its database in `/var/lib/neoirc`;
- has a `HEALTHCHECK` that calls `/.well-known/healthcheck.json` every 30 - has a `HEALTHCHECK` that calls `/.well-known/healthcheck.json` every 30
seconds. seconds.
The image prepares `/var/lib/neoirc` itself on every start. It creates the Run it with a volume for the database and the settings as environment variables:
directory, gives it to `neoirc` and then runs `neoircd` as `neoirc`.
Run it with a named volume for the database and the settings as environment
variables:
```bash ```bash
docker run -d --name neoirc \ docker run -d --name neoirc \
@@ -1814,17 +1807,6 @@ docker run -d --name neoirc \
neoirc neoirc
``` ```
A host directory works the same way in place of the named volume:
```bash
docker run -d --name neoirc \
-p 8080:8080 -p 6667:6667 \
-v /srv/neoirc:/var/lib/neoirc \
-e SERVER_NAME="My Server" \
-e MOTD="Welcome!" \
neoirc
```
### Reverse proxy ### Reverse proxy
Run the server behind a reverse proxy that terminates TLS. The server itself Run the server behind a reverse proxy that terminates TLS. The server itself
@@ -1839,7 +1821,7 @@ over HTTPS. The proxy must:
default does not trust them, so set `TRUSTED_PROXIES=127.0.0.1,::1`. default does not trust them, so set `TRUSTED_PROXIES=127.0.0.1,::1`.
- Through a published Docker port, that address is the Docker bridge - Through a published Docker port, that address is the Docker bridge
gateway, such as `172.17.0.1`. The default trusts it. gateway, such as `172.17.0.1`. The default trusts it.
- Add the client's address to `X-Forwarded-For`. - Add the client's address to `X-Forwarded-For`, or set `X-Real-IP`.
- Wait at least 60 seconds for a response. A long poll can take 30 seconds, and - Wait at least 60 seconds for a response. A long poll can take 30 seconds, and
the server's own write timeout is 60 seconds. the server's own write timeout is 60 seconds.
@@ -1895,34 +1877,23 @@ 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
The server logs with Go's `slog`, one line per event, to standard output. The The server logs with Go's `slog`, one line per event, to standard output. The
lines are JSON, or plain text when standard output is a terminal. Every HTTP lines are JSON, or plain text when standard output is a terminal. Every HTTP
request is logged with: request is logged with its method, URL, protocol, status, latency, client
address, user agent, referrer and request ID. The client address is the one
- start time found as described in [Reverse proxy](#reverse-proxy). `DEBUG=true` adds
- method debug-level lines.
- URL
- protocol
- status
- latency
- client address
- user agent
- referrer
- request ID
The client address is the one found as described in
[Reverse proxy](#reverse-proxy). `DEBUG=true` adds debug-level lines.
### The database ### The database
@@ -2018,8 +1989,6 @@ and their sessions to end. Then it closes the database.
- `schema/`: JSON Schema files for the message envelope, with their own - `schema/`: JSON Schema files for the message envelope, with their own
`README.md`. `README.md`.
- `script/`: the entrypoints below. - `script/`: the entrypoints below.
- `deploy/`: `docker-entrypoint.sh`, the image's entrypoint, which prepares
`/var/lib/neoirc` and starts `neoircd` as `neoirc`.
- `CONVENTIONS.md`, `REPO_POLICIES.md` and `AGENTS.md`: the architecture - `CONVENTIONS.md`, `REPO_POLICIES.md` and `AGENTS.md`: the architecture
conventions, the repository policies and the rules for coding agents. conventions, the repository policies and the rules for coding agents.
-14
View File
@@ -1,14 +0,0 @@
#!/bin/sh
# deploy/docker-entrypoint.sh: the image's ENTRYPOINT, run as root. A host
# directory mounted at /var/lib/neoirc keeps the host's owner, so this gives
# it to neoirc before running neoircd as neoirc.
set -eu
main() {
mkdir -p /var/lib/neoirc
chown -R neoirc:neoirc /var/lib/neoirc
chmod 0700 /var/lib/neoirc
exec su-exec neoirc neoircd "$@"
}
main "$@"
+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
+66 -124
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"
@@ -36,16 +35,14 @@ Type /help for available commands.`
const DefaultDBURL = "file:///var/lib/neoirc/state.db" + const DefaultDBURL = "file:///var/lib/neoirc/state.db" +
"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)" "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
// DefaultTrustedProxies is TRUSTED_PROXIES when it is not set: the // defaultTrustedProxies is TRUSTED_PROXIES when it is not set: the
// private address ranges of RFC 1918. // private address ranges of RFC 1918.
const DefaultTrustedProxies = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" const defaultTrustedProxies = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
var errOutOfRange = errors.New("setting out of range") 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")
// Params defines the dependencies for creating a Config. // Params defines the dependencies for creating a Config.
type Params struct { type Params struct {
fx.In fx.In
@@ -82,9 +79,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) {
@@ -117,20 +113,33 @@ func New(
viper.SetDefault("LOGIN_RATE_LIMIT", "1") viper.SetDefault("LOGIN_RATE_LIMIT", "1")
viper.SetDefault("LOGIN_RATE_BURST", "5") viper.SetDefault("LOGIN_RATE_BURST", "5")
viper.SetDefault("IRC_LISTEN_ADDR", ":6667") viper.SetDefault("IRC_LISTEN_ADDR", ":6667")
viper.SetDefault("TRUSTED_PROXIES", DefaultTrustedProxies) viper.SetDefault("TRUSTED_PROXIES", defaultTrustedProxies)
err := viper.ReadInConfig() err := viper.ReadInConfig()
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 +147,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 +162,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,64 +176,38 @@ 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 // parseTrustedProxies sets *value to the addresses in TRUSTED_PROXIES, a
// value in the config file becomes its text, as with viper.GetString. A // comma-separated list of networks such as 10.0.0.0/8 and single
// list or a map in the config file is an error, where viper.GetString // addresses such as 192.0.2.1. The empty string trusts nothing. Viper
// would return the empty string. The error leaves out the value, which // counts an empty environment variable as unset, so the environment is
// may be a password. // read directly as well.
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.
func parseTrustedProxies(value **netipx.IPSet) error { func parseTrustedProxies(value **netipx.IPSet) error {
var raw string raw := viper.GetString("TRUSTED_PROXIES")
err := parseStringAllowEmptyEnv("TRUSTED_PROXIES", &raw) fromEnv, set := os.LookupEnv("TRUSTED_PROXIES")
if err != nil { if set {
return err raw = fromEnv
} }
ipSet, err := ParseTrustedProxies(raw) var builder netipx.IPSetBuilder
if strings.TrimSpace(raw) != "" {
for entry := range strings.SplitSeq(raw, ",") {
err := addTrustedProxy(&builder, strings.TrimSpace(entry))
if err != nil {
return fmt.Errorf("cannot parse TRUSTED_PROXIES: %w", err)
}
}
}
ipSet, err := builder.IPSet()
if err != nil { if err != nil {
return fmt.Errorf("cannot parse TRUSTED_PROXIES: %w", err) return fmt.Errorf("cannot parse TRUSTED_PROXIES: %w", err)
} }
@@ -238,25 +217,6 @@ func parseTrustedProxies(value **netipx.IPSet) error {
return nil return nil
} }
// ParseTrustedProxies returns the addresses in list, which has the form
// of TRUSTED_PROXIES: a comma-separated list of networks such as
// 10.0.0.0/8 and single addresses such as 192.0.2.1. The empty string
// trusts nothing.
func ParseTrustedProxies(list string) (*netipx.IPSet, error) {
var builder netipx.IPSetBuilder
if strings.TrimSpace(list) != "" {
for entry := range strings.SplitSeq(list, ",") {
err := addTrustedProxy(&builder, strings.TrimSpace(entry))
if err != nil {
return nil, err
}
}
}
return builder.IPSet()
}
// addTrustedProxy adds entry, a network with a slash or a single // addTrustedProxy adds entry, a network with a slash or a single
// address, to builder. An IPv4 address written in IPv6 form, such as // address, to builder. An IPv4 address written in IPv6 form, such as
// ::ffff:192.0.2.1, is added as IPv4. A network in that form and an // ::ffff:192.0.2.1, is added as IPv4. A network in that form and an
@@ -297,10 +257,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 +278,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 +298,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 +308,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
} }
+2 -154
View File
@@ -3,12 +3,10 @@ package config_test
import ( import (
"net/netip" "net/netip"
"os" "os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/spf13/viper"
"sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger" "sneak.berlin/go/neoirc/internal/logger"
@@ -143,7 +141,8 @@ func settingsOf(cfg *config.Config) settings {
} }
func TestBadSettingStopsStartup(t *testing.T) { func TestBadSettingStopsStartup(t *testing.T) {
// Each value is invalid for its setting. // For each setting, the first value does not parse and the others
// parse but are out of range.
tests := []struct { tests := []struct {
key string key string
values []string values []string
@@ -286,154 +285,3 @@ 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) {
// 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"}
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")
_, err := loadConfig(t)
if err == nil || !strings.Contains(err.Error(), key) {
t.Errorf("error = %v, want one naming %s", err, key)
}
})
}
}
}
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) {
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)
}
})
}
}
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 -1
View File
@@ -22,7 +22,7 @@ func (database *Database) SetPassword(
password string, password string,
) error { ) error {
hash, err := bcrypt.GenerateFromPassword( hash, err := bcrypt.GenerateFromPassword(
[]byte(password), database.passwordCost, []byte(password), bcrypt.DefaultCost,
) )
if err != nil { if err != nil {
return fmt.Errorf("hash password: %w", err) return fmt.Errorf("hash password: %w", err)
+5 -18
View File
@@ -15,7 +15,6 @@ import (
"strings" "strings"
"go.uber.org/fx" "go.uber.org/fx"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/logger" "sneak.berlin/go/neoirc/internal/logger"
@@ -40,19 +39,13 @@ type Params struct {
Logger *logger.Logger Logger *logger.Logger
Config *config.Config Config *config.Config
// PasswordCost is the bcrypt cost at which SetPassword hashes
// a password. The server leaves it unset, which means
// bcrypt.DefaultCost. Tests set a lower cost to save time.
PasswordCost int `optional:"true"`
} }
// Database manages the SQLite connection and migrations. // Database manages the SQLite connection and migrations.
type Database struct { type Database struct {
conn *sql.DB conn *sql.DB
log *slog.Logger log *slog.Logger
params *Params params *Params
passwordCost int
} }
// New creates a new Database and registers lifecycle hooks. // New creates a new Database and registers lifecycle hooks.
@@ -60,15 +53,9 @@ func New(
lifecycle fx.Lifecycle, lifecycle fx.Lifecycle,
params Params, params Params,
) (*Database, error) { ) (*Database, error) {
passwordCost := params.PasswordCost
if passwordCost == 0 {
passwordCost = bcrypt.DefaultCost
}
database := &Database{ //nolint:exhaustruct // conn set in OnStart database := &Database{ //nolint:exhaustruct // conn set in OnStart
params: &params, params: &params,
log: params.Logger.Get(), log: params.Logger.Get(),
passwordCost: passwordCost,
} }
database.log.Info("Database instantiated") database.log.Info("Database instantiated")
+1 -85
View File
@@ -10,7 +10,6 @@ import (
"time" "time"
"go.uber.org/fx/fxtest" "go.uber.org/fx/fxtest"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/dbtest" "sneak.berlin/go/neoirc/internal/dbtest"
@@ -27,11 +26,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
@@ -241,85 +236,6 @@ func TestDBURLSettingForeignKeysFailsStartup(t *testing.T) {
} }
} }
// TestPasswordCost checks the bcrypt cost of the hash that SetPassword
// stores: bcrypt.DefaultCost when db.Params leaves PasswordCost unset,
// as the server does, and PasswordCost when it is set.
func TestPasswordCost(t *testing.T) {
t.Parallel()
tests := []struct {
name string
passwordCost int
wantCost int
}{
{name: "unset", wantCost: bcrypt.DefaultCost},
{
name: "bcrypt.MinCost",
passwordCost: bcrypt.MinCost,
wantCost: bcrypt.MinCost,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
lifecycle := fxtest.NewLifecycle(t)
log, err := logger.New(lifecycle, logger.Params{
Globals: &globals.Globals{},
})
if err != nil {
t.Fatal(err)
}
database, err := db.New(lifecycle, db.Params{
Logger: log,
Config: &config.Config{DBURL: dbtest.NewURL(t)},
PasswordCost: test.passwordCost,
})
if err != nil {
t.Fatal(err)
}
lifecycle.RequireStart()
t.Cleanup(lifecycle.RequireStop)
sessionID, _, _, err := database.CreateSession(
t.Context(), "costuser", "", "", "",
)
if err != nil {
t.Fatal(err)
}
err = database.SetPassword(t.Context(), sessionID, "password123")
if err != nil {
t.Fatal(err)
}
var hash string
err = database.GetDB().QueryRowContext(
t.Context(),
"SELECT password_hash FROM sessions WHERE id = ?",
sessionID,
).Scan(&hash)
if err != nil {
t.Fatal(err)
}
cost, err := bcrypt.Cost([]byte(hash))
if err != nil {
t.Fatal(err)
}
if cost != test.wantCost {
t.Errorf("cost = %d, want %d", cost, test.wantCost)
}
})
}
}
// startDatabase starts a Database for dbURL the way the server does. // startDatabase starts a Database for dbURL the way the server does.
// It returns the error that stopped it from starting, if any. // It returns the error that stopped it from starting, if any.
func startDatabase(t *testing.T, dbURL string) (*db.Database, error) { func startDatabase(t *testing.T, dbURL string) (*db.Database, error) {
+3 -6
View File
@@ -8,12 +8,10 @@ import (
"testing" "testing"
"time" "time"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/dbtest" "sneak.berlin/go/neoirc/internal/dbtest"
) )
// NewTestDatabase creates an in-memory database for testing. It // NewTestDatabase creates an in-memory database for testing.
// hashes passwords at bcrypt.MinCost.
func NewTestDatabase(tb testing.TB) (*Database, error) { func NewTestDatabase(tb testing.TB) (*Database, error) {
tb.Helper() tb.Helper()
@@ -25,9 +23,8 @@ func NewTestDatabase(tb testing.TB) (*Database, error) {
} }
database := &Database{ //nolint:exhaustruct // test helper, params not needed database := &Database{ //nolint:exhaustruct // test helper, params not needed
conn: conn, conn: conn,
log: slog.Default(), log: slog.Default(),
passwordCost: bcrypt.MinCost,
} }
err = database.runMigrations(context.Background()) err = database.runMigrations(context.Background())
+113 -175
View File
@@ -145,46 +145,34 @@ type testServer struct {
handlers *handlers.Handlers handlers *handlers.Handlers
} }
const ( // viperMu makes tests start their servers one at a time,
// maxMessageSize is MAX_MESSAGE_SIZE on every test server, the // because config.New writes the global viper instance and
// setting's default. // the server reads it as it starts.
maxMessageSize = 4096 //
//nolint:gochecknoglobals // guards a global, so must be one
// loginRateLimit is LOGIN_RATE_LIMIT on every test server, the var viperMu sync.Mutex
// 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,
) *testServer { ) *testServer {
t.Helper() t.Helper()
return newTestServerWith(t, 0, "", "", config.DefaultTrustedProxies) return newTestServerWith(t, 0, "", "")
} }
func newTestServerWith( func newTestServerWith(
t *testing.T, t *testing.T,
hashcashBits int, hashcashBits int,
operName, operPassword, trustedProxies string, operName, operPassword string,
) *testServer { ) *testServer {
t.Helper() t.Helper()
viperMu.Lock()
defer viperMu.Unlock()
dbURL := dbtest.NewURL(t) dbURL := dbtest.NewURL(t)
copyTemplateDB(t, dbURL) copyTemplateDB(t, dbURL)
trusted, err := config.ParseTrustedProxies(trustedProxies)
if err != nil {
t.Fatalf("parse trusted proxies: %v", err)
}
// Each duration setting defaults to 30 days.
const thirtyDays = "720h"
var ( var (
srv *server.Server srv *server.Server
brk *broker.Broker brk *broker.Broker
@@ -196,25 +184,31 @@ func newTestServerWith(
fx.Provide( fx.Provide(
newTestGlobals, newTestGlobals,
logger.New, logger.New,
// Built here rather than by config.New, which writes func(
// the global viper instance, so servers start in lifecycle fx.Lifecycle,
// parallel. globs *globals.Globals,
func() *config.Config { log *logger.Logger,
return &config.Config{ ) (*config.Config, error) {
DBURL: dbURL, cfg, err := config.New(
// One line: each line is a database write per new session. lifecycle, config.Params{ //nolint:exhaustruct
MOTD: "neoirc test server", Globals: globs, Logger: log,
MaxMessageSize: maxMessageSize, },
HashcashBits: hashcashBits, )
OperName: operName, if err != nil {
OperPassword: operPassword, return nil, fmt.Errorf(
MessageMaxAge: thirtyDays, "test config: %w", err,
QueueMaxAge: thirtyDays, )
SessionIdleTimeout: thirtyDays,
LoginRateLimit: loginRateLimit,
LoginRateBurst: loginRateBurst,
TrustedProxies: trusted,
} }
cfg.DBURL = dbURL
cfg.Port = 0
// One line: each line is a database write per new session.
cfg.MOTD = "neoirc test server"
cfg.HashcashBits = hashcashBits
cfg.OperName = operName
cfg.OperPassword = operPassword
return cfg, nil
}, },
newTestDB, newTestDB,
broker.New, broker.New,
@@ -261,7 +255,7 @@ func newTestDB(
cfg *config.Config, cfg *config.Config,
) (*db.Database, error) { ) (*db.Database, error) {
database, err := db.New(lifecycle, db.Params{ //nolint:exhaustruct database, err := db.New(lifecycle, db.Params{ //nolint:exhaustruct
Logger: log, Config: cfg, PasswordCost: bcrypt.MinCost, Logger: log, Config: cfg,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("test db: %w", err) return nil, fmt.Errorf("test db: %w", err)
@@ -836,7 +830,10 @@ func (tserver *testServer) postBody(
func TestRequestBodyLimit(t *testing.T) { func TestRequestBodyLimit(t *testing.T) {
t.Parallel() t.Parallel()
const nick = "alice_bodylimit" const (
maxMessageSize = 4096 // the default MAX_MESSAGE_SIZE
nick = "alice_bodylimit"
)
endpoints := []struct { endpoints := []struct {
name, path, value string name, path, value string
@@ -1753,67 +1750,52 @@ func TestConcurrentQuitsSendOneQuit(t *testing.T) {
// that the second session then holds one QUIT from the // that the second session then holds one QUIT from the
// first, and that the first session is deleted: its tokens // first, and that the first session is deleted: its tokens
// are refused and its nick is free. It does all this // are refused and its nick is free. It does all this
// quitRaceTrials times, at once, each on a new server, because // quitRaceTrials times, each on a new server, because each
// each trial logs in and logins are rate limited. // trial logs in and logins are rate limited.
func checkQuitRace( func checkQuitRace(
t *testing.T, t *testing.T,
leave func(tserver *testServer, tokens []string), leave func(tserver *testServer, tokens []string),
) { ) {
t.Helper() t.Helper()
for trial := range quitRaceTrials {
t.Run(fmt.Sprintf("trial %d", trial), func(t *testing.T) {
t.Parallel()
checkQuitRaceOnce(t, newTestServer(t), leave)
})
}
}
// checkQuitRaceOnce runs one trial of checkQuitRace on
// tserver.
func checkQuitRaceOnce(
t *testing.T,
tserver *testServer,
leave func(tserver *testServer, tokens []string),
) {
t.Helper()
const nick = "racingquitter" const nick = "racingquitter"
firstToken := tserver.createSession(nick) for trial := range quitRaceTrials {
observerToken := tserver.createSession("raceobserver") tserver := newTestServer(t)
firstToken := tserver.createSession(nick)
observerToken := tserver.createSession("raceobserver")
tserver.setPassword(nick) tserver.setPassword(nick)
tokens := []string{firstToken, tserver.login(nick)} tokens := []string{firstToken, tserver.login(nick)}
for _, token := range []string{firstToken, observerToken} { for _, token := range []string{firstToken, observerToken} {
tserver.sendCommand(token, map[string]any{ tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#quitrace", commandKey: joinCmd, toKey: "#quitrace",
}) })
}
_, lastID := tserver.pollMessages(observerToken, 0)
leave(tserver, tokens)
msgs, _ := tserver.pollMessages(observerToken, lastID)
quits := countQuits(msgs, nick)
if quits != 1 {
t.Errorf("%d QUITs, want 1: %v", quits, msgs)
}
for _, token := range tokens {
status, _ := tserver.getState(token)
if status != http.StatusUnauthorized {
t.Errorf("state got %d, want 401", status)
} }
}
// createSession fails the test if the nick is taken. _, lastID := tserver.pollMessages(observerToken, 0)
tserver.createSession(nick)
leave(tserver, tokens)
msgs, _ := tserver.pollMessages(observerToken, lastID)
quits := countQuits(msgs, nick)
if quits != 1 {
t.Errorf("trial %d: %d QUITs, want 1: %v", trial, quits, msgs)
}
for _, token := range tokens {
status, _ := tserver.getState(token)
if status != http.StatusUnauthorized {
t.Errorf("trial %d: state got %d, want 401", trial, status)
}
}
// createSession fails the test if the nick is taken.
tserver.createSession(nick)
}
} }
// TestLogoutOfOneClientKeepsSession checks that when one of // TestLogoutOfOneClientKeepsSession checks that when one of
@@ -1951,28 +1933,11 @@ const (
// return once the session has left. // return once the session has left.
quitWakeDeadline = 5 * time.Second quitWakeDeadline = 5 * time.Second
// pollWaitStep is how often waitForLongPoll checks // pollWaitStep is how often checkQuitWakesPoll checks
// whether the long poll is waiting yet. // whether the long poll is waiting yet.
pollWaitStep = 10 * time.Millisecond pollWaitStep = 10 * time.Millisecond
) )
// waitForLongPoll waits until a long poll of the session
// sessionID is waiting, and fails the test if none is within
// quitWakeDeadline.
func (tserver *testServer) waitForLongPoll(sessionID int64) {
tserver.t.Helper()
deadline := time.Now().Add(quitWakeDeadline)
for tserver.broker.ListenerCount(sessionID) == 0 {
if time.Now().After(deadline) {
tserver.t.Fatal("the long poll did not start waiting")
}
time.Sleep(pollWaitStep)
}
}
// TestQuitWakesPeerLongPoll checks that a session's QUIT // TestQuitWakesPeerLongPoll checks that a session's QUIT
// wakes the long poll of a user who shares a channel with // wakes the long poll of a user who shares a channel with
// it. // it.
@@ -2047,7 +2012,15 @@ func checkQuitWakesPoll(
msgs, pollErr = tserver.longPoll(peerToken, lastID, quitWakePollTimeout) msgs, pollErr = tserver.longPoll(peerToken, lastID, quitWakePollTimeout)
}() }()
tserver.waitForLongPoll(peerID) deadline := time.Now().Add(quitWakeDeadline)
for tserver.broker.ListenerCount(peerID) == 0 {
if time.Now().After(deadline) {
t.Fatal("the long poll did not start waiting")
}
time.Sleep(pollWaitStep)
}
leave(tserver, token) leave(tserver, token)
@@ -2877,12 +2850,7 @@ func TestLongPoll(t *testing.T) {
pollMsgs = result.Messages pollMsgs = result.Messages
}() }()
bobID, err := tserver.database.GetSessionByNick(t.Context(), "lp_bob") time.Sleep(200 * time.Millisecond)
if err != nil {
t.Fatalf("get session of lp_bob: %v", err)
}
tserver.waitForLongPoll(bobID)
tserver.sendCommand(aliceToken, map[string]any{ tserver.sendCommand(aliceToken, map[string]any{
commandKey: privmsgCmd, commandKey: privmsgCmd,
@@ -3041,18 +3009,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 +3041,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 +3075,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 +3092,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 +3121,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
@@ -4403,11 +4343,12 @@ func TestLoginRateLimitIgnoresForwardedHeaders(t *testing.T) {
} }
func TestLoginRateLimitPerClientBehindTrustedProxy(t *testing.T) { func TestLoginRateLimitPerClientBehindTrustedProxy(t *testing.T) {
t.Parallel()
// The test client connects from 127.0.0.1. Trusted as a proxy, it // The test client connects from 127.0.0.1. Trusted as a proxy, it
// passes each client's address in X-Forwarded-For. // passes each client's address in X-Forwarded-For. t.Setenv rules
tserver := newTestServerWith(t, 0, "", "", "127.0.0.1") // out t.Parallel.
t.Setenv("TRUSTED_PROXIES", "127.0.0.1")
tserver := newTestServer(t)
// An empty JSON object is refused after the rate limit counts the // An empty JSON object is refused after the rate limit counts the
// attempt, so no attempt checks a password or looks up a hostname. // attempt, so no attempt checks a password or looks up a hostname.
@@ -5138,9 +5079,7 @@ func TestRefusedSessionLeavesStampUnspent(t *testing.T) {
badNick = "bad nick" badNick = "bad nick"
) )
tserver := newTestServerWith( tserver := newTestServerWith(t, bits, "", "")
t, bits, "", "", config.DefaultTrustedProxies,
)
// A server with no SERVER_NAME takes stamps for "neoirc". // A server with no SERVER_NAME takes stamps for "neoirc".
status := postSessionWithStamp( status := postSessionWithStamp(
@@ -5346,7 +5285,6 @@ func newTestServerWithOper(
return newTestServerWith( return newTestServerWith(
t, 0, testOperName, testOperPassword, t, 0, testOperName, testOperPassword,
config.DefaultTrustedProxies,
) )
} }
-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(
-7
View File
@@ -70,10 +70,6 @@ type Conn struct {
closed bool closed bool
killed bool killed bool
// pollInterval is how often the relay checks the client
// output queue when no broker notification arrives.
pollInterval time.Duration
// quitReason is the reason in the QUIT that peers are // quitReason is the reason in the QUIT that peers are
// sent when the connection ends. The client's QUIT // sent when the connection ends. The client's QUIT
// replaces it. // replaces it.
@@ -87,7 +83,6 @@ func newConn(
brk *broker.Broker, brk *broker.Broker,
cfg *config.Config, cfg *config.Config,
svc *service.Service, svc *service.Service,
poll time.Duration,
) *Conn { ) *Conn {
host, _, _ := net.SplitHostPort(tcpConn.RemoteAddr().String()) host, _, _ := net.SplitHostPort(tcpConn.RemoteAddr().String())
@@ -106,8 +101,6 @@ func newConn(
serverSfx: srvName, serverSfx: srvName,
remoteIP: host, remoteIP: host,
quitReason: "Connection closed", quitReason: "Connection closed",
pollInterval: poll,
} }
conn.commands = conn.buildCommandMap() conn.commands = conn.buildCommandMap()
+9 -13
View File
@@ -14,16 +14,16 @@ import (
) )
// NewTestServer creates a Server suitable for testing, whose // NewTestServer creates a Server suitable for testing, whose
// connections use interval, deadline and poll in place of // connections use interval and deadline in place of
// pingInterval, pongDeadline and pollInterval, and resolve in // pingInterval and pongDeadline, and resolve in place of
// place of resolveHost. // resolveHost.
// The caller must call Stop() when finished. // The caller must call Stop() when finished.
func NewTestServer( func NewTestServer(
log *slog.Logger, log *slog.Logger,
cfg *config.Config, cfg *config.Config,
database *db.Database, database *db.Database,
brk *broker.Broker, brk *broker.Broker,
interval, deadline, poll time.Duration, interval, deadline time.Duration,
resolve func(ctx context.Context, addr string) string, resolve func(ctx context.Context, addr string) string,
) *Server { ) *Server {
globs := &globals.Globals{ globs := &globals.Globals{
@@ -46,18 +46,10 @@ func NewTestServer(
pingInterval: interval, pingInterval: interval,
pongDeadline: deadline, pongDeadline: deadline,
pollInterval: poll,
stopTimeout: stopTimeout,
resolveHost: resolve, resolveHost: resolve,
} }
} }
// SetStopTimeout makes Stop wait at most timeout for the
// connections' cleanup.
func (s *Server) SetStopTimeout(timeout time.Duration) {
s.stopTimeout = timeout
}
// ResolveHost is the reverse DNS lookup that a server made by // ResolveHost is the reverse DNS lookup that a server made by
// New does for each client. // New does for each client.
func ResolveHost(ctx context.Context, addr string) string { func ResolveHost(ctx context.Context, addr string) string {
@@ -107,6 +99,10 @@ const PingInterval = pingInterval
// for a line before it closes the connection. // for a line before it closes the connection.
const PongDeadline = pongDeadline const PongDeadline = pongDeadline
// StopTimeout is the longest Stop waits for the connections'
// cleanup.
const StopTimeout = stopTimeout
// NewTestConn returns a Conn for nick on tcpConn, without a // NewTestConn returns a Conn for nick on tcpConn, without a
// server, database or service behind it. // server, database or service behind it.
func NewTestConn( func NewTestConn(
@@ -115,7 +111,7 @@ func NewTestConn(
tcpConn net.Conn, tcpConn net.Conn,
nick string, nick string,
) *Conn { ) *Conn {
conn := newConn(tcpConn, log, nil, nil, cfg, nil, pollInterval) conn := newConn(tcpConn, log, nil, nil, cfg, nil)
conn.nick = nick conn.nick = nick
return conn return conn
+4
View File
@@ -3,6 +3,7 @@ package ircserver_test
import ( import (
"strings" "strings"
"testing" "testing"
"time"
"sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
) )
@@ -1507,6 +1508,9 @@ func TestIntegrationThirdClientObserver(t *testing.T) {
bob.joinAndDrain("#trio") bob.joinAndDrain("#trio")
carol.joinAndDrain("#trio") carol.joinAndDrain("#trio")
// Drain join notifications.
time.Sleep(100 * time.Millisecond)
// alice sends; both bob and carol should receive. // alice sends; both bob and carol should receive.
alice.send("PRIVMSG #trio :hello trio") alice.send("PRIVMSG #trio :hello trio")
+1 -1
View File
@@ -17,7 +17,7 @@ import (
func (c *Conn) relayMessages(ctx context.Context) { func (c *Conn) relayMessages(ctx context.Context) {
// Use a ticker as a fallback; primary wakeup is via // Use a ticker as a fallback; primary wakeup is via
// broker notification. // broker notification.
ticker := time.NewTicker(c.pollInterval) ticker := time.NewTicker(pollInterval)
defer ticker.Stop() defer ticker.Stop()
for { for {
+1 -63
View File
@@ -9,7 +9,6 @@ import (
"time" "time"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/dbtest" "sneak.berlin/go/neoirc/internal/dbtest"
"sneak.berlin/go/neoirc/internal/ircserver" "sneak.berlin/go/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/internal/service"
@@ -59,11 +58,7 @@ func (env *testEnv) queueMessage(
func TestRelayKeepsOneWaitChannel(t *testing.T) { func TestRelayKeepsOneWaitChannel(t *testing.T) {
t.Parallel() t.Parallel()
env := newTestEnvWithResolver( env := newTestEnv(t)
t, &config.Config{ServerName: testServerName},
ircserver.PingInterval, ircserver.PongDeadline,
ircserver.PollInterval, ircserver.ResolveHost,
)
client := env.dial(t) client := env.dial(t)
client.register("idle") client.register("idle")
@@ -124,63 +119,6 @@ func (env *testEnv) checkOneWaitChannel(
} }
} }
// TestRelayDeliversAfterFailedRead checks that when the relay
// cannot read the client output queue, it waits for the next
// notification, and delivers once the queue can be read again.
func TestRelayDeliversAfterFailedRead(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("reader")
sessionID, err := env.database.GetSessionByNick(t.Context(), "reader")
if err != nil {
t.Fatalf("get session of reader: %v", err)
}
dbtest.Exec(t, env.dbURL,
"ALTER TABLE client_queues RENAME TO hidden_queues")
// Each pass of the relay takes a wait channel and then reads
// the queue. Once it holds a channel after the second
// notification, the read after the first one has failed.
for range 2 {
env.waitForWaitChannel(t, sessionID)
env.brk.Notify(sessionID)
}
env.waitForWaitChannel(t, sessionID)
dbtest.Exec(t, env.dbURL,
"ALTER TABLE hidden_queues RENAME TO client_queues")
env.queueMessage(
t, "reader", "PRIVMSG", "carol", "reader", []string{"readable"},
)
client.readUntil(func(l string) bool {
return strings.Contains(l, "readable")
})
}
// waitForWaitChannel waits until the session sessionID holds a
// broker wait channel, and fails the test if it does not
// within testTimeout.
func (env *testEnv) waitForWaitChannel(t *testing.T, sessionID int64) {
t.Helper()
deadline := time.Now().Add(testTimeout)
for env.brk.ListenerCount(sessionID) == 0 {
if time.Now().After(deadline) {
t.Fatal("the relay is not waiting for a notification")
}
time.Sleep(relayCheckStep)
}
}
// TestRelayMultiLineBody checks that each line of a PRIVMSG // TestRelayMultiLineBody checks that each line of a PRIVMSG
// or NOTICE body sent over the HTTP API reaches an IRC // or NOTICE body sent over the HTTP API reaches an IRC
// listener user as its own line. // listener user as its own line.
+3 -10
View File
@@ -53,11 +53,6 @@ type Server struct {
pingInterval time.Duration pingInterval time.Duration
pongDeadline time.Duration pongDeadline time.Duration
// pollInterval and stopTimeout are the constants of the
// same names, except in tests, which change them.
pollInterval time.Duration
stopTimeout time.Duration
// resolveHost is the function of the same name, except in // resolveHost is the function of the same name, except in
// tests, which replace it with one that blocks. // tests, which replace it with one that blocks.
resolveHost func(ctx context.Context, addr string) string resolveHost func(ctx context.Context, addr string) string
@@ -84,8 +79,6 @@ func New(
pingInterval: pingInterval, pingInterval: pingInterval,
pongDeadline: pongDeadline, pongDeadline: pongDeadline,
pollInterval: pollInterval,
stopTimeout: stopTimeout,
resolveHost: resolveHost, resolveHost: resolveHost,
} }
@@ -161,10 +154,10 @@ func (s *Server) stop() {
select { select {
case <-done: case <-done:
case <-time.After(s.stopTimeout): case <-time.After(stopTimeout):
s.log.Error( s.log.Error(
"irc connections not cleaned up before stop timeout", "irc connections not cleaned up before stop timeout",
"timeout", s.stopTimeout, "timeout", stopTimeout,
) )
} }
} }
@@ -188,7 +181,7 @@ func (s *Server) acceptLoop(ctx context.Context) {
client := newConn( client := newConn(
tcpConn, s.log, tcpConn, s.log,
s.database, s.brk, s.cfg, s.svc, s.pollInterval, s.database, s.brk, s.cfg, s.svc,
) )
s.mu.Lock() s.mu.Lock()
+23 -61
View File
@@ -18,7 +18,6 @@ import (
"time" "time"
"go.uber.org/fx/fxtest" "go.uber.org/fx/fxtest"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
@@ -31,16 +30,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
// checks the client output queue without a broker
// notification. It is so long that the relay delivers only on
// notification, and a test fails when one is missing.
const testPollInterval = time.Hour
// testEnv holds the shared test infrastructure. // testEnv holds the shared test infrastructure.
type testEnv struct { type testEnv struct {
@@ -101,19 +91,16 @@ func newTestEnvWithPingTimes(
t.Helper() t.Helper()
return newTestEnvWithResolver( return newTestEnvWithResolver(
t, cfg, interval, deadline, testPollInterval, t, cfg, interval, deadline, ircserver.ResolveHost,
ircserver.ResolveHost,
) )
} }
// newTestEnvWithResolver is newTestEnvWithPingTimes, except // newTestEnvWithResolver is newTestEnvWithPingTimes, except
// that the relay checks the client output queue every poll // that the server looks up a client's hostname with resolve.
// without a broker notification, and the server looks up a
// client's hostname with resolve.
func newTestEnvWithResolver( func newTestEnvWithResolver(
t *testing.T, t *testing.T,
cfg *config.Config, cfg *config.Config,
interval, deadline, poll time.Duration, interval, deadline time.Duration,
resolve func(ctx context.Context, addr string) string, resolve func(ctx context.Context, addr string) string,
) *testEnv { ) *testEnv {
t.Helper() t.Helper()
@@ -129,9 +116,8 @@ func newTestEnvWithResolver(
} }
database, err := db.New(lifecycle, db.Params{ database, err := db.New(lifecycle, db.Params{
Logger: dbLog, Logger: dbLog,
Config: &config.Config{DBURL: dbURL}, Config: &config.Config{DBURL: dbURL},
PasswordCost: bcrypt.MinCost,
}) })
if err != nil { if err != nil {
t.Fatalf("new database: %v", err) t.Fatalf("new database: %v", err)
@@ -148,7 +134,7 @@ func newTestEnvWithResolver(
)) ))
srv := ircserver.NewTestServer( srv := ircserver.NewTestServer(
log, cfg, database, brk, interval, deadline, poll, resolve, log, cfg, database, brk, interval, deadline, resolve,
) )
// Port 0 makes the system pick a free port. dial reads it // Port 0 makes the system pick a free port. dial reads it
@@ -444,8 +430,7 @@ func TestRegistrationWhileAnotherLookupIsPending(t *testing.T) {
env := newTestEnvWithResolver( env := newTestEnvWithResolver(
t, &config.Config{ServerName: testServerName}, t, &config.Config{ServerName: testServerName},
ircserver.PingInterval, ircserver.PongDeadline, ircserver.PingInterval, ircserver.PongDeadline, resolve,
testPollInterval, resolve,
) )
env.dial(t) env.dial(t)
@@ -568,49 +553,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,
) )
} }
} }
@@ -1076,16 +1039,12 @@ func TestStopEndsSessions(t *testing.T) {
} }
// TestStopReturnsWhileCleanupIsStuck checks that Stop stops // TestStopReturnsWhileCleanupIsStuck checks that Stop stops
// waiting for a connection's cleanup after its stop timeout, // waiting for a connection's cleanup after StopTimeout, so a
// so a cleanup that cannot finish does not hold up stopping. // cleanup that cannot finish does not hold up stopping.
func TestStopReturnsWhileCleanupIsStuck(t *testing.T) { func TestStopReturnsWhileCleanupIsStuck(t *testing.T) {
t.Parallel() t.Parallel()
const stopTimeout = 100 * time.Millisecond
env := newTestEnv(t) env := newTestEnv(t)
env.srv.SetStopTimeout(stopTimeout)
client := env.dial(t) client := env.dial(t)
client.register("stuck") client.register("stuck")
@@ -1111,7 +1070,7 @@ func TestStopReturnsWhileCleanupIsStuck(t *testing.T) {
select { select {
case <-stopped: case <-stopped:
case <-time.After(stopTimeout + testTimeout): case <-time.After(ircserver.StopTimeout + testTimeout):
t.Error("Stop did not return while a cleanup was stuck") t.Error("Stop did not return while a cleanup was stuck")
} }
@@ -1163,6 +1122,9 @@ func TestDirectMessage(t *testing.T) {
receiver := env.dial(t) receiver := env.dial(t)
receiver.register("dmreceiver") receiver.register("dmreceiver")
// Give relay goroutines time to start.
time.Sleep(100 * time.Millisecond)
sender.send("PRIVMSG dmreceiver :hello privately") sender.send("PRIVMSG dmreceiver :hello privately")
lines := receiver.readUntil(func(line string) bool { lines := receiver.readUntil(func(line string) bool {
+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(
+89 -129
View File
@@ -1,3 +1,7 @@
// Tests use a global viper instance for configuration,
// making parallel execution unsafe.
//
//nolint:paralleltest
package service_test package service_test
import ( import (
@@ -55,12 +59,28 @@ func newTestEnv(t *testing.T) *testEnv {
} }
}, },
logger.New, logger.New,
func() *config.Config { func(
return &config.Config{ lifecycle fx.Lifecycle,
DBURL: dbURL, globs *globals.Globals,
OperName: "admin", log *logger.Logger,
OperPassword: "secret", ) (*config.Config, error) {
cfg, err := config.New(
lifecycle, config.Params{ //nolint:exhaustruct
Globals: globs, Logger: log,
},
)
if err != nil {
return nil, fmt.Errorf(
"test config: %w", err,
)
} }
cfg.DBURL = dbURL
cfg.Port = 0
cfg.OperName = "admin"
cfg.OperPassword = "secret"
return cfg, nil
}, },
func( func(
lifecycle fx.Lifecycle, lifecycle fx.Lifecycle,
@@ -116,8 +136,6 @@ func createSession(
// environment starts on an empty database while another one // environment starts on an empty database while another one
// is still open. // is still open.
func TestEachTestEnvHasItsOwnDatabase(t *testing.T) { func TestEachTestEnvHasItsOwnDatabase(t *testing.T) {
t.Parallel()
ctx := t.Context() ctx := t.Context()
first := newTestEnv(t) first := newTestEnv(t)
@@ -128,8 +146,6 @@ func TestEachTestEnvHasItsOwnDatabase(t *testing.T) {
} }
func TestFanOut(t *testing.T) { func TestFanOut(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -157,8 +173,6 @@ func TestFanOut(t *testing.T) {
} }
func TestJoinChannel(t *testing.T) { func TestJoinChannel(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -199,8 +213,6 @@ func TestJoinChannel(t *testing.T) {
} }
func TestPartChannel(t *testing.T) { func TestPartChannel(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -301,8 +313,6 @@ func joinConcurrently(
// concurrent JOINs of a +l 2 channel with one member, one // concurrent JOINs of a +l 2 channel with one member, one
// gets in and the other gets ERR_CHANNELISFULL. // gets in and the other gets ERR_CHANNELISFULL.
func TestConcurrentJoinsRespectUserLimit(t *testing.T) { func TestConcurrentJoinsRespectUserLimit(t *testing.T) {
t.Parallel()
const userLimit = 2 const userLimit = 2
env := newTestEnv(t) env := newTestEnv(t)
@@ -365,8 +375,6 @@ func TestConcurrentJoinsRespectUserLimit(t *testing.T) {
// two concurrent JOINs that create a channel, exactly one // two concurrent JOINs that create a channel, exactly one
// makes its session the operator. // makes its session the operator.
func TestConcurrentFirstJoinsMakeOneOperator(t *testing.T) { func TestConcurrentFirstJoinsMakeOneOperator(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -418,8 +426,6 @@ func TestConcurrentFirstJoinsMakeOneOperator(t *testing.T) {
// either joins a channel that still exists or gets // either joins a channel that still exists or gets
// ERR_NOSUCHCHANNEL, and never fails otherwise. // ERR_NOSUCHCHANNEL, and never fails otherwise.
func TestJoinConcurrentWithLastPart(t *testing.T) { func TestJoinConcurrentWithLastPart(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -485,8 +491,6 @@ func TestJoinConcurrentWithLastPart(t *testing.T) {
// quits, gets in only once the channel is deleted, into a // quits, gets in only once the channel is deleted, into a
// new channel of that name with no key. // new channel of that name with no key.
func TestJoinConcurrentWithLastQuit(t *testing.T) { func TestJoinConcurrentWithLastQuit(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -565,95 +569,81 @@ const quitCrowdSize = 10
// ahead of that JOIN. The quitting session is also on a // ahead of that JOIN. The quitting session is also on a
// channel with quitCrowdSize other sessions, made before the // channel with quitCrowdSize other sessions, made before the
// watcher's channel, so that its QUIT reaches the watcher // watcher's channel, so that its QUIT reaches the watcher
// last. Each trial runs on a database of its own. // last.
func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) { func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) {
t.Parallel() env := newTestEnv(t)
ctx := t.Context()
for trial := range concurrentJoinTrials { for trial := range concurrentJoinTrials {
t.Run(fmt.Sprintf("trial %d", trial), func(t *testing.T) { nick := fmt.Sprintf("reused%d", trial)
t.Parallel() crowd := fmt.Sprintf("#crowd%d", trial)
shared := fmt.Sprintf("#shared%d", trial)
quitterSID := createSession(ctx, t, env.db, nick)
checkQuitQueuedAheadOfJoin(t, newTestEnv(t)) for _, channel := range []string{crowd, shared} {
}) _, err := env.svc.JoinChannel(ctx, quitterSID, 0, nick, channel, "")
} if err != nil {
} t.Fatalf("join %s: %v", channel, err)
}
// checkQuitQueuedAheadOfJoin runs one trial of
// TestQuitQueuedAheadOfNewNickHoldersJoin on env.
func checkQuitQueuedAheadOfJoin(t *testing.T, env *testEnv) {
t.Helper()
const (
nick = "reused"
crowd = "#crowd"
shared = "#shared"
watcherNick = "watcher"
)
ctx := t.Context()
quitterSID := createSession(ctx, t, env.db, nick)
for _, channel := range []string{crowd, shared} {
_, err := env.svc.JoinChannel(ctx, quitterSID, 0, nick, channel, "")
if err != nil {
t.Fatalf("join %s: %v", channel, err)
} }
}
for member := range quitCrowdSize { for member := range quitCrowdSize {
memberNick := fmt.Sprintf("crowd%d", member) memberNick := fmt.Sprintf("crowd%dx%d", trial, member)
memberSID := createSession(ctx, t, env.db, memberNick) memberSID := createSession(ctx, t, env.db, memberNick)
_, err := env.svc.JoinChannel(ctx, memberSID, 0, memberNick, crowd, "") _, err := env.svc.JoinChannel(ctx, memberSID, 0, memberNick, crowd, "")
if err != nil { if err != nil {
t.Fatalf("join %s: %v", memberNick, err) t.Fatalf("join %s: %v", memberNick, err)
}
} }
}
watcherSID, watcherClientID, _, err := env.db.CreateSession( watcherNick := fmt.Sprintf("watcher%d", trial)
ctx, watcherNick, watcherNick, "localhost", "127.0.0.1",
)
if err != nil {
t.Fatalf("create session %s: %v", watcherNick, err)
}
_, err = env.svc.JoinChannel(ctx, watcherSID, 0, watcherNick, shared, "") watcherSID, watcherClientID, _, err := env.db.CreateSession(
if err != nil { ctx, watcherNick, watcherNick, "localhost", "127.0.0.1",
t.Fatalf("join %s: %v", watcherNick, err)
}
_, lastQueueID, err := env.db.PollMessages(ctx, watcherClientID, 0, 0)
if err != nil {
t.Fatalf("poll: %v", err)
}
errs := runConcurrently(
func() error {
env.svc.BroadcastQuit(ctx, quitterSID, nick, "")
return nil
},
func() error {
return takeNickAndJoin(ctx, env, nick, shared)
},
)
if errs[1] != nil {
t.Fatalf("new session: %v", errs[1])
}
msgs, _, err := env.db.PollMessages(
ctx, watcherClientID, lastQueueID, 0,
)
if err != nil {
t.Fatalf("poll: %v", err)
}
commands := commandsFrom(msgs, nick)
if !slices.Equal(commands, []string{irc.CmdQuit, irc.CmdJoin}) {
t.Errorf(
"watcher got %v from %s, want QUIT then JOIN",
commands, nick,
) )
if err != nil {
t.Fatalf("create session %s: %v", watcherNick, err)
}
_, err = env.svc.JoinChannel(ctx, watcherSID, 0, watcherNick, shared, "")
if err != nil {
t.Fatalf("join %s: %v", watcherNick, err)
}
_, lastQueueID, err := env.db.PollMessages(ctx, watcherClientID, 0, 0)
if err != nil {
t.Fatalf("poll: %v", err)
}
errs := runConcurrently(
func() error {
env.svc.BroadcastQuit(ctx, quitterSID, nick, "")
return nil
},
func() error {
return takeNickAndJoin(ctx, env, nick, shared)
},
)
if errs[1] != nil {
t.Fatalf("trial %d: new session: %v", trial, errs[1])
}
msgs, _, err := env.db.PollMessages(
ctx, watcherClientID, lastQueueID, 0,
)
if err != nil {
t.Fatalf("poll: %v", err)
}
commands := commandsFrom(msgs, nick)
if !slices.Equal(commands, []string{irc.CmdQuit, irc.CmdJoin}) {
t.Errorf(
"trial %d: watcher got %v from %s, want QUIT then JOIN",
trial, commands, nick,
)
}
} }
} }
@@ -701,8 +691,6 @@ func commandsFrom(msgs []db.IRCMessage, nick string) []string {
// into a +i channel uses the invite up, so that a JOIN // into a +i channel uses the invite up, so that a JOIN
// after leaving gets ERR_INVITEONLYCHAN. // after leaving gets ERR_INVITEONLYCHAN.
func TestJoinUsesUpInvite(t *testing.T) { func TestJoinUsesUpInvite(t *testing.T) {
t.Parallel()
const channel = "#club" const channel = "#club"
env := newTestEnv(t) env := newTestEnv(t)
@@ -748,8 +736,6 @@ func TestJoinUsesUpInvite(t *testing.T) {
// session's nick, username and hostname refuses its JOIN // session's nick, username and hostname refuses its JOIN
// with ERR_BANNEDFROMCHAN. // with ERR_BANNEDFROMCHAN.
func TestJoinRefusedByHostmaskBan(t *testing.T) { func TestJoinRefusedByHostmaskBan(t *testing.T) {
t.Parallel()
const channel = "#club" const channel = "#club"
env := newTestEnv(t) env := newTestEnv(t)
@@ -788,8 +774,6 @@ func TestJoinRefusedByHostmaskBan(t *testing.T) {
// keeps a channel other members are still on and deletes // keeps a channel other members are still on and deletes
// the channel when its last member parts. // the channel when its last member parts.
func TestPartDeletesChannelOnlyWhenEmpty(t *testing.T) { func TestPartDeletesChannelOnlyWhenEmpty(t *testing.T) {
t.Parallel()
const channel = "#room" const channel = "#room"
env := newTestEnv(t) env := newTestEnv(t)
@@ -837,8 +821,6 @@ func TestPartDeletesChannelOnlyWhenEmpty(t *testing.T) {
// TestKickOfLastMemberDeletesChannel checks that a KICK // TestKickOfLastMemberDeletesChannel checks that a KICK
// that leaves a channel with no members deletes it. // that leaves a channel with no members deletes it.
func TestKickOfLastMemberDeletesChannel(t *testing.T) { func TestKickOfLastMemberDeletesChannel(t *testing.T) {
t.Parallel()
const channel = "#room" const channel = "#room"
env := newTestEnv(t) env := newTestEnv(t)
@@ -863,8 +845,6 @@ func TestKickOfLastMemberDeletesChannel(t *testing.T) {
} }
func TestSendChannelMessage(t *testing.T) { func TestSendChannelMessage(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -912,8 +892,6 @@ func TestSendChannelMessage(t *testing.T) {
} }
func TestBroadcastQuit(t *testing.T) { func TestBroadcastQuit(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -951,8 +929,6 @@ func TestBroadcastQuit(t *testing.T) {
// error as a DM to an unknown nick. The trigger deletes the // error as a DM to an unknown nick. The trigger deletes the
// target's session just before the DM is stored. // target's session just before the DM is stored.
func TestSendDirectMessageTargetQuits(t *testing.T) { func TestSendDirectMessageTargetQuits(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -984,8 +960,6 @@ func TestSendDirectMessageTargetQuits(t *testing.T) {
// trigger deletes the channel just before the message is // trigger deletes the channel just before the message is
// stored. // stored.
func TestSendChannelMessageChannelDeleted(t *testing.T) { func TestSendChannelMessageChannelDeleted(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -1017,8 +991,6 @@ func TestSendChannelMessageChannelDeleted(t *testing.T) {
} }
func TestSendChannelMessage_Moderated(t *testing.T) { func TestSendChannelMessage_Moderated(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -1070,8 +1042,6 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
// not spend its hashcash stamp: the operator can then send // not spend its hashcash stamp: the operator can then send
// the same message with it. // the same message with it.
func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) { func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
t.Parallel()
const ( const (
channel = "#paid" channel = "#paid"
bits = 2 bits = 2
@@ -1097,8 +1067,6 @@ func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
for _, refusal := range refusals { for _, refusal := range refusals {
t.Run(refusal.name, func(t *testing.T) { t.Run(refusal.name, func(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -1151,8 +1119,6 @@ func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
} }
func TestQueryUserMode(t *testing.T) { func TestQueryUserMode(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -1203,8 +1169,6 @@ func TestQueryUserMode(t *testing.T) {
} }
func TestUserhostReply(t *testing.T) { func TestUserhostReply(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -1310,14 +1274,10 @@ func applyUserModeTests() []applyUserModeTest {
} }
func TestApplyUserMode(t *testing.T) { func TestApplyUserMode(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
for i, test := range applyUserModeTests() { for i, test := range applyUserModeTests() {
t.Run(test.modeStr, func(t *testing.T) { t.Run(test.modeStr, func(t *testing.T) {
t.Parallel()
ctx := t.Context() ctx := t.Context()
sid := createSession( sid := createSession(
ctx, t, env.db, fmt.Sprintf("user%d", i), ctx, t, env.db, fmt.Sprintf("user%d", i),