1 Commits
Author SHA1 Message Date
sneak 9e4ff6942c 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 07:09:42 +00:00
18 changed files with 3499 additions and 2114 deletions
+38 -56
View File
@@ -1,73 +1,55 @@
# Rules for coding agents # AGENTS.md — Rules for AI Agents
This file holds the rules for coding agents working in this repository. ## Before Every Commit
`CONVENTIONS.md` defines the architecture. `REPO_POLICIES.md` defines the
repository standards. Both are copies of shared documents. Do not edit them
here.
## Before every commit 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
1. Run `make fmt`. Never run `go test`, `golangci-lint` or `gofmt` directly; use the `make`
2. Run `make check`. It runs the tests and the linter in Docker, then the format targets.
check. All three must pass with no findings.
Never run `go test`, `golangci-lint` or `gofmt` directly. Use the `make` targets No commit lands on main with lint errors, test failures, or formatting issues.
instead, such as `make test` and `make lint`.
## Branches and pull requests ## Code Style
- Never commit to `main` or `next` directly. Every change is a pull request from - Follow CONVENTIONS.md exactly — it defines the architecture
a feature branch into `next`. - All exported types, functions, methods, and vars must have doc comments
- Only the owner merges `next` into `main`. - All packages must have a package doc comment
- A pull request that closes an issue has the title `Description (closes #N)`. - JSON tags use camelCase (not snake_case)
- One logical change per commit. - No magic numbers — use named constants
- Commit messages are in the imperative mood and short. - 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
## Code style ## Git Workflow
- Follow `CONVENTIONS.md`. - **Never commit directly to main** — all changes go on feature branches
- Every package has a package doc comment. Test files need none. - Merge to main only when ready and passing all checks
- Every exported type, function and variable has a doc comment. Test files need - PR titles: `Description (closes #N)` when closing an issue
none. - One logical change per commit
- JSON tags are camelCase. The HTTP API's `last_id`, `pow_token` and - Commit messages: imperative mood, concise
`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
- The server uses `go.uber.org/fx` for dependency injection. - Dependency injection via Uber fx — all components are fx providers
`cmd/neoircd/main.go` lists its `fx` providers, such as `db.New` and - `internal/` for all application packages — one package per concern
`handlers.New`. - Handlers return `http.HandlerFunc` using the closure pattern
- Application packages live under `internal/`, one package per concern. - Request/response structs defined inside handler closures
- `pkg/irc` holds the IRC command names and numeric reply codes. - Models embed `models.Base` for database access to relation methods
- The `web` package embeds the built web client. - Database migrations in `internal/db/schema/` — embedded via `go:embed`
- Each exported `Handle…` method of `Handlers` returns an `http.HandlerFunc`. - SQLite by default (pure Go driver: modernc.org/sqlite)
- 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
- Prefer table-driven tests. - Table-driven tests preferred
- Use `testing.T` helpers, not assertion libraries. - Use `testing.T` helpers, not assertion libraries
- A test file lives next to the code it tests and ends in `_test.go`. - Test files live next to the code they test
- The linter requires each test file to be in the package's external test - `_test.go` suffix, same package for white-box testing
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
- The required libraries are listed in `CONVENTIONS.md` §1. - Required libraries are listed in CONVENTIONS.md §1
- No new dependency without a reason given in the pull request. - No new dependencies without justification
- Prefer dependencies written in pure Go, which build without cgo. - Pure Go preferred (no CGO)
+4 -8
View File
@@ -33,18 +33,14 @@ 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.
# The modules download in this RUN rather than a layer of their own, because RUN go test -p 4 -timeout 90s -race -cover ./... || \
# 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
+3143 -1692
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -10,6 +10,13 @@ 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",
) )
@@ -22,7 +29,7 @@ func (database *Database) SetPassword(
password string, password string,
) error { ) error {
hash, err := bcrypt.GenerateFromPassword( hash, err := bcrypt.GenerateFromPassword(
[]byte(password), bcrypt.DefaultCost, []byte(password), bcryptCost,
) )
if err != nil { if err != nil {
return fmt.Errorf("hash password: %w", err) return fmt.Errorf("hash password: %w", err)
+5
View File
@@ -85,6 +85,11 @@ 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,7 +6,6 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"testing" "testing"
"time"
"sneak.berlin/go/neoirc/internal/dbtest" "sneak.berlin/go/neoirc/internal/dbtest"
) )
@@ -68,78 +67,3 @@ 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
@@ -0,0 +1,14 @@
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,6 +320,29 @@ 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,
@@ -410,6 +433,54 @@ 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")
@@ -1056,6 +1127,23 @@ 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;
@@ -2372,6 +2460,28 @@ 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.
+16 -3
View File
@@ -125,7 +125,7 @@ func TestCreateSessionStoresIP(t *testing.T) {
database := setupTestDB(t) database := setupTestDB(t)
ctx := t.Context() ctx := t.Context()
sessionID, _, _, err := database.CreateSession( sessionID, clientID, _, 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.GetLatestClientForSession( clientInfo, err := database.GetClientHostInfo(
ctx, sessionID, ctx, clientID,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -169,6 +169,19 @@ 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
@@ -0,0 +1,25 @@
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)
}
+2 -22
View File
@@ -1,5 +1,5 @@
// Package dbtest gives each test a database of its own and runs raw // Package dbtest gives each test a database of its own. Only tests
// SQL on it. Only tests may import it. // may import it.
package dbtest package dbtest
import ( import (
@@ -37,23 +37,3 @@ 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 := decodeJSON(request, &payload) err := json.NewDecoder(request.Body).Decode(&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 := decodeJSON(request, &payload) err := json.NewDecoder(request.Body).Decode(&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{channel}, []string{target},
"End of /WHO list", "End of /WHO list",
) )
hdlr.broker.Notify(sessionID) hdlr.broker.Notify(sessionID)
+40 -164
View File
@@ -47,25 +47,22 @@ 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) {
template := fx.New( db.SetBcryptCost(bcrypt.MinCost)
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) {}),
)
err := template.Start(context.Background()) template, err := sql.Open("sqlite", templateDBURL)
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.Stop(context.Background()) _ = template.Close()
os.Exit(code) os.Exit(code)
} }
@@ -140,7 +137,6 @@ 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
} }
@@ -235,7 +231,6 @@ func newTestServerWith(
t: t, t: t,
fxApp: app, fxApp: app,
broker: brk, broker: brk,
dbURL: dbURL,
database: database, database: database,
handlers: hdlr, handlers: hdlr,
} }
@@ -802,104 +797,6 @@ 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()
@@ -1765,7 +1662,9 @@ func checkQuitRace(
firstToken := tserver.createSession(nick) firstToken := tserver.createSession(nick)
observerToken := tserver.createSession("raceobserver") observerToken := tserver.createSession("raceobserver")
tserver.setPassword(nick) tserver.sendCommand(firstToken, map[string]any{
commandKey: irc.CmdPass, bodyKey: []string{testPassword},
})
tokens := []string{firstToken, tserver.login(nick)} tokens := []string{firstToken, tserver.login(nick)}
@@ -1815,7 +1714,9 @@ func TestLogoutOfOneClientKeepsSession(t *testing.T) {
firstToken := tserver.createSession(nick) firstToken := tserver.createSession(nick)
observerToken := tserver.createSession("logoutobserver") observerToken := tserver.createSession("logoutobserver")
tserver.setPassword(nick) tserver.sendCommand(firstToken, map[string]any{
commandKey: irc.CmdPass, bodyKey: []string{testPassword},
})
secondToken := tserver.login(nick) secondToken := tserver.login(nick)
@@ -1885,12 +1786,15 @@ func TestFailedLogoutChangesNothing(t *testing.T) {
_, lastID := tserver.pollMessages(observerToken, 0) _, lastID := tserver.pollMessages(observerToken, 0)
dbtest.Exec(t, tserver.dbURL, _, err := tserver.database.GetDB().ExecContext(t.Context(),
`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,
@@ -2124,24 +2028,6 @@ 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 {
@@ -3749,8 +3635,13 @@ func TestLoginValid(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
tserver.createSession("loginuser") // Create session and set password via PASS command.
tserver.setPassword("loginuser") token := tserver.createSession("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{
@@ -3814,8 +3705,13 @@ func TestLoginWrongPassword(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
tserver.createSession("wrongpwuser") // Create session and set password via PASS command.
tserver.setPassword("wrongpwuser") token := tserver.createSession("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",
@@ -4143,31 +4039,6 @@ 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()
@@ -4286,8 +4157,13 @@ func TestLoginRateLimitAllowsNormalUse(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
tserver.createSession("normaluser") // Create session and set password via PASS command.
tserver.setPassword("normaluser") token := tserver.createSession("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{
+2 -1
View File
@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"encoding/json"
"net/http" "net/http"
"strings" "strings"
@@ -54,7 +55,7 @@ func (hdlr *Handlers) handleLogin(
var payload loginRequest var payload loginRequest
err := decodeJSON(request, &payload) err := json.NewDecoder(request.Body).Decode(&payload)
if err != nil { if err != nil {
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
-13
View File
@@ -5,7 +5,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"time" "time"
@@ -160,18 +159,6 @@ 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 == "" {
+4 -17
View File
@@ -8,8 +8,6 @@ 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"
) )
@@ -585,10 +583,8 @@ 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 a password, as POST /api/v1/login // client to her session with the password her connection set,
// does. It stores that password without sending PASS. It hashes // as POST /api/v1/login does. It returns her connection, bob's
// 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,
@@ -598,24 +594,15 @@ 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",
) )
+50 -34
View File
@@ -32,9 +32,14 @@ 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
@@ -105,26 +110,33 @@ func newTestEnvWithResolver(
) *testEnv { ) *testEnv {
t.Helper() t.Helper()
dbURL := dbtest.NewURL(t) conn, err := sql.Open("sqlite", dbtest.NewURL(t))
lifecycle := fxtest.NewLifecycle(t)
dbLog, err := logger.New(lifecycle, logger.Params{
Globals: &globals.Globals{},
})
if err != nil { if err != nil {
t.Fatalf("new logger: %v", err) t.Fatalf("open db: %v", err)
} }
database, err := db.New(lifecycle, db.Params{ t.Cleanup(func() {
Logger: dbLog, err := conn.Close()
Config: &config.Config{DBURL: dbURL},
})
if err != nil { if err != nil {
t.Fatalf("new database: %v", err) t.Logf("close db: %v", err)
}
})
conn.SetMaxOpenConns(1)
_, err = conn.ExecContext(
t.Context(), "PRAGMA foreign_keys = ON",
)
if err != nil {
t.Fatalf("pragma: %v", err)
} }
lifecycle.RequireStart() database := db.NewTestDatabaseFromConn(conn)
t.Cleanup(lifecycle.RequireStop)
err = database.RunMigrations(t.Context())
if err != nil {
t.Fatalf("migrate: %v", err)
}
brk := broker.New() brk := broker.New()
@@ -149,7 +161,6 @@ 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,
@@ -918,7 +929,12 @@ func quitSeenByPeers(t *testing.T, quit string) (string, []string) {
t.Fatalf("create peer session: %v", err) t.Fatalf("create peer session: %v", err)
} }
_, _, err = env.database.CreateOrJoinChannel(ctx, "#bye", apiPeerID, "") channelID, err := env.database.GetChannelByName(ctx, "#bye")
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)
} }
@@ -989,9 +1005,12 @@ func TestStopEndsSessions(t *testing.T) {
t.Fatalf("create peer session: %v", err) t.Fatalf("create peer session: %v", err)
} }
channelID, _, err := env.database.CreateOrJoinChannel( channelID, err := env.database.GetChannelByName(ctx, "#shut")
ctx, "#shut", peerID, "", if err != nil {
) 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)
} }
@@ -1049,16 +1068,11 @@ func TestStopReturnsWhileCleanupIsStuck(t *testing.T) {
client.register("stuck") client.register("stuck")
// While a connection of the test's own holds a write // The test database has one connection. While the test
// transaction, the cleanup's first write waits for it to end. // holds it, the cleanup's first query waits for it.
holder, err := sql.Open("sqlite", env.dbURL) held, err := env.database.GetDB().Conn(t.Context())
if err != nil { if err != nil {
t.Fatalf("open a second database connection: %v", err) t.Fatalf("take the 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{})
@@ -1074,11 +1088,10 @@ 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")
} }
// Closing ends the transaction, which lets the cleanup finish // Lets the cleanup finish before the database is closed.
// before the database is closed. err = held.Close()
err = holder.Close()
if err != nil { if err != nil {
t.Errorf("close the second database connection: %v", err) t.Errorf("release the database connection: %v", err)
} }
} }
@@ -1357,11 +1370,14 @@ func TestPassPostRegistrationNotStored(t *testing.T) {
client.register("failpass") client.register("failpass")
dbtest.Exec(t, env.dbURL, _, err := env.database.GetDB().ExecContext(t.Context(),
`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")
+16 -5
View File
@@ -9,6 +9,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"os"
"slices" "slices"
"strings" "strings"
"sync" "sync"
@@ -17,6 +18,7 @@ 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"
@@ -28,10 +30,14 @@ 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
@@ -105,7 +111,6 @@ 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,
@@ -935,16 +940,19 @@ 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")
dbtest.Exec(t, env.dbURL, _, err := env.db.GetDB().ExecContext(ctx,
`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,
) )
@@ -970,12 +978,15 @@ func TestSendChannelMessageChannelDeleted(t *testing.T) {
t.Fatalf("join alice: %v", err) t.Fatalf("join alice: %v", err)
} }
dbtest.Exec(t, env.dbURL, _, err = env.db.GetDB().ExecContext(ctx,
`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