9 Commits
Author SHA1 Message Date
clawbot bc8aa6bb96 Remove the unused GetSessionChannelIDs from the db package (closes #254)
check / check (push) Canceled after 0s
Neither the server nor any test calls this method.

Model: opus-5-5
2026-11-03 12:38:42 +01:00
clawbot bc7ef8240b Keep Go's caches out of the test stage's image (closes #256)
check / check (push) Canceled after 0s
The test stage ran go mod download in its own layer, and go test left
Go's build cache in another. Exporting them took about 20 seconds of
every make test, and nothing reads either one afterwards.

Removing the build cache alone saved almost nothing, because the module
cache is the larger layer. So the download now runs in the same RUN as
go test, and go clean removes both caches once the tests pass. A failing
run still reruns with -v and exits 1.

Model: opus-5-5
2026-11-03 12:20:48 +01:00
clawbot 8afd873813 Move the test-only helpers out of the shipped db package (closes #248)
check / check (push) Canceled after 0s
The server binary carried exported names that only tests call, including
raw SQL access and a way to weaken the password hash.

Delete testing.go, GetDB and SetBcryptCost from the shipped code, so
passwords always hash at bcrypt.DefaultCost. Move GetDB, GetOrCreateChannel,
JoinChannel and HasChannelInvite into export_test.go. Delete
GetClientHostInfo, whose remaining check GetLatestClientForSession covers.

Tests in other packages now build the database with db.New and run raw SQL
through dbtest.Exec. Handler tests and the relay tests' aliceWithHTTPClient
helper need a password only to log in, so they store one hashed at
bcrypt.MinCost. Tests of PASS itself keep the real cost.

Model: opus-5-5
2026-11-03 11:59:17 +01:00
clawbot c12668226f Apply MAX_MESSAGE_SIZE to the whole request body (closes #250)
check / check (push) Canceled after 0s
Creating a session, logging in and posting a message stopped reading
after the first JSON value, so the limit never saw the rest of the
body and trailing bytes were ignored. All three now read the whole
limited body and decode it with json.Unmarshal. A body over the limit,
or anything but whitespace after the value, gets 400 invalid request
body.

Model: opus-5-5
2026-11-03 09:20:42 +01:00
clawbot 4221f71920 Add the # to RPL_ENDOFWHO for an unknown channel over HTTP (closes #249)
check / check (push) Canceled after 0s
WHO over the HTTP API adds a missing # to the channel name, but the
unknown-channel path sent RPL_ENDOFWHO with the name as sent. A client
keyed on the channel name saw two spellings. Both paths now send the
name with the #.

Model: opus-5-5
2026-11-03 08:38:44 +01:00
clawbot 254873a656 Fix the sentences a fresh read of README.md failed (closes #190)
check / check (push) Canceled after 0s
A fresh read of README.md on next, sentence by sentence against
the rules on #190 and the
code, failed one sentence. The IRC listener bullet on PASS after
registration ("answers a password of the wrong length with a
NOTICE, not ERR_NEEDMOREPARAMS (461), and a stored password with a
NOTICE too") needs a second reading to see that "a stored password"
means the case where the password is accepted. It is now two short
sentences: the NOTICE on success first, then the NOTICE instead of
461 on a wrong length. Both facts match handlePassPostReg and
checkPasswordLength in internal/ircserver. Every other sentence
passed; nothing else is changed.

Model: fable-5-1
2026-11-03 08:20:55 +01:00
clawbot 0b63384423 Rewrite README.md sentence by sentence
check / check (push) Canceled after 0s
README.md is rewritten from scratch against the rules on issue 190 and
checked statement by statement against the code on next. It leads with
what the program is and how to use it, puts every endpoint, command,
numeric reply, mode and setting in reference sections, and ends with
development and testing. Where the old text disagreed with the code,
the code decides; the PR body lists each case. The hashcash examples
are real 20-bit stamps. REPO_POLICIES.md and CONVENTIONS.md are copies
of shared documents and are untouched.

Model: fable-5-1 (writing, rework)
2026-11-03 07:38:43 +01:00
clawbot c4fc156a8c Rewrite AGENTS.md sentence by sentence
check / check (push) Canceled after 0s
Rewrite the agent rules from scratch, with one idea per sentence and
every file name and command in backticks. Drop the rule that models
embed `models.Base`, which no package has. Say that the linter keeps
tests in external test packages, and that an `export_test.go` file
gives them access to unexported code. Limit the rules on doc comments
and blank imports to files outside the tests. State magic numbers as
a preference, and correct the blank-line rule for `return`. State the
branch workflow: pull requests target `next` and only the owner
merges `next` into `main`. Record the three snake_case JSON tags the
HTTP API keeps.

Part of #190.

Model: fable-5-1 (writing, rework); opus-5-5 (rework)
2026-11-03 06:59:36 +01:00
clawbot 5fe325399b Show square brackets in the neoirc-cli message window as written (closes #233)
check / check (push) Canceled after 0s
The message window has colour tags on, so tview read square-bracket
text in nicks, channel names, reasons, topics and message text as
tags. It dropped that text or changed the colour of the rest of the
line.

A new escape in internal/cli/ui.go writes each "[" as "[[:]", and
app.go applies it to every part of a line that comes from the user or
the server. tview.Escape is not used: it misses bracket pairs holding
symbols such as "|", and tags with a URL.

Lines for other server commands now show the command, such as
"[MODE]", which tview had read as a tag.

Model: opus-5-5
2026-11-02 08:30:56 +01:00
18 changed files with 2155 additions and 3540 deletions
+56 -38
View File
@@ -1,55 +1,73 @@
# AGENTS.md — Rules for AI Agents # Rules for coding agents
## Before Every Commit This file holds the rules for coding agents working in this repository.
`CONVENTIONS.md` defines the architecture. `REPO_POLICIES.md` defines the
repository standards. Both are copies of shared documents. Do not edit them
here.
1. **Format**: `make fmt` ## Before every commit
2. **Check**: `make check` — the tests and the linter, which run in Docker, and
the format check: all passing, zero issues
Never run `go test`, `golangci-lint` or `gofmt` directly; use the `make` 1. Run `make fmt`.
targets. 2. Run `make check`. It runs the tests and the linter in Docker, then the format
check. All three must pass with no findings.
No commit lands on main with lint errors, test failures, or formatting issues. Never run `go test`, `golangci-lint` or `gofmt` directly. Use the `make` targets
instead, such as `make test` and `make lint`.
## Code Style ## Branches and pull requests
- Follow CONVENTIONS.md exactly — it defines the architecture - Never commit to `main` or `next` directly. Every change is a pull request from
- All exported types, functions, methods, and vars must have doc comments a feature branch into `next`.
- All packages must have a package doc comment - Only the owner merges `next` into `main`.
- JSON tags use camelCase (not snake_case) - A pull request that closes an issue has the title `Description (closes #N)`.
- No magic numbers — use named constants - One logical change per commit.
- No inline error handling (`if err := ...; err != nil`) — use plain assignment - Commit messages are in the imperative mood and short.
- Blank line before every `return` statement
- Blank line separating embedded fields from regular fields in structs
- Blank imports must have a justification comment
## Git Workflow ## Code style
- **Never commit directly to main** — all changes go on feature branches - Follow `CONVENTIONS.md`.
- Merge to main only when ready and passing all checks - Every package has a package doc comment. Test files need none.
- PR titles: `Description (closes #N)` when closing an issue - Every exported type, function and variable has a doc comment. Test files need
- One logical change per commit none.
- Commit messages: imperative mood, concise - JSON tags are camelCase. The HTTP API's `last_id`, `pow_token` and
`hashcash_bits` stay as they are, each with a `//nolint:tagliatelle` comment.
- Prefer named constants to magic numbers. The `mnd` linter checks for them
outside test files.
- No inline error handling such as `if err := f(); err != nil`. Assign the error
on its own line, then test it.
- A `return` that follows another statement in its block has a blank line before
it.
- A blank line between the embedded fields of a struct and its other fields.
- Every blank import has a comment saying why it is there. Test files need none.
## Architecture ## Architecture
- Dependency injection via Uber fx — all components are fx providers - The server uses `go.uber.org/fx` for dependency injection.
- `internal/` for all application packages — one package per concern `cmd/neoircd/main.go` lists its `fx` providers, such as `db.New` and
- Handlers return `http.HandlerFunc` using the closure pattern `handlers.New`.
- Request/response structs defined inside handler closures - Application packages live under `internal/`, one package per concern.
- Models embed `models.Base` for database access to relation methods - `pkg/irc` holds the IRC command names and numeric reply codes.
- Database migrations in `internal/db/schema/` — embedded via `go:embed` - The `web` package embeds the built web client.
- SQLite by default (pure Go driver: modernc.org/sqlite) - Each exported `Handle…` method of `Handlers` returns an `http.HandlerFunc`.
- A request struct is defined inside the method that decodes it.
- The server uses the database only through the methods of `db.Database`.
- The database migrations live in `internal/db/schema/` and are embedded with
`go:embed`.
- The database is SQLite, through the pure Go driver `modernc.org/sqlite`.
## Testing ## Testing
- Table-driven tests preferred - Prefer table-driven tests.
- Use `testing.T` helpers, not assertion libraries - Use `testing.T` helpers, not assertion libraries.
- Test files live next to the code they test - A test file lives next to the code it tests and ends in `_test.go`.
- `_test.go` suffix, same package for white-box testing - The linter requires each test file to be in the package's external test
package, such as `handlers_test`. It allows `export_test.go` and files ending
in `_internal_test.go` in the package itself.
- An `export_test.go` file gives the external tests access to the package's
unexported code.
## Dependencies ## Dependencies
- Required libraries are listed in CONVENTIONS.md §1 - The required libraries are listed in `CONVENTIONS.md` §1.
- No new dependencies without justification - No new dependency without a reason given in the pull request.
- Pure Go preferred (no CGO) - Prefer dependencies written in pure Go, which build without cgo.
+8 -4
View File
@@ -33,14 +33,18 @@ RUN golangci-lint run --config .golangci.yml ./...
# golang:1.24.13-bookworm, 2026-10-06 # golang:1.24.13-bookworm, 2026-10-06
FROM golang@sha256:1a6d4452c65dea36aac2e2d606b01b4a029ec90cc1ae53890540ce6173ea77ac AS test FROM golang@sha256:1a6d4452c65dea36aac2e2d606b01b4a029ec90cc1ae53890540ce6173ea77ac AS test
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . . COPY . .
RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css web/dist/app.js RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css web/dist/app.js
# -p 4 because test runs on a shared build host cap their parallelism. # -p 4 because test runs on a shared build host cap their parallelism.
RUN go test -p 4 -timeout 90s -race -cover ./... || \ # The modules download in this RUN rather than a layer of their own, because
# exporting a layer that holds them is slow. Nothing reads them or the build
# cache afterwards, so go clean removes both.
RUN set -e; \
go mod download; \
go test -p 4 -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \ { echo "--- Rerunning with -v for details ---"; \
go test -p 4 -timeout 90s -race -v ./...; exit 1; } go test -p 4 -timeout 90s -race -v ./...; exit 1; }; \
go clean -cache -modcache
# Build stage. Nothing is wanted from either phase above; the copies # Build stage. Nothing is wanted from either phase above; the copies
# are what make BuildKit build them first, so this stage cannot run # are what make BuildKit build them first, so this stage cannot run
+1753 -3204
View File
File diff suppressed because it is too large Load Diff
+1 -8
View File
@@ -10,13 +10,6 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
//nolint:gochecknoglobals // var so tests can override via SetBcryptCost
var bcryptCost = bcrypt.DefaultCost
// SetBcryptCost overrides the bcrypt cost.
// Use bcrypt.MinCost in tests to avoid slow hashing.
func SetBcryptCost(cost int) { bcryptCost = cost }
var errNoPassword = errors.New( var errNoPassword = errors.New(
"account has no password set", "account has no password set",
) )
@@ -29,7 +22,7 @@ func (database *Database) SetPassword(
password string, password string,
) error { ) error {
hash, err := bcrypt.GenerateFromPassword( hash, err := bcrypt.GenerateFromPassword(
[]byte(password), bcryptCost, []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
View File
@@ -85,11 +85,6 @@ func New(
return database, nil return database, nil
} }
// GetDB returns the underlying sql.DB connection.
func (database *Database) GetDB() *sql.DB {
return database.conn
}
func (database *Database) connect(ctx context.Context) error { func (database *Database) connect(ctx context.Context) error {
dbURL := database.params.Config.DBURL dbURL := database.params.Config.DBURL
if dbURL == "" { if dbURL == "" {
+76
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"testing" "testing"
"time"
"sneak.berlin/go/neoirc/internal/dbtest" "sneak.berlin/go/neoirc/internal/dbtest"
) )
@@ -67,3 +68,78 @@ func (database *Database) ExecForTest(
return nil return nil
} }
// GetDB returns the underlying sql.DB connection.
func (database *Database) GetDB() *sql.DB {
return database.conn
}
// GetOrCreateChannel returns channel id, creating if needed.
// Uses INSERT OR IGNORE to avoid TOCTOU races.
func (database *Database) GetOrCreateChannel(
ctx context.Context,
name string,
) (int64, error) {
now := time.Now()
_, err := database.conn.ExecContext(ctx,
`INSERT OR IGNORE INTO channels
(name, created_at, updated_at)
VALUES (?, ?, ?)`,
name, now, now)
if err != nil {
return 0, fmt.Errorf("create channel: %w", err)
}
var channelID int64
err = database.conn.QueryRowContext(
ctx,
"SELECT id FROM channels WHERE name = ?",
name,
).Scan(&channelID)
if err != nil {
return 0, fmt.Errorf("get channel: %w", err)
}
return channelID, nil
}
// JoinChannel adds a session to a channel.
func (database *Database) JoinChannel(
ctx context.Context,
channelID, sessionID int64,
) error {
_, err := database.conn.ExecContext(ctx,
`INSERT OR IGNORE INTO channel_members
(channel_id, session_id, joined_at)
VALUES (?, ?, ?)`,
channelID, sessionID, time.Now())
if err != nil {
return fmt.Errorf("join channel: %w", err)
}
return nil
}
// HasChannelInvite checks if a session has been invited
// to a channel.
func (database *Database) HasChannelInvite(
ctx context.Context,
channelID, sessionID int64,
) (bool, error) {
var count int
err := database.conn.QueryRowContext(ctx,
`SELECT COUNT(*) FROM channel_invites
WHERE channel_id = ? AND session_id = ?`,
channelID, sessionID,
).Scan(&count)
if err != nil {
return false, fmt.Errorf(
"check invite: %w", err,
)
}
return count > 0, nil
}
-14
View File
@@ -1,14 +0,0 @@
package db_test
import (
"os"
"testing"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/db"
)
func TestMain(m *testing.M) {
db.SetBcryptCost(bcrypt.MinCost)
os.Exit(m.Run())
}
-110
View File
@@ -320,29 +320,6 @@ type ClientHostInfo struct {
Hostname string Hostname string
} }
// GetClientHostInfo returns the IP and hostname for a
// client.
func (database *Database) GetClientHostInfo(
ctx context.Context,
clientID int64,
) (*ClientHostInfo, error) {
var info ClientHostInfo
err := database.conn.QueryRowContext(
ctx,
`SELECT ip, hostname
FROM clients WHERE id = ?`,
clientID,
).Scan(&info.IP, &info.Hostname)
if err != nil {
return nil, fmt.Errorf(
"get client host info: %w", err,
)
}
return &info, nil
}
// SetSessionOper sets the is_oper flag on a session. // SetSessionOper sets the is_oper flag on a session.
func (database *Database) SetSessionOper( func (database *Database) SetSessionOper(
ctx context.Context, ctx context.Context,
@@ -433,54 +410,6 @@ func (database *Database) GetChannelByName(
return channelID, nil return channelID, nil
} }
// GetOrCreateChannel returns channel id, creating if needed.
// Uses INSERT OR IGNORE to avoid TOCTOU races.
func (database *Database) GetOrCreateChannel(
ctx context.Context,
name string,
) (int64, error) {
now := time.Now()
_, err := database.conn.ExecContext(ctx,
`INSERT OR IGNORE INTO channels
(name, created_at, updated_at)
VALUES (?, ?, ?)`,
name, now, now)
if err != nil {
return 0, fmt.Errorf("create channel: %w", err)
}
var channelID int64
err = database.conn.QueryRowContext(
ctx,
"SELECT id FROM channels WHERE name = ?",
name,
).Scan(&channelID)
if err != nil {
return 0, fmt.Errorf("get channel: %w", err)
}
return channelID, nil
}
// JoinChannel adds a session to a channel.
func (database *Database) JoinChannel(
ctx context.Context,
channelID, sessionID int64,
) error {
_, err := database.conn.ExecContext(ctx,
`INSERT OR IGNORE INTO channel_members
(channel_id, session_id, joined_at)
VALUES (?, ?, ?)`,
channelID, sessionID, time.Now())
if err != nil {
return fmt.Errorf("join channel: %w", err)
}
return nil
}
// Errors that CreateOrJoinChannel refuses a join with. // Errors that CreateOrJoinChannel refuses a join with.
var ( var (
ErrBannedFromChannel = errors.New("banned from channel") ErrBannedFromChannel = errors.New("banned from channel")
@@ -1127,23 +1056,6 @@ func (database *Database) GetChannelMemberIDs(
return scanInt64s(rows) return scanInt64s(rows)
} }
// GetSessionChannelIDs returns channel IDs for a session.
func (database *Database) GetSessionChannelIDs(
ctx context.Context,
sessionID int64,
) ([]int64, error) {
rows, err := database.conn.QueryContext(ctx,
`SELECT channel_id FROM channel_members
WHERE session_id = ?`, sessionID)
if err != nil {
return nil, fmt.Errorf(
"get session channel ids: %w", err,
)
}
return scanInt64s(rows)
}
// InsertMessage stores a message and returns its DB ID. // InsertMessage stores a message and returns its DB ID.
// toSessionID is the session a DM or INVITE is sent to and // toSessionID is the session a DM or INVITE is sent to and
// toChannelID the channel a channel message is sent to; // toChannelID the channel a channel message is sent to;
@@ -2460,28 +2372,6 @@ func (database *Database) AddChannelInvite(
return nil return nil
} }
// HasChannelInvite checks if a session has been invited
// to a channel.
func (database *Database) HasChannelInvite(
ctx context.Context,
channelID, sessionID int64,
) (bool, error) {
var count int
err := database.conn.QueryRowContext(ctx,
`SELECT COUNT(*) FROM channel_invites
WHERE channel_id = ? AND session_id = ?`,
channelID, sessionID,
).Scan(&count)
if err != nil {
return false, fmt.Errorf(
"check invite: %w", err,
)
}
return count > 0, nil
}
// --- Tier 2: Secret (+s) --- // --- Tier 2: Secret (+s) ---
// IsChannelSecret checks if a channel has +s mode. // IsChannelSecret checks if a channel has +s mode.
+3 -16
View File
@@ -125,7 +125,7 @@ func TestCreateSessionStoresIP(t *testing.T) {
database := setupTestDB(t) database := setupTestDB(t)
ctx := t.Context() ctx := t.Context()
sessionID, clientID, _, err := database.CreateSession( sessionID, _, _, err := database.CreateSession(
ctx, "ipuser", "ident", "host.example.com", ctx, "ipuser", "ident", "host.example.com",
"192.168.1.42", "192.168.1.42",
) )
@@ -147,8 +147,8 @@ func TestCreateSessionStoresIP(t *testing.T) {
) )
} }
clientInfo, err := database.GetClientHostInfo( clientInfo, err := database.GetLatestClientForSession(
ctx, clientID, ctx, sessionID,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -169,19 +169,6 @@ func TestCreateSessionStoresIP(t *testing.T) {
} }
} }
func TestGetClientHostInfoNotFound(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
_, err := database.GetClientHostInfo(
t.Context(), 99999,
)
if err == nil {
t.Fatal("expected error for nonexistent client")
}
}
func TestGetSessionHostInfoNotFound(t *testing.T) { func TestGetSessionHostInfoNotFound(t *testing.T) {
t.Parallel() t.Parallel()
-25
View File
@@ -1,25 +0,0 @@
package db
import (
"context"
"database/sql"
"log/slog"
)
// NewTestDatabaseFromConn creates a Database wrapping an
// existing *sql.DB connection. Intended for integration
// tests in other packages.
func NewTestDatabaseFromConn(conn *sql.DB) *Database {
return &Database{ //nolint:exhaustruct
conn: conn,
log: slog.Default(),
}
}
// RunMigrations applies all schema migrations. Exposed
// for integration tests in other packages.
func (database *Database) RunMigrations(
ctx context.Context,
) error {
return database.runMigrations(ctx)
}
+22 -2
View File
@@ -1,5 +1,5 @@
// Package dbtest gives each test a database of its own. Only tests // Package dbtest gives each test a database of its own and runs raw
// may import it. // SQL on it. Only tests may import it.
package dbtest package dbtest
import ( import (
@@ -37,3 +37,23 @@ func NewURL(tb testing.TB) string {
return dbURL return dbURL
} }
// Exec runs one raw SQL statement on the database at dbURL, over a
// connection of its own. A test uses it for what the methods of
// db.Database cannot do, such as creating a trigger that makes a
// write fail.
func Exec(tb testing.TB, dbURL, statement string) {
tb.Helper()
pool, err := sql.Open("sqlite", dbURL)
if err != nil {
tb.Fatalf("open test database: %v", err)
}
defer func() { _ = pool.Close() }()
_, err = pool.ExecContext(tb.Context(), statement)
if err != nil {
tb.Fatalf("run %q: %v", statement, err)
}
}
+3 -3
View File
@@ -188,7 +188,7 @@ func (hdlr *Handlers) handleCreateSession(
var payload createRequest var payload createRequest
err := json.NewDecoder(request.Body).Decode(&payload) err := decodeJSON(request, &payload)
if err != nil { if err != nil {
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
@@ -925,7 +925,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
var payload commandRequest var payload commandRequest
err := json.NewDecoder(request.Body).Decode(&payload) err := decodeJSON(request, &payload)
if err != nil { if err != nil {
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
@@ -3238,7 +3238,7 @@ func (hdlr *Handlers) handleWho(
// 315 RPL_ENDOFWHO (empty result) // 315 RPL_ENDOFWHO (empty result)
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfWho, nick, ctx, clientID, irc.RplEndOfWho, nick,
[]string{target}, []string{channel},
"End of /WHO list", "End of /WHO list",
) )
hdlr.broker.Notify(sessionID) hdlr.broker.Notify(sessionID)
+164 -40
View File
@@ -47,22 +47,25 @@ import (
const templateDBURL = "file:template?mode=memory&cache=shared" const templateDBURL = "file:template?mode=memory&cache=shared"
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
db.SetBcryptCost(bcrypt.MinCost) template := fx.New(
fx.NopLogger,
fx.Supply(
&globals.Globals{},
&config.Config{DBURL: templateDBURL},
),
fx.Provide(logger.New, newTestDB),
// fx makes the database only if something asks for it.
fx.Invoke(func(*db.Database) {}),
)
template, err := sql.Open("sqlite", templateDBURL) err := template.Start(context.Background())
if err != nil {
panic(err)
}
err = db.NewTestDatabaseFromConn(template).
RunMigrations(context.Background())
if err != nil { if err != nil {
panic(err) panic(err)
} }
code := m.Run() code := m.Run()
_ = template.Close() _ = template.Stop(context.Background())
os.Exit(code) os.Exit(code)
} }
@@ -137,6 +140,7 @@ type testServer struct {
t *testing.T t *testing.T
fxApp *fxtest.App fxApp *fxtest.App
broker *broker.Broker broker *broker.Broker
dbURL string
database *db.Database database *db.Database
handlers *handlers.Handlers handlers *handlers.Handlers
} }
@@ -231,6 +235,7 @@ func newTestServerWith(
t: t, t: t,
fxApp: app, fxApp: app,
broker: brk, broker: brk,
dbURL: dbURL,
database: database, database: database,
handlers: hdlr, handlers: hdlr,
} }
@@ -797,6 +802,104 @@ func TestCreateSessionMalformed(t *testing.T) {
} }
} }
func (tserver *testServer) postBody(
path, token, body string,
) (int, map[string]any) {
tserver.t.Helper()
resp, err := doRequestAuth(
tserver.t, http.MethodPost, tserver.url(path), token,
strings.NewReader(body),
)
if err != nil {
tserver.t.Fatalf("post %s: %v", path, err)
}
defer func() { _ = resp.Body.Close() }()
var result map[string]any
_ = json.NewDecoder(resp.Body).Decode(&result)
return resp.StatusCode, result
}
// TestRequestBodyLimit sends each endpoint a small valid JSON value
// followed by other bytes. Only whitespace may follow the value, and
// the whole body counts towards MAX_MESSAGE_SIZE.
func TestRequestBodyLimit(t *testing.T) {
t.Parallel()
const (
maxMessageSize = 4096 // the default MAX_MESSAGE_SIZE
nick = "alice_bodylimit"
)
endpoints := []struct {
name, path, value string
acceptedStatus int
}{
{
"create session", apiSession,
`{"nick":"bob_bodylimit"}`,
http.StatusCreated,
},
{
"login", "/api/v1/login",
`{"nick":"` + nick + `","password":"` + testPassword + `"}`,
http.StatusOK,
},
{
"post message", apiMessages,
`{"command":"PING"}`,
http.StatusOK,
},
}
for _, endpoint := range endpoints {
t.Run(endpoint.name, func(t *testing.T) {
t.Parallel()
tserver := newTestServer(t)
token := tserver.createSession(nick)
tserver.setPassword(nick)
bytesToLimit := maxMessageSize - len(endpoint.value)
for _, trailing := range []string{
strings.Repeat(" ", bytesToLimit+1),
strings.Repeat("x", bytesToLimit+1),
"x",
"{}",
} {
status, result := tserver.postBody(
endpoint.path, token, endpoint.value+trailing,
)
if status != http.StatusBadRequest ||
result["error"] != "invalid request body" {
t.Errorf(
"%d bytes of %.10q after the value: expected "+
"400 invalid request body, got %d: %v",
len(trailing), trailing, status, result,
)
}
}
status, result := tserver.postBody(
endpoint.path, token,
endpoint.value+strings.Repeat(" ", bytesToLimit),
)
if status != endpoint.acceptedStatus {
t.Errorf(
"body of exactly the limit: expected %d, got %d: %v",
endpoint.acceptedStatus, status, result,
)
}
})
}
}
func TestAuthNoCookie(t *testing.T) { func TestAuthNoCookie(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1662,9 +1765,7 @@ func checkQuitRace(
firstToken := tserver.createSession(nick) firstToken := tserver.createSession(nick)
observerToken := tserver.createSession("raceobserver") observerToken := tserver.createSession("raceobserver")
tserver.sendCommand(firstToken, map[string]any{ tserver.setPassword(nick)
commandKey: irc.CmdPass, bodyKey: []string{testPassword},
})
tokens := []string{firstToken, tserver.login(nick)} tokens := []string{firstToken, tserver.login(nick)}
@@ -1714,9 +1815,7 @@ func TestLogoutOfOneClientKeepsSession(t *testing.T) {
firstToken := tserver.createSession(nick) firstToken := tserver.createSession(nick)
observerToken := tserver.createSession("logoutobserver") observerToken := tserver.createSession("logoutobserver")
tserver.sendCommand(firstToken, map[string]any{ tserver.setPassword(nick)
commandKey: irc.CmdPass, bodyKey: []string{testPassword},
})
secondToken := tserver.login(nick) secondToken := tserver.login(nick)
@@ -1786,15 +1885,12 @@ func TestFailedLogoutChangesNothing(t *testing.T) {
_, lastID := tserver.pollMessages(observerToken, 0) _, lastID := tserver.pollMessages(observerToken, 0)
_, err := tserver.database.GetDB().ExecContext(t.Context(), dbtest.Exec(t, tserver.dbURL,
`CREATE TRIGGER keep_sessions `CREATE TRIGGER keep_sessions
BEFORE DELETE ON sessions BEFORE DELETE ON sessions
BEGIN BEGIN
SELECT RAISE(ABORT, 'sessions are kept'); SELECT RAISE(ABORT, 'sessions are kept');
END`) END`)
if err != nil {
t.Fatal(err)
}
resp, err := doRequestAuth( resp, err := doRequestAuth(
t, http.MethodPost, tserver.url(apiLogout), token, nil, t, http.MethodPost, tserver.url(apiLogout), token, nil,
@@ -2028,6 +2124,24 @@ func (tserver *testServer) longPoll(
return result.Messages, nil return result.Messages, nil
} }
// setPassword gives the session nick the password testPassword
// without sending PASS. It hashes at bcrypt.MinCost, and a login
// compares at the cost stored in the hash, so both are fast.
func (tserver *testServer) setPassword(nick string) {
tserver.t.Helper()
hash, err := bcrypt.GenerateFromPassword(
[]byte(testPassword), bcrypt.MinCost,
)
if err != nil {
tserver.t.Fatalf("hash password: %v", err)
}
dbtest.Exec(tserver.t, tserver.dbURL,
"UPDATE sessions SET password_hash = '"+string(hash)+
"' WHERE nick = '"+nick+"'")
}
// login logs in to the session nick with testPassword and // login logs in to the session nick with testPassword and
// returns the new client's token. // returns the new client's token.
func (tserver *testServer) login(nick string) string { func (tserver *testServer) login(nick string) string {
@@ -3635,13 +3749,8 @@ func TestLoginValid(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
// Create session and set password via PASS command. tserver.createSession("loginuser")
token := tserver.createSession("loginuser") tserver.setPassword("loginuser")
tserver.sendCommand(token, map[string]any{
commandKey: irc.CmdPass,
bodyKey: []string{testPassword},
})
// Login with nick + password. // Login with nick + password.
loginBody, err := json.Marshal(map[string]string{ loginBody, err := json.Marshal(map[string]string{
@@ -3705,13 +3814,8 @@ func TestLoginWrongPassword(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
// Create session and set password via PASS command. tserver.createSession("wrongpwuser")
token := tserver.createSession("wrongpwuser") tserver.setPassword("wrongpwuser")
tserver.sendCommand(token, map[string]any{
commandKey: irc.CmdPass,
bodyKey: []string{"correctpass1"},
})
postJSONExpectStatus( postJSONExpectStatus(
t, tserver, "/api/v1/login", t, tserver, "/api/v1/login",
@@ -4039,6 +4143,31 @@ func assertWhoReplyUsername(
) )
} }
func TestWhoUnknownChannelEndReplyAddsHash(t *testing.T) {
t.Parallel()
tserver := newTestServer(t)
token := tserver.createSession("whoasker")
_, lastID := tserver.pollMessages(token, 0)
msgs, _ := sendAndPoll(tserver, token, lastID, map[string]any{
commandKey: irc.CmdWho,
toKey: "nosuch",
})
endOfWho := findNumericWithParams(msgs, "315")
if endOfWho == nil {
t.Fatalf("expected RPL_ENDOFWHO (315), got %v", msgs)
}
params := getNumericParams(endOfWho)
if !slices.Equal(params, []string{"#nosuch"}) {
t.Fatalf("expected params [#nosuch], got %v", params)
}
}
func TestSessionUsernameDefault(t *testing.T) { func TestSessionUsernameDefault(t *testing.T) {
t.Parallel() t.Parallel()
@@ -4157,13 +4286,8 @@ func TestLoginRateLimitAllowsNormalUse(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
// Create session and set password via PASS command. tserver.createSession("normaluser")
token := tserver.createSession("normaluser") tserver.setPassword("normaluser")
tserver.sendCommand(token, map[string]any{
commandKey: irc.CmdPass,
bodyKey: []string{testPassword},
})
// A single login should succeed without rate limiting. // A single login should succeed without rate limiting.
loginBody, err := json.Marshal(map[string]string{ loginBody, err := json.Marshal(map[string]string{
+1 -2
View File
@@ -1,7 +1,6 @@
package handlers package handlers
import ( import (
"encoding/json"
"net/http" "net/http"
"strings" "strings"
@@ -55,7 +54,7 @@ func (hdlr *Handlers) handleLogin(
var payload loginRequest var payload loginRequest
err := json.NewDecoder(request.Body).Decode(&payload) err := decodeJSON(request, &payload)
if err != nil { if err != nil {
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
+13
View File
@@ -5,6 +5,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"time" "time"
@@ -159,6 +160,18 @@ func (hdlr *Handlers) respondError(
) )
} }
// decodeJSON reads the whole request body and decodes it into dest.
// It fails if the body is larger than the http.MaxBytesReader limit,
// or holds anything other than one JSON value and whitespace.
func decodeJSON(request *http.Request, dest any) error {
body, err := io.ReadAll(request.Body)
if err != nil {
return err
}
return json.Unmarshal(body, dest)
}
func (hdlr *Handlers) idleTimeout() time.Duration { func (hdlr *Handlers) idleTimeout() time.Duration {
raw := hdlr.params.Config.SessionIdleTimeout raw := hdlr.params.Config.SessionIdleTimeout
if raw == "" { if raw == "" {
+17 -4
View File
@@ -8,6 +8,8 @@ import (
"testing" "testing"
"time" "time"
"golang.org/x/crypto/bcrypt"
"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"
) )
@@ -583,8 +585,10 @@ func ownSessionCopies(
// aliceWithHTTPClient connects alice to the IRC listener and // aliceWithHTTPClient connects alice to the IRC listener and
// has her join #chan, which bob is on too, then adds an HTTP API // has her join #chan, which bob is on too, then adds an HTTP API
// client to her session with the password her connection set, // client to her session with a password, as POST /api/v1/login
// as POST /api/v1/login does. It returns her connection, bob's // does. It stores that password without sending PASS. It hashes
// at bcrypt.MinCost, and a login compares at the cost stored in
// the hash, so both are fast. It returns her connection, bob's
// connection, her session and her HTTP API client. // connection, her session and her HTTP API client.
func aliceWithHTTPClient( func aliceWithHTTPClient(
t *testing.T, env *testEnv, t *testing.T, env *testEnv,
@@ -594,15 +598,24 @@ func aliceWithHTTPClient(
const password = "alicepass" const password = "alicepass"
alice := env.dial(t) alice := env.dial(t)
alice.send("PASS " + password)
alice.register("alice") alice.register("alice")
// Registration stores the password before the JOIN runs.
alice.joinAndDrain("#chan") alice.joinAndDrain("#chan")
bob := env.dial(t) bob := env.dial(t)
bob.register("bob") bob.register("bob")
bob.joinAndDrain("#chan") bob.joinAndDrain("#chan")
hash, err := bcrypt.GenerateFromPassword(
[]byte(password), bcrypt.MinCost,
)
if err != nil {
t.Fatalf("hash password: %v", err)
}
dbtest.Exec(t, env.dbURL,
"UPDATE sessions SET password_hash = '"+string(hash)+
"' WHERE nick = 'alice'")
sessionID, httpClientID, _, err := env.database.LoginUser( sessionID, httpClientID, _, err := env.database.LoginUser(
t.Context(), "alice", password, "127.0.0.1", "localhost", t.Context(), "alice", password, "127.0.0.1", "localhost",
) )
+33 -49
View File
@@ -32,14 +32,9 @@ import (
const testTimeout = 5 * time.Second const testTimeout = 5 * time.Second
func TestMain(m *testing.M) {
db.SetBcryptCost(4)
os.Exit(m.Run())
}
// testEnv holds the shared test infrastructure. // testEnv holds the shared test infrastructure.
type testEnv struct { type testEnv struct {
dbURL string
database *db.Database database *db.Database
brk *broker.Broker brk *broker.Broker
cfg *config.Config cfg *config.Config
@@ -110,34 +105,27 @@ func newTestEnvWithResolver(
) *testEnv { ) *testEnv {
t.Helper() t.Helper()
conn, err := sql.Open("sqlite", dbtest.NewURL(t)) dbURL := dbtest.NewURL(t)
if err != nil { lifecycle := fxtest.NewLifecycle(t)
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { dbLog, err := logger.New(lifecycle, logger.Params{
err := conn.Close() Globals: &globals.Globals{},
if err != nil {
t.Logf("close db: %v", err)
}
}) })
conn.SetMaxOpenConns(1)
_, err = conn.ExecContext(
t.Context(), "PRAGMA foreign_keys = ON",
)
if err != nil { if err != nil {
t.Fatalf("pragma: %v", err) t.Fatalf("new logger: %v", err)
} }
database := db.NewTestDatabaseFromConn(conn) database, err := db.New(lifecycle, db.Params{
Logger: dbLog,
err = database.RunMigrations(t.Context()) Config: &config.Config{DBURL: dbURL},
})
if err != nil { if err != nil {
t.Fatalf("migrate: %v", err) t.Fatalf("new database: %v", err)
} }
lifecycle.RequireStart()
t.Cleanup(lifecycle.RequireStop)
brk := broker.New() brk := broker.New()
log := slog.New(slog.NewTextHandler( log := slog.New(slog.NewTextHandler(
@@ -161,6 +149,7 @@ func newTestEnvWithResolver(
t.Cleanup(srv.Stop) t.Cleanup(srv.Stop)
return &testEnv{ return &testEnv{
dbURL: dbURL,
database: database, database: database,
brk: brk, brk: brk,
cfg: cfg, cfg: cfg,
@@ -929,12 +918,7 @@ func quitSeenByPeers(t *testing.T, quit string) (string, []string) {
t.Fatalf("create peer session: %v", err) t.Fatalf("create peer session: %v", err)
} }
channelID, err := env.database.GetChannelByName(ctx, "#bye") _, _, err = env.database.CreateOrJoinChannel(ctx, "#bye", apiPeerID, "")
if err != nil {
t.Fatalf("get channel: %v", err)
}
err = env.database.JoinChannel(ctx, channelID, apiPeerID)
if err != nil { if err != nil {
t.Fatalf("join peer: %v", err) t.Fatalf("join peer: %v", err)
} }
@@ -1005,12 +989,9 @@ func TestStopEndsSessions(t *testing.T) {
t.Fatalf("create peer session: %v", err) t.Fatalf("create peer session: %v", err)
} }
channelID, err := env.database.GetChannelByName(ctx, "#shut") channelID, _, err := env.database.CreateOrJoinChannel(
if err != nil { ctx, "#shut", peerID, "",
t.Fatalf("get channel: %v", err) )
}
err = env.database.JoinChannel(ctx, channelID, peerID)
if err != nil { if err != nil {
t.Fatalf("join peer: %v", err) t.Fatalf("join peer: %v", err)
} }
@@ -1068,11 +1049,16 @@ func TestStopReturnsWhileCleanupIsStuck(t *testing.T) {
client.register("stuck") client.register("stuck")
// The test database has one connection. While the test // While a connection of the test's own holds a write
// holds it, the cleanup's first query waits for it. // transaction, the cleanup's first write waits for it to end.
held, err := env.database.GetDB().Conn(t.Context()) holder, err := sql.Open("sqlite", env.dbURL)
if err != nil { if err != nil {
t.Fatalf("take the database connection: %v", err) t.Fatalf("open a second database connection: %v", err)
}
_, err = holder.ExecContext(t.Context(), "BEGIN IMMEDIATE")
if err != nil {
t.Fatalf("begin a write transaction: %v", err)
} }
stopped := make(chan struct{}) stopped := make(chan struct{})
@@ -1088,10 +1074,11 @@ func TestStopReturnsWhileCleanupIsStuck(t *testing.T) {
t.Error("Stop did not return while a cleanup was stuck") t.Error("Stop did not return while a cleanup was stuck")
} }
// Lets the cleanup finish before the database is closed. // Closing ends the transaction, which lets the cleanup finish
err = held.Close() // before the database is closed.
err = holder.Close()
if err != nil { if err != nil {
t.Errorf("release the database connection: %v", err) t.Errorf("close the second database connection: %v", err)
} }
} }
@@ -1370,14 +1357,11 @@ func TestPassPostRegistrationNotStored(t *testing.T) {
client.register("failpass") client.register("failpass")
_, err := env.database.GetDB().ExecContext(t.Context(), dbtest.Exec(t, env.dbURL,
`CREATE TRIGGER refuse_password `CREATE TRIGGER refuse_password
BEFORE UPDATE OF password_hash ON sessions BEFORE UPDATE OF password_hash ON sessions
BEGIN SELECT RAISE(ABORT, 'refused'); END`, BEGIN SELECT RAISE(ABORT, 'refused'); END`,
) )
if err != nil {
t.Fatalf("create trigger: %v", err)
}
client.send("PASS :mypassword123") client.send("PASS :mypassword123")
+5 -16
View File
@@ -9,7 +9,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"os"
"slices" "slices"
"strings" "strings"
"sync" "sync"
@@ -18,7 +17,6 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"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"
@@ -30,14 +28,10 @@ import (
"sneak.berlin/go/neoirc/pkg/irc" "sneak.berlin/go/neoirc/pkg/irc"
) )
func TestMain(m *testing.M) {
db.SetBcryptCost(bcrypt.MinCost)
os.Exit(m.Run())
}
// testEnv holds all dependencies for a service test. // testEnv holds all dependencies for a service test.
type testEnv struct { type testEnv struct {
svc *service.Service svc *service.Service
dbURL string
db *db.Database db *db.Database
broker *broker.Broker broker *broker.Broker
app *fxtest.App app *fxtest.App
@@ -111,6 +105,7 @@ func newTestEnv(t *testing.T) *testEnv {
return &testEnv{ return &testEnv{
svc: svc, svc: svc,
dbURL: dbURL,
db: database, db: database,
broker: brk, broker: brk,
app: app, app: app,
@@ -940,19 +935,16 @@ func TestSendDirectMessageTargetQuits(t *testing.T) {
sid := createSession(ctx, t, env.db, "alice") sid := createSession(ctx, t, env.db, "alice")
createSession(ctx, t, env.db, "bob") createSession(ctx, t, env.db, "bob")
_, err := env.db.GetDB().ExecContext(ctx, dbtest.Exec(t, env.dbURL,
`CREATE TRIGGER delete_recipient `CREATE TRIGGER delete_recipient
BEFORE INSERT ON messages BEFORE INSERT ON messages
BEGIN BEGIN
DELETE FROM sessions WHERE id = NEW.to_session_id; DELETE FROM sessions WHERE id = NEW.to_session_id;
END`) END`)
if err != nil {
t.Fatalf("create trigger: %v", err)
}
body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson
_, err = env.svc.SendDirectMessage( _, err := env.svc.SendDirectMessage(
ctx, sid, 0, "alice", irc.CmdPrivmsg, "bob", body, nil, ctx, sid, 0, "alice", irc.CmdPrivmsg, "bob", body, nil,
) )
@@ -978,15 +970,12 @@ func TestSendChannelMessageChannelDeleted(t *testing.T) {
t.Fatalf("join alice: %v", err) t.Fatalf("join alice: %v", err)
} }
_, err = env.db.GetDB().ExecContext(ctx, dbtest.Exec(t, env.dbURL,
`CREATE TRIGGER delete_recipient `CREATE TRIGGER delete_recipient
BEFORE INSERT ON messages BEFORE INSERT ON messages
BEGIN BEGIN
DELETE FROM channels WHERE id = NEW.to_channel_id; DELETE FROM channels WHERE id = NEW.to_channel_id;
END`) END`)
if err != nil {
t.Fatalf("create trigger: %v", err)
}
body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson