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`
2. **Check**: `make check` — the tests and the linter, which run in Docker, and
the format check: all passing, zero issues
## Before every commit
Never run `go test`, `golangci-lint` or `gofmt` directly; use the `make`
targets.
1. Run `make fmt`.
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
- All exported types, functions, methods, and vars must have doc comments
- All packages must have a package doc comment
- JSON tags use camelCase (not snake_case)
- No magic numbers — use named constants
- No inline error handling (`if err := ...; err != nil`) — use plain assignment
- Blank line before every `return` statement
- Blank line separating embedded fields from regular fields in structs
- Blank imports must have a justification comment
- Never commit to `main` or `next` directly. Every change is a pull request from
a feature branch into `next`.
- Only the owner merges `next` into `main`.
- A pull request that closes an issue has the title `Description (closes #N)`.
- One logical change per commit.
- Commit messages are in the imperative mood and short.
## Git Workflow
## Code style
- **Never commit directly to main** — all changes go on feature branches
- Merge to main only when ready and passing all checks
- PR titles: `Description (closes #N)` when closing an issue
- One logical change per commit
- Commit messages: imperative mood, concise
- Follow `CONVENTIONS.md`.
- Every package has a package doc comment. Test files need none.
- Every exported type, function and variable has a doc comment. Test files need
none.
- 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
- Dependency injection via Uber fx — all components are fx providers
- `internal/` for all application packages — one package per concern
- Handlers return `http.HandlerFunc` using the closure pattern
- Request/response structs defined inside handler closures
- Models embed `models.Base` for database access to relation methods
- Database migrations in `internal/db/schema/` — embedded via `go:embed`
- SQLite by default (pure Go driver: modernc.org/sqlite)
- The server uses `go.uber.org/fx` for dependency injection.
`cmd/neoircd/main.go` lists its `fx` providers, such as `db.New` and
`handlers.New`.
- Application packages live under `internal/`, one package per concern.
- `pkg/irc` holds the IRC command names and numeric reply codes.
- The `web` package embeds the built web client.
- 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
- Table-driven tests preferred
- Use `testing.T` helpers, not assertion libraries
- Test files live next to the code they test
- `_test.go` suffix, same package for white-box testing
- Prefer table-driven tests.
- Use `testing.T` helpers, not assertion libraries.
- A test file lives next to the code it tests and ends in `_test.go`.
- 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
- Required libraries are listed in CONVENTIONS.md §1
- No new dependencies without justification
- Pure Go preferred (no CGO)
- The required libraries are listed in `CONVENTIONS.md` §1.
- No new dependency without a reason given in the pull request.
- 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
FROM golang@sha256:1a6d4452c65dea36aac2e2d606b01b4a029ec90cc1ae53890540ce6173ea77ac AS test
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
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.
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 ---"; \
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
# 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"
)
//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(
"account has no password set",
)
@@ -29,7 +22,7 @@ func (database *Database) SetPassword(
password string,
) error {
hash, err := bcrypt.GenerateFromPassword(
[]byte(password), bcryptCost,
[]byte(password), bcrypt.DefaultCost,
)
if err != nil {
return fmt.Errorf("hash password: %w", err)
-5
View File
@@ -85,11 +85,6 @@ func New(
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 {
dbURL := database.params.Config.DBURL
if dbURL == "" {
+76
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"log/slog"
"testing"
"time"
"sneak.berlin/go/neoirc/internal/dbtest"
)
@@ -67,3 +68,78 @@ func (database *Database) ExecForTest(
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
}
// 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.
func (database *Database) SetSessionOper(
ctx context.Context,
@@ -433,54 +410,6 @@ func (database *Database) GetChannelByName(
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.
var (
ErrBannedFromChannel = errors.New("banned from channel")
@@ -1127,23 +1056,6 @@ func (database *Database) GetChannelMemberIDs(
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.
// toSessionID is the session a DM or INVITE is sent to and
// toChannelID the channel a channel message is sent to;
@@ -2460,28 +2372,6 @@ func (database *Database) AddChannelInvite(
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) ---
// IsChannelSecret checks if a channel has +s mode.
+3 -16
View File
@@ -125,7 +125,7 @@ func TestCreateSessionStoresIP(t *testing.T) {
database := setupTestDB(t)
ctx := t.Context()
sessionID, clientID, _, err := database.CreateSession(
sessionID, _, _, err := database.CreateSession(
ctx, "ipuser", "ident", "host.example.com",
"192.168.1.42",
)
@@ -147,8 +147,8 @@ func TestCreateSessionStoresIP(t *testing.T) {
)
}
clientInfo, err := database.GetClientHostInfo(
ctx, clientID,
clientInfo, err := database.GetLatestClientForSession(
ctx, sessionID,
)
if err != nil {
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) {
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
// may import it.
// Package dbtest gives each test a database of its own and runs raw
// SQL on it. Only tests may import it.
package dbtest
import (
@@ -37,3 +37,23 @@ func NewURL(tb testing.TB) string {
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
err := json.NewDecoder(request.Body).Decode(&payload)
err := decodeJSON(request, &payload)
if err != nil {
hdlr.respondError(
writer, request,
@@ -925,7 +925,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
var payload commandRequest
err := json.NewDecoder(request.Body).Decode(&payload)
err := decodeJSON(request, &payload)
if err != nil {
hdlr.respondError(
writer, request,
@@ -3238,7 +3238,7 @@ func (hdlr *Handlers) handleWho(
// 315 RPL_ENDOFWHO (empty result)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfWho, nick,
[]string{target},
[]string{channel},
"End of /WHO list",
)
hdlr.broker.Notify(sessionID)
+164 -40
View File
@@ -47,22 +47,25 @@ import (
const templateDBURL = "file:template?mode=memory&cache=shared"
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)
if err != nil {
panic(err)
}
err = db.NewTestDatabaseFromConn(template).
RunMigrations(context.Background())
err := template.Start(context.Background())
if err != nil {
panic(err)
}
code := m.Run()
_ = template.Close()
_ = template.Stop(context.Background())
os.Exit(code)
}
@@ -137,6 +140,7 @@ type testServer struct {
t *testing.T
fxApp *fxtest.App
broker *broker.Broker
dbURL string
database *db.Database
handlers *handlers.Handlers
}
@@ -231,6 +235,7 @@ func newTestServerWith(
t: t,
fxApp: app,
broker: brk,
dbURL: dbURL,
database: database,
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) {
t.Parallel()
@@ -1662,9 +1765,7 @@ func checkQuitRace(
firstToken := tserver.createSession(nick)
observerToken := tserver.createSession("raceobserver")
tserver.sendCommand(firstToken, map[string]any{
commandKey: irc.CmdPass, bodyKey: []string{testPassword},
})
tserver.setPassword(nick)
tokens := []string{firstToken, tserver.login(nick)}
@@ -1714,9 +1815,7 @@ func TestLogoutOfOneClientKeepsSession(t *testing.T) {
firstToken := tserver.createSession(nick)
observerToken := tserver.createSession("logoutobserver")
tserver.sendCommand(firstToken, map[string]any{
commandKey: irc.CmdPass, bodyKey: []string{testPassword},
})
tserver.setPassword(nick)
secondToken := tserver.login(nick)
@@ -1786,15 +1885,12 @@ func TestFailedLogoutChangesNothing(t *testing.T) {
_, lastID := tserver.pollMessages(observerToken, 0)
_, err := tserver.database.GetDB().ExecContext(t.Context(),
dbtest.Exec(t, tserver.dbURL,
`CREATE TRIGGER keep_sessions
BEFORE DELETE ON sessions
BEGIN
SELECT RAISE(ABORT, 'sessions are kept');
END`)
if err != nil {
t.Fatal(err)
}
resp, err := doRequestAuth(
t, http.MethodPost, tserver.url(apiLogout), token, nil,
@@ -2028,6 +2124,24 @@ func (tserver *testServer) longPoll(
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
// returns the new client's token.
func (tserver *testServer) login(nick string) string {
@@ -3635,13 +3749,8 @@ func TestLoginValid(t *testing.T) {
tserver := newTestServer(t)
// Create session and set password via PASS command.
token := tserver.createSession("loginuser")
tserver.sendCommand(token, map[string]any{
commandKey: irc.CmdPass,
bodyKey: []string{testPassword},
})
tserver.createSession("loginuser")
tserver.setPassword("loginuser")
// Login with nick + password.
loginBody, err := json.Marshal(map[string]string{
@@ -3705,13 +3814,8 @@ func TestLoginWrongPassword(t *testing.T) {
tserver := newTestServer(t)
// Create session and set password via PASS command.
token := tserver.createSession("wrongpwuser")
tserver.sendCommand(token, map[string]any{
commandKey: irc.CmdPass,
bodyKey: []string{"correctpass1"},
})
tserver.createSession("wrongpwuser")
tserver.setPassword("wrongpwuser")
postJSONExpectStatus(
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) {
t.Parallel()
@@ -4157,13 +4286,8 @@ func TestLoginRateLimitAllowsNormalUse(t *testing.T) {
tserver := newTestServer(t)
// Create session and set password via PASS command.
token := tserver.createSession("normaluser")
tserver.sendCommand(token, map[string]any{
commandKey: irc.CmdPass,
bodyKey: []string{testPassword},
})
tserver.createSession("normaluser")
tserver.setPassword("normaluser")
// A single login should succeed without rate limiting.
loginBody, err := json.Marshal(map[string]string{
+1 -2
View File
@@ -1,7 +1,6 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
@@ -55,7 +54,7 @@ func (hdlr *Handlers) handleLogin(
var payload loginRequest
err := json.NewDecoder(request.Body).Decode(&payload)
err := decodeJSON(request, &payload)
if err != nil {
hdlr.respondError(
writer, request,
+13
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"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 {
raw := hdlr.params.Config.SessionIdleTimeout
if raw == "" {
+17 -4
View File
@@ -8,6 +8,8 @@ import (
"testing"
"time"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/dbtest"
"sneak.berlin/go/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/service"
)
@@ -583,8 +585,10 @@ func ownSessionCopies(
// aliceWithHTTPClient connects alice to the IRC listener and
// has her join #chan, which bob is on too, then adds an HTTP API
// client to her session with the password her connection set,
// as POST /api/v1/login does. It returns her connection, bob's
// client to her session with a password, as POST /api/v1/login
// 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.
func aliceWithHTTPClient(
t *testing.T, env *testEnv,
@@ -594,15 +598,24 @@ func aliceWithHTTPClient(
const password = "alicepass"
alice := env.dial(t)
alice.send("PASS " + password)
alice.register("alice")
// Registration stores the password before the JOIN runs.
alice.joinAndDrain("#chan")
bob := env.dial(t)
bob.register("bob")
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(
t.Context(), "alice", password, "127.0.0.1", "localhost",
)
+33 -49
View File
@@ -32,14 +32,9 @@ import (
const testTimeout = 5 * time.Second
func TestMain(m *testing.M) {
db.SetBcryptCost(4)
os.Exit(m.Run())
}
// testEnv holds the shared test infrastructure.
type testEnv struct {
dbURL string
database *db.Database
brk *broker.Broker
cfg *config.Config
@@ -110,34 +105,27 @@ func newTestEnvWithResolver(
) *testEnv {
t.Helper()
conn, err := sql.Open("sqlite", dbtest.NewURL(t))
if err != nil {
t.Fatalf("open db: %v", err)
}
dbURL := dbtest.NewURL(t)
lifecycle := fxtest.NewLifecycle(t)
t.Cleanup(func() {
err := conn.Close()
if err != nil {
t.Logf("close db: %v", err)
}
dbLog, err := logger.New(lifecycle, logger.Params{
Globals: &globals.Globals{},
})
conn.SetMaxOpenConns(1)
_, err = conn.ExecContext(
t.Context(), "PRAGMA foreign_keys = ON",
)
if err != nil {
t.Fatalf("pragma: %v", err)
t.Fatalf("new logger: %v", err)
}
database := db.NewTestDatabaseFromConn(conn)
err = database.RunMigrations(t.Context())
database, err := db.New(lifecycle, db.Params{
Logger: dbLog,
Config: &config.Config{DBURL: dbURL},
})
if err != nil {
t.Fatalf("migrate: %v", err)
t.Fatalf("new database: %v", err)
}
lifecycle.RequireStart()
t.Cleanup(lifecycle.RequireStop)
brk := broker.New()
log := slog.New(slog.NewTextHandler(
@@ -161,6 +149,7 @@ func newTestEnvWithResolver(
t.Cleanup(srv.Stop)
return &testEnv{
dbURL: dbURL,
database: database,
brk: brk,
cfg: cfg,
@@ -929,12 +918,7 @@ func quitSeenByPeers(t *testing.T, quit string) (string, []string) {
t.Fatalf("create peer session: %v", err)
}
channelID, err := env.database.GetChannelByName(ctx, "#bye")
if err != nil {
t.Fatalf("get channel: %v", err)
}
err = env.database.JoinChannel(ctx, channelID, apiPeerID)
_, _, err = env.database.CreateOrJoinChannel(ctx, "#bye", apiPeerID, "")
if err != nil {
t.Fatalf("join peer: %v", err)
}
@@ -1005,12 +989,9 @@ func TestStopEndsSessions(t *testing.T) {
t.Fatalf("create peer session: %v", err)
}
channelID, err := env.database.GetChannelByName(ctx, "#shut")
if err != nil {
t.Fatalf("get channel: %v", err)
}
err = env.database.JoinChannel(ctx, channelID, peerID)
channelID, _, err := env.database.CreateOrJoinChannel(
ctx, "#shut", peerID, "",
)
if err != nil {
t.Fatalf("join peer: %v", err)
}
@@ -1068,11 +1049,16 @@ func TestStopReturnsWhileCleanupIsStuck(t *testing.T) {
client.register("stuck")
// The test database has one connection. While the test
// holds it, the cleanup's first query waits for it.
held, err := env.database.GetDB().Conn(t.Context())
// While a connection of the test's own holds a write
// transaction, the cleanup's first write waits for it to end.
holder, err := sql.Open("sqlite", env.dbURL)
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{})
@@ -1088,10 +1074,11 @@ func TestStopReturnsWhileCleanupIsStuck(t *testing.T) {
t.Error("Stop did not return while a cleanup was stuck")
}
// Lets the cleanup finish before the database is closed.
err = held.Close()
// Closing ends the transaction, which lets the cleanup finish
// before the database is closed.
err = holder.Close()
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")
_, err := env.database.GetDB().ExecContext(t.Context(),
dbtest.Exec(t, env.dbURL,
`CREATE TRIGGER refuse_password
BEFORE UPDATE OF password_hash ON sessions
BEGIN SELECT RAISE(ABORT, 'refused'); END`,
)
if err != nil {
t.Fatalf("create trigger: %v", err)
}
client.send("PASS :mypassword123")
+5 -16
View File
@@ -9,7 +9,6 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"slices"
"strings"
"sync"
@@ -18,7 +17,6 @@ import (
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
@@ -30,14 +28,10 @@ import (
"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.
type testEnv struct {
svc *service.Service
dbURL string
db *db.Database
broker *broker.Broker
app *fxtest.App
@@ -111,6 +105,7 @@ func newTestEnv(t *testing.T) *testEnv {
return &testEnv{
svc: svc,
dbURL: dbURL,
db: database,
broker: brk,
app: app,
@@ -940,19 +935,16 @@ func TestSendDirectMessageTargetQuits(t *testing.T) {
sid := createSession(ctx, t, env.db, "alice")
createSession(ctx, t, env.db, "bob")
_, err := env.db.GetDB().ExecContext(ctx,
dbtest.Exec(t, env.dbURL,
`CREATE TRIGGER delete_recipient
BEFORE INSERT ON messages
BEGIN
DELETE FROM sessions WHERE id = NEW.to_session_id;
END`)
if err != nil {
t.Fatalf("create trigger: %v", err)
}
body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson
_, err = env.svc.SendDirectMessage(
_, err := env.svc.SendDirectMessage(
ctx, sid, 0, "alice", irc.CmdPrivmsg, "bob", body, nil,
)
@@ -978,15 +970,12 @@ func TestSendChannelMessageChannelDeleted(t *testing.T) {
t.Fatalf("join alice: %v", err)
}
_, err = env.db.GetDB().ExecContext(ctx,
dbtest.Exec(t, env.dbURL,
`CREATE TRIGGER delete_recipient
BEFORE INSERT ON messages
BEGIN
DELETE FROM channels WHERE id = NEW.to_channel_id;
END`)
if err != nil {
t.Fatalf("create trigger: %v", err)
}
body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson