Compare commits
1
Commits
next
..
51d9d774fd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51d9d774fd |
No files matched your search
+6
-7
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -1832,14 +1814,10 @@ speaks plain HTTP, and the auth cookie is `Secure`, so browsers send it only
|
|||||||
over HTTPS. The proxy must:
|
over HTTPS. The proxy must:
|
||||||
|
|
||||||
- Connect from an address in `TRUSTED_PROXIES`. The default trusts the private
|
- Connect from an address in `TRUSTED_PROXIES`. The default trusts the private
|
||||||
ranges `10.0.0.0/8`, `172.16.0.0/12` and `192.168.0.0/16`. The setting must
|
ranges `10.0.0.0/8`, `172.16.0.0/12` and `192.168.0.0/16`. A proxy on the same
|
||||||
hold the address the server sees the proxy connect from. For a proxy on the
|
host connects from `127.0.0.1`, which the default does not trust, so set
|
||||||
same host:
|
`TRUSTED_PROXIES=127.0.0.1` for it.
|
||||||
- With the server outside Docker, that address is `127.0.0.1` or `::1`. The
|
- Add the client's address to `X-Forwarded-For`, or set `X-Real-IP`.
|
||||||
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
|
|
||||||
gateway, such as `172.17.0.1`. The default trusts it.
|
|
||||||
- Add the client's address to `X-Forwarded-For`.
|
|
||||||
- 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 +1873,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
|
||||||
|
|
||||||
@@ -2005,8 +1972,8 @@ and their sessions to end. Then it closes the database.
|
|||||||
gets a message.
|
gets a message.
|
||||||
- `internal/hashcash/`: stamp checking and minting.
|
- `internal/hashcash/`: stamp checking and minting.
|
||||||
- `internal/ratelimit/`: the per-address login rate limiter.
|
- `internal/ratelimit/`: the per-address login rate limiter.
|
||||||
- `internal/middleware/`: finding the client's address, request logging,
|
- `internal/middleware/`: request logging, cross-origin requests, the
|
||||||
cross-origin requests, the `Content-Security-Policy` header and metrics.
|
`Content-Security-Policy` header and metrics.
|
||||||
- `internal/healthcheck/` and `internal/stats/`: the health check and its
|
- `internal/healthcheck/` and `internal/stats/`: the health check and its
|
||||||
counters.
|
counters.
|
||||||
- `internal/logger/` and `internal/globals/`: logging setup and build
|
- `internal/logger/` and `internal/globals/`: logging setup and build
|
||||||
@@ -2018,8 +1985,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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 "$@"
|
|
||||||
@@ -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
|
||||||
|
|||||||
+67
-139
@@ -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,12 @@ 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 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 +77,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 +111,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, ¬Found) {
|
if !errors.As(err, ¬Found) {
|
||||||
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: ¶ms,
|
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: ¶ms,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = parseSettings(cfg)
|
err = parseSettings(cfg)
|
||||||
@@ -138,6 +145,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 +160,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 +174,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,29 +215,9 @@ 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.
|
||||||
// address with a zone are errors, since no connection could match them.
|
|
||||||
func addTrustedProxy(builder *netipx.IPSetBuilder, entry string) error {
|
func addTrustedProxy(builder *netipx.IPSetBuilder, entry string) error {
|
||||||
if strings.Contains(entry, "/") {
|
if strings.Contains(entry, "/") {
|
||||||
prefix, err := netip.ParsePrefix(entry)
|
prefix, err := netip.ParsePrefix(entry)
|
||||||
@@ -268,12 +225,6 @@ func addTrustedProxy(builder *netipx.IPSetBuilder, entry string) error {
|
|||||||
return err //nolint:wrapcheck // the caller names the setting
|
return err //nolint:wrapcheck // the caller names the setting
|
||||||
}
|
}
|
||||||
|
|
||||||
// A connection from an IPv4 address is checked in IPv4 form.
|
|
||||||
if prefix.Addr().Is4In6() {
|
|
||||||
return fmt.Errorf("%w: %s is an IPv4 network in IPv6 form",
|
|
||||||
errNeverMatches, entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
builder.AddPrefix(prefix)
|
builder.AddPrefix(prefix)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -284,11 +235,6 @@ func addTrustedProxy(builder *netipx.IPSetBuilder, entry string) error {
|
|||||||
return err //nolint:wrapcheck // the caller names the setting
|
return err //nolint:wrapcheck // the caller names the setting
|
||||||
}
|
}
|
||||||
|
|
||||||
// IPSet.Contains is false for every address with a zone.
|
|
||||||
if addr.Zone() != "" {
|
|
||||||
return fmt.Errorf("%w: %s has a zone", errNeverMatches, entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
builder.Add(addr.Unmap())
|
builder.Add(addr.Unmap())
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -297,10 +243,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 +264,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 +284,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 +294,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
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -162,7 +161,6 @@ func TestBadSettingStopsStartup(t *testing.T) {
|
|||||||
{key: "SESSION_IDLE_TIMEOUT", values: []string{"a month", "0", "-1m"}},
|
{key: "SESSION_IDLE_TIMEOUT", values: []string{"a month", "0", "-1m"}},
|
||||||
{key: "TRUSTED_PROXIES", values: []string{
|
{key: "TRUSTED_PROXIES", values: []string{
|
||||||
"proxy.example.com", "10.0.0.0/33", "192.0.2.1:8080",
|
"proxy.example.com", "10.0.0.0/33", "192.0.2.1:8080",
|
||||||
"::ffff:10.0.0.0/104", "fe80::1%eth0",
|
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,21 +237,21 @@ func TestTrustedProxies(t *testing.T) {
|
|||||||
set: true,
|
set: true,
|
||||||
value: "198.51.100.0/24, 192.0.2.7,2001:db8::/32",
|
value: "198.51.100.0/24, 192.0.2.7,2001:db8::/32",
|
||||||
trusted: []string{"198.51.100.200", "192.0.2.7", "2001:db8::5"},
|
trusted: []string{"198.51.100.200", "192.0.2.7", "2001:db8::5"},
|
||||||
untrusted: []string{"10.4.5.6", "192.168.4.4", "192.0.2.8"},
|
untrusted: []string{"10.1.2.3", "192.168.1.1", "192.0.2.8"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "a mapped IPv6 address is trusted as IPv4",
|
name: "a mapped IPv6 address is trusted as IPv4",
|
||||||
set: true,
|
set: true,
|
||||||
value: "::ffff:192.0.2.7",
|
value: "::ffff:192.0.2.7",
|
||||||
trusted: []string{"192.0.2.7"},
|
trusted: []string{"192.0.2.7"},
|
||||||
untrusted: []string{"10.7.8.9"},
|
untrusted: []string{"10.1.2.3"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "the empty string trusts nothing",
|
name: "the empty string trusts nothing",
|
||||||
set: true,
|
set: true,
|
||||||
value: "",
|
value: "",
|
||||||
trusted: nil,
|
trusted: nil,
|
||||||
untrusted: []string{"10.0.0.1", "192.168.0.1", "::1"},
|
untrusted: []string{"10.1.2.3", "192.168.1.1", "127.0.0.1"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,154 +284,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
@@ -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
@@ -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: ¶ms,
|
params: ¶ms,
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
passwordCost: passwordCost,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
database.log.Info("Database instantiated")
|
database.log.Info("Database instantiated")
|
||||||
|
|||||||
+1
-85
@@ -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) {
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
+109
-221
@@ -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
|
||||||
@@ -4355,7 +4295,7 @@ func TestLoginRateLimitIgnoresForwardedHeaders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loginBody, err := json.Marshal(map[string]string{
|
loginBody, err := json.Marshal(map[string]string{
|
||||||
nickKey: "unknownnick", passwordKey: "wrongpassword",
|
nickKey: "nosuchuser", passwordKey: "doesnotmatter",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -4402,55 +4342,6 @@ func TestLoginRateLimitIgnoresForwardedHeaders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginRateLimitPerClientBehindTrustedProxy(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// The test client connects from 127.0.0.1. Trusted as a proxy, it
|
|
||||||
// passes each client's address in X-Forwarded-For.
|
|
||||||
tserver := newTestServerWith(t, 0, "", "", "127.0.0.1")
|
|
||||||
|
|
||||||
// An empty JSON object is refused after the rate limit counts the
|
|
||||||
// attempt, so no attempt checks a password or looks up a hostname.
|
|
||||||
loginFrom := func(forwardedFor string) int {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
request, err := http.NewRequestWithContext(
|
|
||||||
t.Context(), http.MethodPost,
|
|
||||||
tserver.url("/api/v1/login"), strings.NewReader("{}"),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
request.Header.Set("Content-Type", "application/json")
|
|
||||||
request.Header.Set("X-Forwarded-For", forwardedFor)
|
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(request)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
|
|
||||||
return resp.StatusCode
|
|
||||||
}
|
|
||||||
|
|
||||||
// The default burst is 5 attempts per address.
|
|
||||||
for range 5 {
|
|
||||||
loginFrom("203.0.113.1")
|
|
||||||
}
|
|
||||||
|
|
||||||
status := loginFrom("203.0.113.1")
|
|
||||||
if status != http.StatusTooManyRequests {
|
|
||||||
t.Errorf("sixth attempt from 203.0.113.1 got %d, want 429", status)
|
|
||||||
}
|
|
||||||
|
|
||||||
status = loginFrom("203.0.113.2")
|
|
||||||
if status == http.StatusTooManyRequests {
|
|
||||||
t.Error("first attempt from 203.0.113.2 got 429")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoginRateLimitAllowsNormalUse(t *testing.T) {
|
func TestLoginRateLimitAllowsNormalUse(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -5138,9 +5029,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 +5235,6 @@ func newTestServerWithOper(
|
|||||||
|
|
||||||
return newTestServerWith(
|
return newTestServerWith(
|
||||||
t, 0, testOperName, testOperPassword,
|
t, 0, testOperName, testOperPassword,
|
||||||
config.DefaultTrustedProxies,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -120,10 +119,11 @@ func clientAddr(
|
|||||||
return realIP.Unmap()
|
return realIP.Unmap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
entries := strings.Split(forwarded, ",")
|
||||||
client := peer
|
client := peer
|
||||||
|
|
||||||
for _, entry := range slices.Backward(strings.Split(forwarded, ",")) {
|
for i := len(entries) - 1; i >= 0; i-- {
|
||||||
addr, err := netip.ParseAddr(strings.TrimSpace(entry))
|
addr, err := netip.ParseAddr(strings.TrimSpace(entries[i]))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return peer
|
return peer
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,122 +14,111 @@ import (
|
|||||||
"sneak.berlin/go/neoirc/internal/middleware"
|
"sneak.berlin/go/neoirc/internal/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The tests trust 10.0.0.0/8. Clients use the documentation ranges
|
func TestClientIP(t *testing.T) {
|
||||||
// 192.0.2.0/24, 198.51.100.0/24 and 203.0.113.0/24.
|
t.Parallel()
|
||||||
const (
|
|
||||||
trustedNetwork = "10.0.0.0/8"
|
|
||||||
proxyPeer = "10.0.0.1:4000"
|
|
||||||
directPeer = "192.0.2.1:4000"
|
|
||||||
client = "203.0.113.7"
|
|
||||||
clientWithPort = "203.0.113.7:4000"
|
|
||||||
realIPClient = "203.0.113.8"
|
|
||||||
)
|
|
||||||
|
|
||||||
type clientIPTest struct {
|
// The proxies are in 10.0.0.0/8. Clients use the documentation
|
||||||
name string
|
// ranges 192.0.2.0/24, 198.51.100.0/24 and 203.0.113.0/24.
|
||||||
remoteAddr string
|
tests := []struct {
|
||||||
forwarded []string
|
name string
|
||||||
realIP string
|
remoteAddr string
|
||||||
want string
|
forwarded []string
|
||||||
}
|
realIP string
|
||||||
|
want string
|
||||||
func clientIPTests() []clientIPTest {
|
}{
|
||||||
return []clientIPTest{
|
|
||||||
{
|
{
|
||||||
name: "untrusted peer ignores forged headers",
|
name: "untrusted peer ignores forged headers",
|
||||||
remoteAddr: directPeer,
|
remoteAddr: "192.0.2.1:4000",
|
||||||
forwarded: []string{client},
|
forwarded: []string{"203.0.113.7"},
|
||||||
realIP: realIPClient,
|
realIP: "203.0.113.8",
|
||||||
want: directPeer,
|
want: "192.0.2.1:4000",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trusted peer without headers",
|
||||||
|
remoteAddr: "10.0.0.1:4000",
|
||||||
|
want: "10.0.0.1:4000",
|
||||||
},
|
},
|
||||||
{name: "trusted peer without headers", remoteAddr: proxyPeer, want: proxyPeer},
|
|
||||||
{
|
{
|
||||||
name: "trusted peer gives rightmost untrusted entry",
|
name: "trusted peer gives rightmost untrusted entry",
|
||||||
remoteAddr: proxyPeer,
|
remoteAddr: "10.0.0.1:4000",
|
||||||
forwarded: []string{"198.51.100.9, 203.0.113.7, 10.0.0.2"},
|
forwarded: []string{"198.51.100.9, 203.0.113.7, 10.0.0.2"},
|
||||||
want: clientWithPort,
|
want: "203.0.113.7:4000",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "entries split over several headers",
|
name: "entries split over several headers",
|
||||||
remoteAddr: proxyPeer,
|
remoteAddr: "10.0.0.1:4000",
|
||||||
forwarded: []string{"198.51.100.9", client, "10.0.0.2"},
|
forwarded: []string{"198.51.100.9", "203.0.113.7", "10.0.0.2"},
|
||||||
want: clientWithPort,
|
want: "203.0.113.7:4000",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "all-trusted chain gives leftmost entry",
|
name: "all-trusted chain gives leftmost entry",
|
||||||
remoteAddr: proxyPeer,
|
remoteAddr: "10.0.0.1:4000",
|
||||||
forwarded: []string{"10.0.0.3, 10.0.0.2"},
|
forwarded: []string{"10.0.0.3, 10.0.0.2"},
|
||||||
want: "10.0.0.3:4000",
|
want: "10.0.0.3:4000",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "X-Real-IP without X-Forwarded-For",
|
name: "X-Real-IP without X-Forwarded-For",
|
||||||
remoteAddr: proxyPeer,
|
remoteAddr: "10.0.0.1:4000",
|
||||||
realIP: realIPClient,
|
realIP: "203.0.113.8",
|
||||||
want: "203.0.113.8:4000",
|
want: "203.0.113.8:4000",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "X-Forwarded-For wins over X-Real-IP",
|
name: "X-Forwarded-For wins over X-Real-IP",
|
||||||
remoteAddr: proxyPeer,
|
remoteAddr: "10.0.0.1:4000",
|
||||||
forwarded: []string{client},
|
forwarded: []string{"203.0.113.7"},
|
||||||
realIP: realIPClient,
|
realIP: "203.0.113.8",
|
||||||
want: clientWithPort,
|
want: "203.0.113.7:4000",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "unparseable X-Forwarded-For entry gives peer",
|
name: "unparseable X-Forwarded-For entry gives peer",
|
||||||
remoteAddr: proxyPeer,
|
remoteAddr: "10.0.0.1:4000",
|
||||||
forwarded: []string{"203.0.113.7, not-an-ip, 10.0.0.2"},
|
forwarded: []string{"203.0.113.7, not-an-ip, 10.0.0.2"},
|
||||||
want: proxyPeer,
|
want: "10.0.0.1:4000",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "unparseable entry past the client is not read",
|
name: "unparseable entry past the client is not read",
|
||||||
remoteAddr: proxyPeer,
|
remoteAddr: "10.0.0.1:4000",
|
||||||
forwarded: []string{"not-an-ip, 203.0.113.7"},
|
forwarded: []string{"not-an-ip, 203.0.113.7"},
|
||||||
want: clientWithPort,
|
want: "203.0.113.7:4000",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "unparseable X-Real-IP gives peer",
|
name: "unparseable X-Real-IP gives peer",
|
||||||
remoteAddr: proxyPeer,
|
remoteAddr: "10.0.0.1:4000",
|
||||||
realIP: "not-an-ip",
|
realIP: "not-an-ip",
|
||||||
want: proxyPeer,
|
want: "10.0.0.1:4000",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "mapped IPv6 peer is unmapped and trusted",
|
name: "mapped IPv6 peer is unmapped and trusted",
|
||||||
remoteAddr: "[::ffff:10.0.0.1]:4000",
|
remoteAddr: "[::ffff:10.0.0.1]:4000",
|
||||||
forwarded: []string{client},
|
forwarded: []string{"203.0.113.7"},
|
||||||
want: clientWithPort,
|
want: "203.0.113.7:4000",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "mapped IPv6 peer outside the set",
|
name: "mapped IPv6 peer outside the set",
|
||||||
remoteAddr: "[::ffff:192.0.2.1]:4000",
|
remoteAddr: "[::ffff:192.0.2.1]:4000",
|
||||||
forwarded: []string{client},
|
forwarded: []string{"203.0.113.7"},
|
||||||
want: directPeer,
|
want: "192.0.2.1:4000",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "IPv6 client",
|
name: "IPv6 client",
|
||||||
remoteAddr: proxyPeer,
|
remoteAddr: "10.0.0.1:4000",
|
||||||
forwarded: []string{"2001:db8::1"},
|
forwarded: []string{"2001:db8::1"},
|
||||||
want: "[2001:db8::1]:4000",
|
want: "[2001:db8::1]:4000",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func TestClientIP(t *testing.T) {
|
handler := newMiddleware(t, "10.0.0.0/8").ClientIP()(
|
||||||
t.Parallel()
|
http.HandlerFunc(func(
|
||||||
|
writer http.ResponseWriter, request *http.Request,
|
||||||
|
) {
|
||||||
|
_, _ = writer.Write([]byte(request.RemoteAddr))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
mware := newMiddleware(t, trustedNetwork)
|
for _, test := range tests {
|
||||||
|
|
||||||
for _, test := range clientIPTests() {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var got string
|
|
||||||
|
|
||||||
handler := mware.ClientIP()(http.HandlerFunc(func(
|
|
||||||
_ http.ResponseWriter, request *http.Request,
|
|
||||||
) {
|
|
||||||
got = request.RemoteAddr
|
|
||||||
}))
|
|
||||||
|
|
||||||
request := httptest.NewRequestWithContext(
|
request := httptest.NewRequestWithContext(
|
||||||
t.Context(), http.MethodGet, "/", nil,
|
t.Context(), http.MethodGet, "/", nil,
|
||||||
)
|
)
|
||||||
@@ -143,8 +132,10 @@ func TestClientIP(t *testing.T) {
|
|||||||
request.Header.Set("X-Real-IP", test.realIP)
|
request.Header.Set("X-Real-IP", test.realIP)
|
||||||
}
|
}
|
||||||
|
|
||||||
handler.ServeHTTP(httptest.NewRecorder(), request)
|
recorder := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
got := recorder.Body.String()
|
||||||
if got != test.want {
|
if got != test.want {
|
||||||
t.Errorf("RemoteAddr = %q, want %q", got, test.want)
|
t.Errorf("RemoteAddr = %q, want %q", got, test.want)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in new issue
Block a user