Compare commits
9
Commits
9e4ff6942c
..
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc8aa6bb96 | ||
|
|
bc7ef8240b | ||
|
|
8afd873813 | ||
|
|
c12668226f | ||
|
|
4221f71920 | ||
|
|
254873a656 | ||
|
|
0b63384423 | ||
|
|
c4fc156a8c | ||
|
|
5fe325399b |
@@ -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
@@ -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
|
||||
|
||||
+1
-8
@@ -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)
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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,
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user