1 Commits
Author SHA1 Message Date
clawbot 4e268b9118 Bring README.md in line with the modes and the schema (closes #119)
check / check (push) Waiting to run
The MODE section described a query-only command. It now covers channel
and user mode queries and changes on the HTTP API and the IRC listener,
the letters each accepts, what is broadcast, and the error numerics.
The schema section now lists every table and column in
001_initial.sql.

The rest of the README was checked against the code, and each statement
the code contradicts is corrected: polled numerics carry their name in
command and their number in code; command errors are numerics with HTTP
200, not 404 or 409; the broker is keyed by session; WAL is never on; an
empty IRC_LISTEN_ADDR does not disable the listener; and the IRC
listener handles modes, INVITE, +s and +H more narrowly.

Model: opus-5-5
2026-10-08 06:05:25 +00:00
4 changed files with 105 additions and 203 deletions
+93 -107
View File
@@ -216,27 +216,25 @@ Each session has an IRC-style hostmask composed of three parts:
- **nick** — the user's current nick (changes with `NICK` command)
- **username** — an ident-like identifier set at session creation (optional
`username` field in the session request; defaults to the nick)
- **hostname** — the reverse DNS name of the creating client's IP address,
looked up at session creation time, or the IP address itself when the lookup
gives no name
- **hostname** — automatically resolved via reverse DNS of the connecting
client's IP address at session creation time
- **ip** — the real IP address of the session creator, extracted from
`X-Forwarded-For`, `X-Real-IP`, or `RemoteAddr`
Each **client connection** (created at session creation or login) also stores
its own **ip** and **hostname**, found the same way. The session's hostname is
the hostname of the client that created it, and every user can see it: in WHOIS
(311), WHO (352), USERHOST (302), NAMES (353) over the HTTP API, and
`GET /api/v1/channels/{name}/members`. Only `RPL_WHOISACTUALLY` (338) is for
**server operators** (o-line) alone: it gives the IP address and hostname of the
target's newest client.
its own **ip** and **hostname**, allowing the server to track the network origin
of each individual client independently from the session. Client-level IP and
hostname are **not displayed to regular users**. They are only visible to
**server operators** (o-line) via `RPL_WHOISACTUALLY` (338) when the oper
performs a WHOIS on a user.
The hostmask appears in:
- **WHOIS** (`311 RPL_WHOISUSER`) — `params` contains
`[nick, username, hostname, "*"]`
- **WHOIS (oper-only)** (`338 RPL_WHOISACTUALLY`) — when the querier is a server
operator, includes the IP address and hostname of the target's newest client
(HTTP API only; the IRC listener's WHOIS does not send it)
operator, includes the target's current client IP and hostname (HTTP API only;
the IRC listener's WHOIS does not send it)
- **WHO** (`352 RPL_WHOREPLY`) — `params` contains
`[channel, username, hostname, server, nick, flags]`
@@ -440,8 +438,7 @@ The entire read/write loop for a client is two endpoints. Everything else
```
┌─ Client ──────────────────────────────────────────────────┐
│ │
│ 1. POST /api/v1/session │
│ {"nick":"alice","pow_token":"<hashcash stamp>"} │
│ 1. POST /api/v1/session {"nick":"alice"} │
│ → Set-Cookie: neoirc_auth=<random_hex>; HttpOnly; ... │
│ → {"id":1, "nick":"alice"} │
│ │
@@ -474,8 +471,7 @@ The entire read/write loop for a client is two endpoints. Everything else
```
┌─ Client A ────────────────────────────────────────────────┐
│ │
│ 1. POST /api/v1/session │
│ {"nick":"alice","pow_token":"<hashcash stamp>"} │
│ 1. POST /api/v1/session {"nick":"alice"} │
│ → Set-Cookie: neoirc_auth=<cookie_a>; HttpOnly; ... │
│ → {"id":1, "nick":"alice"} │
│ │
@@ -1083,8 +1079,8 @@ RPL_WHOISCHANNELS (319), and RPL_ENDOFWHOIS (318).
If the querying user is a **server operator** (authenticated via `OPER`), the
response over the HTTP API additionally includes RPL_WHOISACTUALLY (338) with
the IP address and hostname of the target's newest client. The IRC listener's
WHOIS does not send it.
the target's current client IP address and hostname. The IRC listener's WHOIS
does not send it.
**C2S:**
@@ -1126,8 +1122,7 @@ LUSERS replies are also sent automatically during connection registration.
Authenticate as a server operator (o-line). On success, the session gains oper
privileges: it can use `KILL` and `WALLOPS`, and over the HTTP API its WHOIS
responses include more (the IP address and hostname of the target user's newest
client).
responses include more (the target user's current client IP and hostname).
**C2S:**
@@ -1271,6 +1266,12 @@ IRC listener sends the usual 3-digit code. Over the HTTP API, 005 arrives named
| `501` | ERR_UMODEUNKNOWNFLAG | User mode not accepted | `{"command":"ERR_UMODEUNKNOWNFLAG","code":501,"to":"alice","body":["Unknown MODE flag"]}` |
| `502` | ERR_USERSDONTMATCH | User MODE for another nick | `{"command":"ERR_USERSDONTMATCH","code":502,"to":"alice","body":["Can't change mode for other users"]}` |
**Note:** Numeric replies are now implemented. All IRC command responses
(success and error) are delivered as numeric replies through the message queue.
HTTP error codes are reserved for transport-level issues (auth failures,
malformed requests, server errors). The `params` field in the message envelope
carries IRC-style parameters (e.g., channel name, target nick).
### Channel Modes
Inspired by IRC, simplified. See [MODE](#mode--query-and-change-modes) for how
@@ -1351,10 +1352,10 @@ key ERR_BADCHANNELKEY (475), and one joining a full channel ERR_CHANNELISFULL
`KICK #channel nick [:reason]`. The kicked user and all channel members receive
the KICK message.
**NOTICE:** Over the HTTP API, a NOTICE gets no RPL_AWAY and no hashcash check
on `+H` channels, but it gets the same error numerics as PRIVMSG (411, 412, 401,
403, 404). The IRC listener handles NOTICE exactly as PRIVMSG, so a NOTICE to a
user who is away gets RPL_AWAY there.
**NOTICE:** Follows RFC 2812 over the HTTP API — NOTICE never triggers
auto-replies (including RPL_AWAY), and skips hashcash validation on +H channels
(servers and services use NOTICE). On the IRC listener, a NOTICE to a user who
is away does get RPL_AWAY.
**ISUPPORT:** The server advertises `PREFIX=(ov)@+` in RPL_ISUPPORT (005), with
`CHANMODES=b,k,Hl,imnst` over the HTTP API and `CHANMODES=,,H,imnst` on the IRC
@@ -1453,11 +1454,9 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
| `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
The `username` field sets the user portion of the IRC hostmask
(`nick!user@host`). The hostname is the reverse DNS name of the connecting
client's IP address, looked up at session creation time, or the IP address
itself when the lookup gives no name; every user can see it (see
[Hostmask](#hostmask-nickuserhost)). Together these form the hostmask used in
WHOIS, WHO, NAMES, and ban matching (`+b`).
(`nick!user@host`). The hostname is automatically resolved via reverse DNS of
the connecting client's IP address at session creation time. Together these form
the hostmask used in WHOIS, WHO, and ban matching (`+b`).
**Response:** `201 Created`
@@ -1736,12 +1735,12 @@ reference with all required and optional fields.
All IRC commands return HTTP 200 OK, usually with `{"status": "error"}` when the
reply is an error numeric (a WHOIS for an unknown nick still gets
`{"status": "ok"}`), and otherwise a status of the command's own (see each
command). **Numeric replies**, for success and for errors, are delivered through
the message queue (see [Numeric Reply Codes](#numeric-reply-codes-s2c-only)); a
command that has none, such as a successful `PRIVMSG`, is answered by the HTTP
response alone, and `PING`'s `PONG` is the HTTP response body. HTTP error codes
(4xx/5xx) are reserved for transport-level problems: malformed JSON (400),
missing/invalid auth cookies (401), and server errors (500).
command). IRC-level success and error responses are delivered as **numeric
replies** through the message queue (see
[Numeric Reply Codes](#numeric-reply-codes-s2c-only)); `PING` is the exception,
its `PONG` is the HTTP response body. HTTP error codes (4xx/5xx) are reserved
for transport-level problems: malformed JSON (400), missing/invalid auth cookies
(401), and server errors (500).
**HTTP errors (transport-level only):**
@@ -2408,36 +2407,36 @@ applied; `001_initial.sql` creates the tables below.
#### `sessions`
| Column | Type | Description |
| --------------- | -------- | -------------------------------------------------------------------------------- |
| `id` | INTEGER | Primary key (auto-increment) |
| `uuid` | TEXT | Unique session UUID |
| `nick` | TEXT | Unique nick |
| `username` | TEXT | IRC ident/username portion of the hostmask (defaults to nick) |
| `hostname` | TEXT | Reverse DNS name of the creating client's IP, or the IP itself when it has none |
| `ip` | TEXT | Real IP address of the session creator |
| `is_oper` | INTEGER | Server operator, user mode `+o` (0 = no, 1 = yes) |
| `is_wallops` | INTEGER | User mode `+w`, receives `WALLOPS` (0 = no, 1 = yes) |
| `password_hash` | TEXT | bcrypt hash (empty string for anonymous sessions) |
| `signing_key` | TEXT | Public signing key; nothing sets or reads it yet |
| `away_message` | TEXT | Away message (empty string if not away) |
| `created_at` | DATETIME | Session creation time |
| `last_seen` | DATETIME | Last login, authenticated HTTP API request or IRC listener command (or creation) |
| Column | Type | Description |
| --------------- | -------- | ------------------------------------------------------------- |
| `id` | INTEGER | Primary key (auto-increment) |
| `uuid` | TEXT | Unique session UUID |
| `nick` | TEXT | Unique nick |
| `username` | TEXT | IRC ident/username portion of the hostmask (defaults to nick) |
| `hostname` | TEXT | Reverse DNS hostname of the connecting client IP |
| `ip` | TEXT | Real IP address of the session creator |
| `is_oper` | INTEGER | Server operator, user mode `+o` (0 = no, 1 = yes) |
| `is_wallops` | INTEGER | User mode `+w`, receives `WALLOPS` (0 = no, 1 = yes) |
| `password_hash` | TEXT | bcrypt hash (empty string for anonymous sessions) |
| `signing_key` | TEXT | Public signing key; nothing sets or reads it yet |
| `away_message` | TEXT | Away message (empty string if not away) |
| `created_at` | DATETIME | Session creation time |
| `last_seen` | DATETIME | Last login or authenticated HTTP API request |
Index on `(uuid)`.
#### `clients`
| Column | Type | Description |
| ------------ | -------- | ------------------------------------------------------------------------- |
| `id` | INTEGER | Primary key (auto-increment) |
| `uuid` | TEXT | Unique client UUID |
| `session_id` | INTEGER | FK → sessions.id (cascade delete) |
| `token` | TEXT | Auth cookie value (SHA-256 hash of the 64-hex-char cookie) |
| `ip` | TEXT | Real IP address of this client connection |
| `hostname` | TEXT | Reverse DNS name of this client's IP, or the IP itself when it has none |
| `created_at` | DATETIME | Client creation time |
| `last_seen` | DATETIME | Last authenticated HTTP API request or IRC listener command (or creation) |
| Column | Type | Description |
| ------------ | -------- | ---------------------------------------------------------- |
| `id` | INTEGER | Primary key (auto-increment) |
| `uuid` | TEXT | Unique client UUID |
| `session_id` | INTEGER | FK → sessions.id (cascade delete) |
| `token` | TEXT | Auth cookie value (SHA-256 hash of the 64-hex-char cookie) |
| `ip` | TEXT | Real IP address of this client connection |
| `hostname` | TEXT | Reverse DNS hostname of this client connection |
| `created_at` | DATETIME | Client creation time |
| `last_seen` | DATETIME | Last authenticated HTTP API request (or creation) |
Indexes on `(token)` and `(session_id)`.
@@ -2555,9 +2554,10 @@ issues) and simpler than UUIDs (integer comparison vs. string comparison).
session types in the cleanup path — `handleQuit` and `cleanupUser` both call
`DeleteSession` unconditionally. Idle clients are automatically removed after
`SESSION_IDLE_TIMEOUT` (default 30 days) without an authenticated HTTP API
request or, on the IRC listener, a command — the server runs a background
cleanup loop that, for a session left with no clients, parts the user from all
channels, broadcasts QUIT, and releases the nick.
request — the server runs a background cleanup loop that, for a session left
with no clients, parts the user from all channels, broadcasts QUIT, and
releases the nick. A client on the IRC listener never makes such a request, so
it counts as idle from when it connected.
- **Clients**: Individual client auth cookies are invalidated on
`POST /api/v1/logout`. A session can have multiple clients; removing one
doesn't affect others. However, when the last client is removed (via logout),
@@ -2581,28 +2581,28 @@ and Viper also reads a YAML file `neoirc.yaml` from `/etc/neoirc/` or
environment variable wins over the file. An environment variable set to the
empty string counts as unset, so the file's value or the default applies.
| Variable | Type | Default | Description |
| ---------------------- | ------ | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PORT` | int | `8080` | HTTP listen port |
| `DBURL` | string | `file:///var/lib/neoirc/state.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:///path/to/db.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. The SQLite driver ignores `_journal_mode`, so this does not turn on WAL mode. |
| `DEBUG` | bool | `false` | Switch logging to debug level |
| `MESSAGE_MAX_AGE` | string | `720h` | Maximum age of messages as a Go duration string (e.g. `720h`, `24h`). Messages older than this are pruned. Default is 30 days. |
| `SESSION_IDLE_TIMEOUT` | string | `720h` | Idle timeout as a Go duration string (e.g. `720h`, `24h`). Clients with no authenticated HTTP API request or IRC listener command for this long are removed, and a session left with none is expired and its nick released (see [Data Lifecycle](#data-lifecycle)). Default is 30 days. |
| `QUEUE_MAX_AGE` | string | `720h` | Maximum age of client output queue entries as a Go duration string (e.g. `720h`, `24h`). Entries older than this are pruned. Default is 30 days. |
| `MAX_MESSAGE_SIZE` | int | `4096` | Maximum request body size in bytes for `POST /api/v1/session`, `POST /api/v1/login` and `POST /api/v1/messages`; a larger body gets `400` `invalid request body` |
| `MOTD` | string | a built-in banner | Message of the day, sent to clients as MOTD numerics and shown via `GET /api/v1/server` |
| `SERVER_NAME` | string | `""` | Server display name. If empty, the server calls itself `neoirc` in replies and in `GET /api/v1/server` returns it empty. |
| `FEDERATION_KEY` | string | `""` | Shared key for server federation linking (planned) |
| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) |
| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. |
| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint |
| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. |
| `NEOIRC_OPER_NAME` | string | `""` | Server operator (o-line) username. Both name and password must be set to enable OPER. |
| `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. |
| `LOGIN_RATE_LIMIT` | float | `1` | Allowed login attempts per second per IP address. |
| `LOGIN_RATE_BURST` | int | `5` | Maximum burst of login attempts per IP before rate limiting kicks in. |
| `IRC_LISTEN_ADDR` | string | `:6667` | TCP address for the traditional IRC protocol listener. Only an empty value in `neoirc.yaml` disables it; an empty environment variable does not. |
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
| Variable | Type | Default | Description |
| ---------------------- | ------ | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PORT` | int | `8080` | HTTP listen port |
| `DBURL` | string | `file:///var/lib/neoirc/state.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:///path/to/db.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. The SQLite driver ignores `_journal_mode`, so this does not turn on WAL mode. |
| `DEBUG` | bool | `false` | Switch logging to debug level |
| `MESSAGE_MAX_AGE` | string | `720h` | Maximum age of messages as a Go duration string (e.g. `720h`, `24h`). Messages older than this are pruned. Default is 30 days. |
| `SESSION_IDLE_TIMEOUT` | string | `720h` | Idle timeout as a Go duration string (e.g. `720h`, `24h`). Clients with no authenticated HTTP API request for this long are removed, and a session left with none is expired and its nick released (see [Data Lifecycle](#data-lifecycle)). Default is 30 days. |
| `QUEUE_MAX_AGE` | string | `720h` | Maximum age of client output queue entries as a Go duration string (e.g. `720h`, `24h`). Entries older than this are pruned. Default is 30 days. |
| `MAX_MESSAGE_SIZE` | int | `4096` | Maximum request body size in bytes for `POST /api/v1/session`, `POST /api/v1/login` and `POST /api/v1/messages`; a larger body gets `400` `invalid request body` |
| `MOTD` | string | a built-in banner | Message of the day, sent to clients as MOTD numerics and shown via `GET /api/v1/server` |
| `SERVER_NAME` | string | `""` | Server display name. If empty, the server calls itself `neoirc` in replies and in `GET /api/v1/server` returns it empty. |
| `FEDERATION_KEY` | string | `""` | Shared key for server federation linking (planned) |
| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) |
| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. |
| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint |
| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. |
| `NEOIRC_OPER_NAME` | string | `""` | Server operator (o-line) username. Both name and password must be set to enable OPER. |
| `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. |
| `LOGIN_RATE_LIMIT` | float | `1` | Allowed login attempts per second per IP address. |
| `LOGIN_RATE_BURST` | int | `5` | Maximum burst of login attempts per IP before rate limiting kicks in. |
| `IRC_LISTEN_ADDR` | string | `:6667` | TCP address for the traditional IRC protocol listener. Only an empty value in `neoirc.yaml` disables it; an empty environment variable does not. |
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
### Example `.env` file
@@ -2677,22 +2677,8 @@ IRC_LISTEN_ADDR: ""
Messages sent by IRC clients appear in channels visible to HTTP/JSON API clients
and vice versa. The IRC listener and HTTP API share the same database, broker,
and session infrastructure, so a user connected via IRC and a user connected via
the HTTP API can talk in the same channels, with these gaps today:
- An IRC listener user receives only the first line of a multi-line `body`.
- A `+k`, `+l` or `+b` change made over the HTTP API reaches IRC listener
members without its key, limit or mask.
- The IRC listener's `JOIN` accepts any channel name, while over the HTTP API a
name that is not `#` followed by 1–63 letters, digits, `_` or `-` gets
ERR_NOSUCHCHANNEL (403), so HTTP API users cannot join such a channel.
- The IRC listener does not pass on a `PRIVMSG`, `NOTICE`, `JOIN`, `PART`,
`NICK` or `QUIT` whose sender's nick matches the user's own nick ignoring
case, so an IRC listener user `alice` never sees these from `Alice`, although
nicks are case-sensitive.
https://git.eeqj.de/sneak/neoirc/issues/127 tracks the first three and
https://git.eeqj.de/sneak/neoirc/issues/128 the last.
and session infrastructure. A user connected via IRC and a user connected via
the HTTP API can communicate in the same channels seamlessly.
### Docker Usage
@@ -3177,11 +3163,11 @@ When the limit is exceeded, the server returns **429 Too Many Requests** with a
> targeted attack.
**Why rate limits here but not on session creation?** Session creation is
protected by hashcash proof-of-work (no IP tracking needed). Login involves
bcrypt password verification against a registered account — a fundamentally
different threat model where an attacker targets a specific account. Per-IP rate
limiting is appropriate here because the cost of a wrong guess is borne by the
server (bcrypt), not the client.
protected by hashcash proof-of-work (stateless, no IP tracking needed). Login
involves bcrypt password verification against a registered account — a
fundamentally different threat model where an attacker targets a specific
account. Per-IP rate limiting is appropriate here because the cost of a wrong
guess is borne by the server (bcrypt), not the client.
---
@@ -3236,8 +3222,8 @@ server (bcrypt), not the client.
331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-502 errors)
- [x] **Max message size enforcement** — reject request bodies over
`MAX_MESSAGE_SIZE` bytes
- [x] **NOTICE command** — distinct from PRIVMSG over the HTTP API (no RPL_AWAY,
no hashcash check)
- [x] **NOTICE command** — distinct from PRIVMSG (no RPL_AWAY, no hashcash
check)
- [x] **Multi-client sessions** — set a password via PASS command, then login
from additional devices via `POST /api/v1/login`
- [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for all API
+3 -24
View File
@@ -204,42 +204,21 @@ func (database *Database) GetSessionByToken(
)
}
_ = database.UpdateLastSeen(ctx, sessionID, clientID)
return sessionID, clientID, nick, nil
}
// UpdateLastSeen sets last_seen to now on a session and on
// one of its clients, so that the idle cleanup keeps them.
func (database *Database) UpdateLastSeen(
ctx context.Context,
sessionID, clientID int64,
) error {
now := time.Now()
_, err := database.conn.ExecContext(
_, _ = database.conn.ExecContext(
ctx,
"UPDATE sessions SET last_seen = ? WHERE id = ?",
now, sessionID,
)
if err != nil {
return fmt.Errorf(
"update session last_seen: %w", err,
)
}
_, err = database.conn.ExecContext(
_, _ = database.conn.ExecContext(
ctx,
"UPDATE clients SET last_seen = ? WHERE id = ?",
now, clientID,
)
if err != nil {
return fmt.Errorf(
"update client last_seen: %w", err,
)
}
return nil
return sessionID, clientID, nick, nil
}
// GetSessionByNick returns session id for a given nick.
-11
View File
@@ -368,17 +368,6 @@ func (c *Conn) handleMessage(
return
}
// Every command is activity, so the idle cleanup must not
// remove this user.
err := c.database.UpdateLastSeen(
ctx, c.sessionID, c.clientID,
)
if err != nil {
c.log.Error(
"failed to update last_seen", "error", err,
)
}
handler, ok := c.commands[msg.Command]
if !ok {
c.sendNumeric(
+9 -61
View File
@@ -2,7 +2,6 @@ package ircserver_test
import (
"bufio"
"crypto/rand"
"database/sql"
"fmt"
"log/slog"
@@ -70,11 +69,9 @@ func newTestEnvWithConfig(
) *testEnv {
t.Helper()
// A random name, so that no other test environment, not
// even an earlier run of the same test, can share it.
dsn := fmt.Sprintf(
"file:%s?mode=memory&cache=shared&_journal_mode=WAL",
rand.Text(),
t.Name(),
)
conn, err := sql.Open("sqlite", dsn)
@@ -82,13 +79,6 @@ func newTestEnvWithConfig(
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() {
err := conn.Close()
if err != nil {
t.Logf("close db: %v", err)
}
})
conn.SetMaxOpenConns(1)
_, err = conn.ExecContext(
@@ -133,9 +123,14 @@ func newTestEnvWithConfig(
t.Fatalf("start irc server: %v", err)
}
// Cleanups run last registered first, so the server stops
// before its database is closed.
t.Cleanup(srv.Stop)
t.Cleanup(func() {
srv.Stop()
err := conn.Close()
if err != nil {
t.Logf("close db: %v", err)
}
})
return &testEnv{
database: database,
@@ -347,20 +342,6 @@ func TestRegistration(t *testing.T) {
assertContains(t, lines, " 001 ", "RPL_WELCOME")
}
// TestEachTestEnvHasItsOwnDatabase checks that a test
// environment starts on an empty database while another one
// is still open.
func TestEachTestEnvHasItsOwnDatabase(t *testing.T) {
t.Parallel()
first := newTestEnv(t)
first.dial(t).register("samenick")
second := newTestEnv(t)
lines := second.dial(t).register("samenick")
assertContains(t, lines, " 001 ", "RPL_WELCOME")
}
func TestWelcomeContainsNick(t *testing.T) {
t.Parallel()
@@ -388,39 +369,6 @@ func TestPingPong(t *testing.T) {
assertContains(t, lines, "PONG", "PONG response")
}
// TestIdleCleanupKeepsActiveUser checks that a user who
// sends commands is kept by the idle cleanup, and a user who
// sends nothing is not.
func TestIdleCleanupKeepsActiveUser(t *testing.T) {
t.Parallel()
const idleTimeout = 500 * time.Millisecond
env := newTestEnv(t)
active := env.dial(t)
active.register("active")
idle := env.dial(t)
idle.register("idle")
time.Sleep(idleTimeout)
active.sendAndExpect("PING :still here", "PONG")
// The idle cleanup in internal/handlers removes exactly
// the users this returns.
stale, err := env.database.GetStaleOrphanSessions(
t.Context(), time.Now().Add(-idleTimeout),
)
if err != nil {
t.Fatalf("get stale sessions: %v", err)
}
if len(stale) != 1 || stale[0].Nick != "idle" {
t.Errorf("cleanup removes %v, want only idle", stale)
}
}
func TestJoinChannel(t *testing.T) {
t.Parallel()