1 Commits
Author SHA1 Message Date
clawbot 4e268b9118 Bring README.md in line with the modes and the schema (closes #119)
check / check (push) Canceled after 0s
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
+54 -68
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) - **nick** — the user's current nick (changes with `NICK` command)
- **username** — an ident-like identifier set at session creation (optional - **username** — an ident-like identifier set at session creation (optional
`username` field in the session request; defaults to the nick) `username` field in the session request; defaults to the nick)
- **hostname** — the reverse DNS name of the creating client's IP address, - **hostname** — automatically resolved via reverse DNS of the connecting
looked up at session creation time, or the IP address itself when the lookup client's IP address at session creation time
gives no name
- **ip** — the real IP address of the session creator, extracted from - **ip** — the real IP address of the session creator, extracted from
`X-Forwarded-For`, `X-Real-IP`, or `RemoteAddr` `X-Forwarded-For`, `X-Real-IP`, or `RemoteAddr`
Each **client connection** (created at session creation or login) also stores 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 its own **ip** and **hostname**, allowing the server to track the network origin
the hostname of the client that created it, and every user can see it: in WHOIS of each individual client independently from the session. Client-level IP and
(311), WHO (352), USERHOST (302), NAMES (353) over the HTTP API, and hostname are **not displayed to regular users**. They are only visible to
`GET /api/v1/channels/{name}/members`. Only `RPL_WHOISACTUALLY` (338) is for **server operators** (o-line) via `RPL_WHOISACTUALLY` (338) when the oper
**server operators** (o-line) alone: it gives the IP address and hostname of the performs a WHOIS on a user.
target's newest client.
The hostmask appears in: The hostmask appears in:
- **WHOIS** (`311 RPL_WHOISUSER`) — `params` contains - **WHOIS** (`311 RPL_WHOISUSER`) — `params` contains
`[nick, username, hostname, "*"]` `[nick, username, hostname, "*"]`
- **WHOIS (oper-only)** (`338 RPL_WHOISACTUALLY`) — when the querier is a server - **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 operator, includes the target's current client IP and hostname (HTTP API only;
(HTTP API only; the IRC listener's WHOIS does not send it) the IRC listener's WHOIS does not send it)
- **WHO** (`352 RPL_WHOREPLY`) — `params` contains - **WHO** (`352 RPL_WHOREPLY`) — `params` contains
`[channel, username, hostname, server, nick, flags]` `[channel, username, hostname, server, nick, flags]`
@@ -440,8 +438,7 @@ The entire read/write loop for a client is two endpoints. Everything else
``` ```
┌─ Client ──────────────────────────────────────────────────┐ ┌─ Client ──────────────────────────────────────────────────┐
│ │ │ │
│ 1. POST /api/v1/session │ │ 1. POST /api/v1/session {"nick":"alice"} │
│ {"nick":"alice","pow_token":"<hashcash stamp>"} │
│ → Set-Cookie: neoirc_auth=<random_hex>; HttpOnly; ... │ │ → Set-Cookie: neoirc_auth=<random_hex>; HttpOnly; ... │
│ → {"id":1, "nick":"alice"} │ │ → {"id":1, "nick":"alice"} │
│ │ │ │
@@ -474,8 +471,7 @@ The entire read/write loop for a client is two endpoints. Everything else
``` ```
┌─ Client A ────────────────────────────────────────────────┐ ┌─ Client A ────────────────────────────────────────────────┐
│ │ │ │
│ 1. POST /api/v1/session │ │ 1. POST /api/v1/session {"nick":"alice"} │
│ {"nick":"alice","pow_token":"<hashcash stamp>"} │
│ → Set-Cookie: neoirc_auth=<cookie_a>; HttpOnly; ... │ │ → Set-Cookie: neoirc_auth=<cookie_a>; HttpOnly; ... │
│ → {"id":1, "nick":"alice"} │ │ → {"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 If the querying user is a **server operator** (authenticated via `OPER`), the
response over the HTTP API additionally includes RPL_WHOISACTUALLY (338) with 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 the target's current client IP address and hostname. The IRC listener's WHOIS
WHOIS does not send it. does not send it.
**C2S:** **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 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 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 responses include more (the target user's current client IP and hostname).
client).
**C2S:** **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"]}` | | `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"]}` | | `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 ### Channel Modes
Inspired by IRC, simplified. See [MODE](#mode--query-and-change-modes) for how 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 `KICK #channel nick [:reason]`. The kicked user and all channel members receive
the KICK message. the KICK message.
**NOTICE:** Over the HTTP API, a NOTICE gets no RPL_AWAY and no hashcash check **NOTICE:** Follows RFC 2812 over the HTTP API — NOTICE never triggers
on `+H` channels, but it gets the same error numerics as PRIVMSG (411, 412, 401, auto-replies (including RPL_AWAY), and skips hashcash validation on +H channels
403, 404). The IRC listener handles NOTICE exactly as PRIVMSG, so a NOTICE to a (servers and services use NOTICE). On the IRC listener, a NOTICE to a user who
user who is away gets RPL_AWAY there. is away does get RPL_AWAY.
**ISUPPORT:** The server advertises `PREFIX=(ov)@+` in RPL_ISUPPORT (005), with **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 `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) | | `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
The `username` field sets the user portion of the IRC hostmask The `username` field sets the user portion of the IRC hostmask
(`nick!user@host`). The hostname is the reverse DNS name of the connecting (`nick!user@host`). The hostname is automatically resolved via reverse DNS of
client's IP address, looked up at session creation time, or the IP address the connecting client's IP address at session creation time. Together these form
itself when the lookup gives no name; every user can see it (see the hostmask used in WHOIS, WHO, and ban matching (`+b`).
[Hostmask](#hostmask-nickuserhost)). Together these form the hostmask used in
WHOIS, WHO, NAMES, and ban matching (`+b`).
**Response:** `201 Created` **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 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 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 `{"status": "ok"}`), and otherwise a status of the command's own (see each
command). **Numeric replies**, for success and for errors, are delivered through command). IRC-level success and error responses are delivered as **numeric
the message queue (see [Numeric Reply Codes](#numeric-reply-codes-s2c-only)); a replies** through the message queue (see
command that has none, such as a successful `PRIVMSG`, is answered by the HTTP [Numeric Reply Codes](#numeric-reply-codes-s2c-only)); `PING` is the exception,
response alone, and `PING`'s `PONG` is the HTTP response body. HTTP error codes its `PONG` is the HTTP response body. HTTP error codes (4xx/5xx) are reserved
(4xx/5xx) are reserved for transport-level problems: malformed JSON (400), for transport-level problems: malformed JSON (400), missing/invalid auth cookies
missing/invalid auth cookies (401), and server errors (500). (401), and server errors (500).
**HTTP errors (transport-level only):** **HTTP errors (transport-level only):**
@@ -2409,12 +2408,12 @@ applied; `001_initial.sql` creates the tables below.
#### `sessions` #### `sessions`
| Column | Type | Description | | Column | Type | Description |
| --------------- | -------- | -------------------------------------------------------------------------------- | | --------------- | -------- | ------------------------------------------------------------- |
| `id` | INTEGER | Primary key (auto-increment) | | `id` | INTEGER | Primary key (auto-increment) |
| `uuid` | TEXT | Unique session UUID | | `uuid` | TEXT | Unique session UUID |
| `nick` | TEXT | Unique nick | | `nick` | TEXT | Unique nick |
| `username` | TEXT | IRC ident/username portion of the hostmask (defaults to 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 | | `hostname` | TEXT | Reverse DNS hostname of the connecting client IP |
| `ip` | TEXT | Real IP address of the session creator | | `ip` | TEXT | Real IP address of the session creator |
| `is_oper` | INTEGER | Server operator, user mode `+o` (0 = no, 1 = yes) | | `is_oper` | INTEGER | Server operator, user mode `+o` (0 = no, 1 = yes) |
| `is_wallops` | INTEGER | User mode `+w`, receives `WALLOPS` (0 = no, 1 = yes) | | `is_wallops` | INTEGER | User mode `+w`, receives `WALLOPS` (0 = no, 1 = yes) |
@@ -2422,22 +2421,22 @@ applied; `001_initial.sql` creates the tables below.
| `signing_key` | TEXT | Public signing key; nothing sets or reads it yet | | `signing_key` | TEXT | Public signing key; nothing sets or reads it yet |
| `away_message` | TEXT | Away message (empty string if not away) | | `away_message` | TEXT | Away message (empty string if not away) |
| `created_at` | DATETIME | Session creation time | | `created_at` | DATETIME | Session creation time |
| `last_seen` | DATETIME | Last login, authenticated HTTP API request or IRC listener command (or creation) | | `last_seen` | DATETIME | Last login or authenticated HTTP API request |
Index on `(uuid)`. Index on `(uuid)`.
#### `clients` #### `clients`
| Column | Type | Description | | Column | Type | Description |
| ------------ | -------- | ------------------------------------------------------------------------- | | ------------ | -------- | ---------------------------------------------------------- |
| `id` | INTEGER | Primary key (auto-increment) | | `id` | INTEGER | Primary key (auto-increment) |
| `uuid` | TEXT | Unique client UUID | | `uuid` | TEXT | Unique client UUID |
| `session_id` | INTEGER | FK → sessions.id (cascade delete) | | `session_id` | INTEGER | FK → sessions.id (cascade delete) |
| `token` | TEXT | Auth cookie value (SHA-256 hash of the 64-hex-char cookie) | | `token` | TEXT | Auth cookie value (SHA-256 hash of the 64-hex-char cookie) |
| `ip` | TEXT | Real IP address of this client connection | | `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 | | `hostname` | TEXT | Reverse DNS hostname of this client connection |
| `created_at` | DATETIME | Client creation time | | `created_at` | DATETIME | Client creation time |
| `last_seen` | DATETIME | Last authenticated HTTP API request or IRC listener command (or creation) | | `last_seen` | DATETIME | Last authenticated HTTP API request (or creation) |
Indexes on `(token)` and `(session_id)`. 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 session types in the cleanup path — `handleQuit` and `cleanupUser` both call
`DeleteSession` unconditionally. Idle clients are automatically removed after `DeleteSession` unconditionally. Idle clients are automatically removed after
`SESSION_IDLE_TIMEOUT` (default 30 days) without an authenticated HTTP API `SESSION_IDLE_TIMEOUT` (default 30 days) without an authenticated HTTP API
request or, on the IRC listener, a command — the server runs a background request — the server runs a background cleanup loop that, for a session left
cleanup loop that, for a session left with no clients, parts the user from all with no clients, parts the user from all channels, broadcasts QUIT, and
channels, broadcasts QUIT, and releases the nick. 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 - **Clients**: Individual client auth cookies are invalidated on
`POST /api/v1/logout`. A session can have multiple clients; removing one `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), doesn't affect others. However, when the last client is removed (via logout),
@@ -2582,12 +2582,12 @@ 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. empty string counts as unset, so the file's value or the default applies.
| Variable | Type | Default | Description | | Variable | Type | Default | Description |
| ---------------------- | ------ | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------- | ------ | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PORT` | int | `8080` | HTTP listen port | | `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. | | `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 | | `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. | | `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. | | `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. | | `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` | | `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` | | `MOTD` | string | a built-in banner | Message of the day, sent to clients as MOTD numerics and shown via `GET /api/v1/server` |
@@ -2677,22 +2677,8 @@ IRC_LISTEN_ADDR: ""
Messages sent by IRC clients appear in channels visible to HTTP/JSON API clients 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 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 and session infrastructure. A user connected via IRC and a user connected via
the HTTP API can talk in the same channels, with these gaps today: the HTTP API can communicate in the same channels seamlessly.
- 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.
### Docker Usage ### Docker Usage
@@ -3177,11 +3163,11 @@ When the limit is exceeded, the server returns **429 Too Many Requests** with a
> targeted attack. > targeted attack.
**Why rate limits here but not on session creation?** Session creation is **Why rate limits here but not on session creation?** Session creation is
protected by hashcash proof-of-work (no IP tracking needed). Login involves protected by hashcash proof-of-work (stateless, no IP tracking needed). Login
bcrypt password verification against a registered account — a fundamentally involves bcrypt password verification against a registered account — a
different threat model where an attacker targets a specific account. Per-IP rate fundamentally different threat model where an attacker targets a specific
limiting is appropriate here because the cost of a wrong guess is borne by the account. Per-IP rate limiting is appropriate here because the cost of a wrong
server (bcrypt), not the client. 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) 331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-502 errors)
- [x] **Max message size enforcement** — reject request bodies over - [x] **Max message size enforcement** — reject request bodies over
`MAX_MESSAGE_SIZE` bytes `MAX_MESSAGE_SIZE` bytes
- [x] **NOTICE command** — distinct from PRIVMSG over the HTTP API (no RPL_AWAY, - [x] **NOTICE command** — distinct from PRIVMSG (no RPL_AWAY, no hashcash
no hashcash check) check)
- [x] **Multi-client sessions** — set a password via PASS command, then login - [x] **Multi-client sessions** — set a password via PASS command, then login
from additional devices via `POST /api/v1/login` from additional devices via `POST /api/v1/login`
- [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for all API - [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() now := time.Now()
_, err := database.conn.ExecContext( _, _ = database.conn.ExecContext(
ctx, ctx,
"UPDATE sessions SET last_seen = ? WHERE id = ?", "UPDATE sessions SET last_seen = ? WHERE id = ?",
now, sessionID, now, sessionID,
) )
if err != nil {
return fmt.Errorf(
"update session last_seen: %w", err,
)
}
_, err = database.conn.ExecContext( _, _ = database.conn.ExecContext(
ctx, ctx,
"UPDATE clients SET last_seen = ? WHERE id = ?", "UPDATE clients SET last_seen = ? WHERE id = ?",
now, clientID, 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. // GetSessionByNick returns session id for a given nick.
-11
View File
@@ -368,17 +368,6 @@ func (c *Conn) handleMessage(
return 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] handler, ok := c.commands[msg.Command]
if !ok { if !ok {
c.sendNumeric( c.sendNumeric(
+9 -61
View File
@@ -2,7 +2,6 @@ package ircserver_test
import ( import (
"bufio" "bufio"
"crypto/rand"
"database/sql" "database/sql"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -70,11 +69,9 @@ func newTestEnvWithConfig(
) *testEnv { ) *testEnv {
t.Helper() 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( dsn := fmt.Sprintf(
"file:%s?mode=memory&cache=shared&_journal_mode=WAL", "file:%s?mode=memory&cache=shared&_journal_mode=WAL",
rand.Text(), t.Name(),
) )
conn, err := sql.Open("sqlite", dsn) conn, err := sql.Open("sqlite", dsn)
@@ -82,13 +79,6 @@ func newTestEnvWithConfig(
t.Fatalf("open db: %v", err) t.Fatalf("open db: %v", err)
} }
t.Cleanup(func() {
err := conn.Close()
if err != nil {
t.Logf("close db: %v", err)
}
})
conn.SetMaxOpenConns(1) conn.SetMaxOpenConns(1)
_, err = conn.ExecContext( _, err = conn.ExecContext(
@@ -133,9 +123,14 @@ func newTestEnvWithConfig(
t.Fatalf("start irc server: %v", err) t.Fatalf("start irc server: %v", err)
} }
// Cleanups run last registered first, so the server stops t.Cleanup(func() {
// before its database is closed. srv.Stop()
t.Cleanup(srv.Stop)
err := conn.Close()
if err != nil {
t.Logf("close db: %v", err)
}
})
return &testEnv{ return &testEnv{
database: database, database: database,
@@ -347,20 +342,6 @@ func TestRegistration(t *testing.T) {
assertContains(t, lines, " 001 ", "RPL_WELCOME") 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) { func TestWelcomeContainsNick(t *testing.T) {
t.Parallel() t.Parallel()
@@ -388,39 +369,6 @@ func TestPingPong(t *testing.T) {
assertContains(t, lines, "PONG", "PONG response") 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) { func TestJoinChannel(t *testing.T) {
t.Parallel() t.Parallel()