Compare commits
1
Commits
next
..
98a3bf16cd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98a3bf16cd |
@@ -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
@@ -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
|
||||||
|
|||||||
+53
-61
@@ -108,7 +108,7 @@ func (a *App) handleInput(text string) {
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.ui.AddStatus(
|
a.ui.AddStatus(
|
||||||
"[red]Send error: " + escape(err.Error()),
|
"[red]Send error: " + err.Error(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,7 +166,7 @@ func (a *App) dispatchInfoCommand(cmd, args string) {
|
|||||||
a.cmdWhois(args)
|
a.cmdWhois(args)
|
||||||
default:
|
default:
|
||||||
a.ui.AddStatus(
|
a.ui.AddStatus(
|
||||||
"[red]Unknown command: " + escape(cmd),
|
"[red]Unknown command: " + cmd,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,7 +182,7 @@ func (a *App) cmdConnect(serverURL string) {
|
|||||||
|
|
||||||
serverURL = strings.TrimRight(serverURL, "/")
|
serverURL = strings.TrimRight(serverURL, "/")
|
||||||
|
|
||||||
a.ui.AddStatus("Connecting to " + escape(serverURL) + "...")
|
a.ui.AddStatus("Connecting to " + serverURL + "...")
|
||||||
|
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
nick := a.nick
|
nick := a.nick
|
||||||
@@ -192,9 +192,9 @@ func (a *App) cmdConnect(serverURL string) {
|
|||||||
|
|
||||||
resp, err := client.CreateSession(nick)
|
resp, err := client.CreateSession(nick)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.ui.AddStatus(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
"[red]Connection failed: " + escape(err.Error()),
|
"[red]Connection failed: %v", err,
|
||||||
)
|
))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -218,7 +218,7 @@ func (a *App) cmdConnect(serverURL string) {
|
|||||||
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
"[green]Connected! Nick: %s, Session: %d",
|
"[green]Connected! Nick: %s, Session: %d",
|
||||||
escape(resp.Nick), resp.ID,
|
resp.Nick, resp.ID,
|
||||||
))
|
))
|
||||||
a.ui.SetStatus(resp.Nick, "", "connected")
|
a.ui.SetStatus(resp.Nick, "", "connected")
|
||||||
|
|
||||||
@@ -244,7 +244,7 @@ func (a *App) cmdNick(nick string) {
|
|||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
a.ui.AddStatus(
|
a.ui.AddStatus(
|
||||||
"Nick set to " + escape(nick) +
|
"Nick set to " + nick +
|
||||||
" (will be used on connect)",
|
" (will be used on connect)",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -256,9 +256,9 @@ func (a *App) cmdNick(nick string) {
|
|||||||
Body: []string{nick},
|
Body: []string{nick},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.ui.AddStatus(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
"[red]Nick change failed: " + escape(err.Error()),
|
"[red]Nick change failed: %v", err,
|
||||||
)
|
))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -269,7 +269,7 @@ func (a *App) cmdNick(nick string) {
|
|||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
a.ui.SetStatus(nick, target, "connected")
|
a.ui.SetStatus(nick, target, "connected")
|
||||||
a.ui.AddStatus("Nick changed to " + escape(nick))
|
a.ui.AddStatus("Nick changed to " + nick)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) cmdJoin(channel string) {
|
func (a *App) cmdJoin(channel string) {
|
||||||
@@ -297,9 +297,9 @@ func (a *App) cmdJoin(channel string) {
|
|||||||
|
|
||||||
err := a.client.JoinChannel(channel)
|
err := a.client.JoinChannel(channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.ui.AddStatus(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
"[red]Join failed: " + escape(err.Error()),
|
"[red]Join failed: %v", err,
|
||||||
)
|
))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -337,9 +337,9 @@ func (a *App) cmdPart(channel string) {
|
|||||||
|
|
||||||
err := a.client.PartChannel(channel)
|
err := a.client.PartChannel(channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.ui.AddStatus(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
"[red]Part failed: " + escape(err.Error()),
|
"[red]Part failed: %v", err,
|
||||||
)
|
))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -384,9 +384,9 @@ func (a *App) cmdMsg(args string) {
|
|||||||
Body: []string{text},
|
Body: []string{text},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.ui.AddStatus(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
"[red]Send failed: " + escape(err.Error()),
|
"[red]Send failed: %v", err,
|
||||||
)
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,16 +402,10 @@ func (a *App) cmdQuery(nick string) {
|
|||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
a.target = nick
|
a.target = nick
|
||||||
myNick := a.nick
|
myNick := a.nick
|
||||||
connected := a.connected
|
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
connStatus := "disconnected"
|
|
||||||
if connected {
|
|
||||||
connStatus = "connected"
|
|
||||||
}
|
|
||||||
|
|
||||||
a.ui.SwitchToBuffer(nick)
|
a.ui.SwitchToBuffer(nick)
|
||||||
a.ui.SetStatus(myNick, nick, connStatus)
|
a.ui.SetStatus(myNick, nick, "connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) cmdTopic(args string) {
|
func (a *App) cmdTopic(args string) {
|
||||||
@@ -445,9 +439,9 @@ func (a *App) cmdTopic(args string) {
|
|||||||
Body: []string{args},
|
Body: []string{args},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.ui.AddStatus(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
"[red]Topic set failed: " + escape(err.Error()),
|
"[red]Topic set failed: %v", err,
|
||||||
)
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,9 +465,9 @@ func (a *App) cmdNames() {
|
|||||||
|
|
||||||
members, err := a.client.GetMembers(target)
|
members, err := a.client.GetMembers(target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.ui.AddStatus(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
"[red]Names failed: " + escape(err.Error()),
|
"[red]Names failed: %v", err,
|
||||||
)
|
))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -486,7 +480,7 @@ func (a *App) cmdNames() {
|
|||||||
|
|
||||||
a.ui.AddLine(target, fmt.Sprintf(
|
a.ui.AddLine(target, fmt.Sprintf(
|
||||||
"[cyan]*** Members of %s: %s",
|
"[cyan]*** Members of %s: %s",
|
||||||
escape(target), escape(strings.Join(nicks, " ")),
|
target, strings.Join(nicks, " "),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,9 +511,9 @@ func (a *App) cmdList() {
|
|||||||
|
|
||||||
channels, err := a.client.ListChannels()
|
channels, err := a.client.ListChannels()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.ui.AddStatus(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
"[red]List failed: " + escape(err.Error()),
|
"[red]List failed: %v", err,
|
||||||
)
|
))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -528,7 +522,7 @@ func (a *App) cmdList() {
|
|||||||
|
|
||||||
for _, ch := range channels {
|
for _, ch := range channels {
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
" %s %s", escape(ch.Name), escape(ch.Topic),
|
" %s %s", ch.Name, ch.Topic,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,9 +544,9 @@ func (a *App) cmdMotd() {
|
|||||||
&api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct
|
&api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.ui.AddStatus(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
"[red]MOTD failed: " + escape(err.Error()),
|
"[red]MOTD failed: %v", err,
|
||||||
)
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,9 +582,9 @@ func (a *App) cmdWho(args string) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.ui.AddStatus(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
"[red]WHO failed: " + escape(err.Error()),
|
"[red]WHO failed: %v", err,
|
||||||
)
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,9 +613,9 @@ func (a *App) cmdWhois(args string) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.ui.AddStatus(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
"[red]WHOIS failed: " + escape(err.Error()),
|
"[red]WHOIS failed: %v", err,
|
||||||
)
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -824,7 +818,7 @@ func (a *App) handlePrivmsgEvent(
|
|||||||
|
|
||||||
a.ui.AddLine(target, fmt.Sprintf(
|
a.ui.AddLine(target, fmt.Sprintf(
|
||||||
"[gray]%s [green]<%s>[white] %s",
|
"[gray]%s [green]<%s>[white] %s",
|
||||||
timestamp, escape(msg.From), escape(text),
|
timestamp, msg.From, text,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -837,7 +831,7 @@ func (a *App) handleJoinEvent(
|
|||||||
|
|
||||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||||
"[gray]%s [yellow]*** %s has joined %s",
|
"[gray]%s [yellow]*** %s has joined %s",
|
||||||
timestamp, escape(msg.From), escape(msg.To),
|
timestamp, msg.From, msg.To,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -856,11 +850,11 @@ func (a *App) handlePartEvent(
|
|||||||
|
|
||||||
line := fmt.Sprintf(
|
line := fmt.Sprintf(
|
||||||
"[gray]%s [yellow]*** %s has left %s",
|
"[gray]%s [yellow]*** %s has left %s",
|
||||||
timestamp, escape(msg.From), escape(msg.To),
|
timestamp, msg.From, msg.To,
|
||||||
)
|
)
|
||||||
|
|
||||||
if reason != "" {
|
if reason != "" {
|
||||||
line += " (" + escape(reason) + ")"
|
line += " (" + reason + ")"
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg.From == myNick {
|
if msg.From == myNick {
|
||||||
@@ -881,12 +875,12 @@ func (a *App) handleQuitEvent(
|
|||||||
if reason != "" {
|
if reason != "" {
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
"[gray]%s [yellow]*** %s has quit (%s)",
|
"[gray]%s [yellow]*** %s has quit (%s)",
|
||||||
timestamp, escape(msg.From), escape(reason),
|
timestamp, msg.From, reason,
|
||||||
))
|
))
|
||||||
} else {
|
} else {
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
"[gray]%s [yellow]*** %s has quit",
|
"[gray]%s [yellow]*** %s has quit",
|
||||||
timestamp, escape(msg.From),
|
timestamp, msg.From,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -913,7 +907,7 @@ func (a *App) handleNickEvent(
|
|||||||
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
"[gray]%s [yellow]*** %s is now known as %s",
|
"[gray]%s [yellow]*** %s is now known as %s",
|
||||||
timestamp, escape(msg.From), escape(newNick),
|
timestamp, msg.From, newNick,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -925,7 +919,7 @@ func (a *App) handleNoticeEvent(
|
|||||||
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
"[gray]%s [magenta]--%s-- %s",
|
"[gray]%s [magenta]--%s-- %s",
|
||||||
timestamp, escape(msg.From), escape(text),
|
timestamp, msg.From, text,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -941,7 +935,7 @@ func (a *App) handleTopicEvent(
|
|||||||
|
|
||||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||||
"[gray]%s [cyan]*** %s set topic: %s",
|
"[gray]%s [cyan]*** %s set topic: %s",
|
||||||
timestamp, escape(msg.From), escape(text),
|
timestamp, msg.From, text,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -951,12 +945,10 @@ func (a *App) handleDefaultEvent(
|
|||||||
lines := msg.BodyLines()
|
lines := msg.BodyLines()
|
||||||
text := strings.Join(lines, " ")
|
text := strings.Join(lines, " ")
|
||||||
|
|
||||||
// The brackets around the command are escaped with it, as tview
|
|
||||||
// reads a command such as "[MODE]" as a tag.
|
|
||||||
if text != "" {
|
if text != "" {
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
"[gray]%s [white]%s %s",
|
"[gray]%s [white][%s] %s",
|
||||||
timestamp, escape("["+msg.Command+"]"), escape(text),
|
timestamp, msg.Command, text,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"github.com/gdamore/tcell/v2"
|
"github.com/gdamore/tcell/v2"
|
||||||
api "sneak.berlin/go/neoirc/internal/cli/api"
|
api "sneak.berlin/go/neoirc/internal/cli/api"
|
||||||
@@ -24,13 +23,6 @@ const (
|
|||||||
testText = "hello"
|
testText = "hello"
|
||||||
)
|
)
|
||||||
|
|
||||||
// What the status bar shows while the client is connected, and
|
|
||||||
// while it is not.
|
|
||||||
const (
|
|
||||||
statusConnected = "[connected]"
|
|
||||||
statusDisconnected = "[disconnected]"
|
|
||||||
)
|
|
||||||
|
|
||||||
// eventLoopTimeout is how long a test waits for a typed line to
|
// eventLoopTimeout is how long a test waits for a typed line to
|
||||||
// be handled, for the welcome lines, for the UI to stop, for a poll
|
// be handled, for the welcome lines, for the UI to stop, for a poll
|
||||||
// or for a poll loop to end, before it fails.
|
// or for a poll loop to end, before it fails.
|
||||||
@@ -369,46 +361,6 @@ func TestWindowConnectionState(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestQueryConnectionState checks that typing /query bob leaves the
|
|
||||||
// status bar showing [connected] only while the client is connected,
|
|
||||||
// and [disconnected] otherwise.
|
|
||||||
func TestQueryConnectionState(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const line = "/query " + testOtherNick
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
connected bool
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{name: "not connected", connected: false, want: statusDisconnected},
|
|
||||||
{name: "connected", connected: true, want: statusConnected},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
app := &App{ui: NewUI(), nick: testNick, connected: test.connected}
|
|
||||||
handled := handledLines(app)
|
|
||||||
runUI(t, app.ui)
|
|
||||||
|
|
||||||
for _, r := range line {
|
|
||||||
app.ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone))
|
|
||||||
}
|
|
||||||
|
|
||||||
app.ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone))
|
|
||||||
waitHandled(t, handled, line)
|
|
||||||
|
|
||||||
status := statusBarText(app.ui)
|
|
||||||
if !strings.Contains(status, test.want) {
|
|
||||||
t.Fatalf("status bar is %q, want it to show %s", status, test.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEventLoopKeepsRunning sends Alt and 1, then a typed line and
|
// TestEventLoopKeepsRunning sends Alt and 1, then a typed line and
|
||||||
// Enter, to a running UI. It checks that both are handled, so the
|
// Enter, to a running UI. It checks that both are handled, so the
|
||||||
// event loop did not stop on either.
|
// event loop did not stop on either.
|
||||||
@@ -893,260 +845,6 @@ func TestOwnEventFromOtherClient(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestBracketsInMessageWindow checks that each kind of line drawn
|
|
||||||
// from a server event shows nicks, channel names, texts, reasons
|
|
||||||
// and topics holding square brackets as written.
|
|
||||||
func TestBracketsInMessageWindow(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Every event is sent to this channel and has a body of one line.
|
|
||||||
const channel = "#chan[x]"
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
command string
|
|
||||||
from string
|
|
||||||
body string
|
|
||||||
// Whether the line goes to the channel's window rather than
|
|
||||||
// the status window, which is the one shown at start.
|
|
||||||
inChannel bool
|
|
||||||
// How the line ends, after the timestamps.
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "message", command: irc.CmdPrivmsg, inChannel: true,
|
|
||||||
from: "bob[|[]", body: "x[^[] [:::y|z] [red]",
|
|
||||||
want: "<bob[|[]> x[^[] [:::y|z] [red]",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "joined", command: irc.CmdJoin, from: "x[^[]", inChannel: true,
|
|
||||||
want: "*** x[^[] has joined #chan[x]",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "left", command: irc.CmdPart, inChannel: true,
|
|
||||||
from: "erin[away]", body: "[:::y|z]",
|
|
||||||
want: "*** erin[away] has left #chan[x] ([:::y|z])",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "topic", command: irc.CmdTopic, inChannel: true,
|
|
||||||
from: "dave[afk]", body: "[red]news",
|
|
||||||
want: "*** dave[afk] set topic: [red]news",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "quit", command: irc.CmdQuit, from: "carol[x]", body: "bye [red]",
|
|
||||||
want: "*** carol[x] has quit (bye [red])",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "quit without reason", command: irc.CmdQuit, from: "frank[x]",
|
|
||||||
want: "*** frank[x] has quit",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "nick change", command: irc.CmdNick, from: "erin[m]", body: "erin[x]",
|
|
||||||
want: "*** erin[m] is now known as erin[x]",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "notice", command: irc.CmdNotice, from: "bob[x]", body: "[::b]note",
|
|
||||||
want: "--bob[x]-- [::b]note",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "other command", command: irc.CmdMode, body: "+v dave[afk]",
|
|
||||||
want: "[MODE] +v dave[afk]",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
app := &App{ui: NewUI(), nick: testNick}
|
|
||||||
screen := runUI(t, app.ui)
|
|
||||||
|
|
||||||
if test.inChannel {
|
|
||||||
app.ui.SwitchToBuffer(channel)
|
|
||||||
}
|
|
||||||
|
|
||||||
app.handleServerMessage(&api.Message{
|
|
||||||
Command: test.command, From: test.from, To: channel,
|
|
||||||
Body: []string{test.body},
|
|
||||||
})
|
|
||||||
|
|
||||||
got, _ := shownLine(app.ui, screen, 0)
|
|
||||||
if !strings.HasSuffix(got, test.want) {
|
|
||||||
t.Fatalf("message window shows %q, want it to end %q", got, test.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBracketsInCommandErrors checks that when the server refuses
|
|
||||||
// a typed command, the error the client shows holds the server's
|
|
||||||
// answer as written.
|
|
||||||
func TestBracketsInCommandErrors(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
server := newRefusingServer(t)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
typed string
|
|
||||||
// The start of the line, before the error.
|
|
||||||
reply string
|
|
||||||
}{
|
|
||||||
{typed: testText, reply: "Send error"},
|
|
||||||
{typed: "/nick bob", reply: "Nick change failed"},
|
|
||||||
{typed: "/join #x", reply: "Join failed"},
|
|
||||||
{typed: "/part", reply: "Part failed"},
|
|
||||||
{typed: "/msg bob hi", reply: "Send failed"},
|
|
||||||
{typed: "/topic hi", reply: "Topic set failed"},
|
|
||||||
{typed: "/names", reply: "Names failed"},
|
|
||||||
{typed: "/list", reply: "List failed"},
|
|
||||||
{typed: "/motd", reply: "MOTD failed"},
|
|
||||||
{typed: "/who", reply: "WHO failed"},
|
|
||||||
{typed: "/whois bob", reply: "WHOIS failed"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.reply, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
app := &App{
|
|
||||||
ui: NewUI(),
|
|
||||||
client: api.NewClient(server.URL),
|
|
||||||
nick: testNick,
|
|
||||||
target: testChannel,
|
|
||||||
connected: true,
|
|
||||||
}
|
|
||||||
screen := runUI(t, app.ui)
|
|
||||||
|
|
||||||
app.handleInput(test.typed)
|
|
||||||
|
|
||||||
want := test.reply + ": HTTP error 400: [red]no"
|
|
||||||
|
|
||||||
got, _ := shownLine(app.ui, screen, 0)
|
|
||||||
if !strings.HasSuffix(got, want) {
|
|
||||||
t.Fatalf("message window shows %q, want it to end %q", got, want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBracketsInCommandReplies checks that the other lines the
|
|
||||||
// client draws for a typed command show square brackets as
|
|
||||||
// written, whether they come from the typed line or the server.
|
|
||||||
func TestBracketsInCommandReplies(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const channel = "#chan[x]"
|
|
||||||
|
|
||||||
refusing := newRefusingServer(t)
|
|
||||||
session := newServer(t, `{"id": 1, "nick": "erin[x]"}`)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
// The server the client is connected to before the line is
|
|
||||||
// typed, if any.
|
|
||||||
server *httptest.Server
|
|
||||||
typed string
|
|
||||||
// Whether the line goes to the channel's window rather than
|
|
||||||
// the status window, and which line of the window it is,
|
|
||||||
// counting from 0.
|
|
||||||
inChannel bool
|
|
||||||
line int
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{name: "unknown command", typed: "/x[red]", want: "Unknown command: /x[red]"},
|
|
||||||
{
|
|
||||||
name: "nick set", typed: "/nick erin[x]",
|
|
||||||
want: "Nick set to erin[x] (will be used on connect)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "nick changed", server: session, typed: "/nick erin[x]",
|
|
||||||
want: "Nick changed to erin[x]",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "connecting", typed: "/connect " + refusing.URL + "/[x]",
|
|
||||||
want: "Connecting to " + refusing.URL + "/[x]...",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "connection failed", typed: "/connect " + refusing.URL, line: 1,
|
|
||||||
want: "Connection failed: HTTP error 400: [red]no",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "connected", typed: "/connect " + session.URL, line: 1,
|
|
||||||
want: "Connected! Nick: erin[x], Session: 1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "members", server: newServer(t, `[{"nick": "bob[x]"}]`),
|
|
||||||
typed: "/names", inChannel: true, want: "*** Members of #chan[x]: bob[x]",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "channel list", typed: "/list", line: 1, want: " #chan[x] [red]news",
|
|
||||||
server: newServer(t, `[{"name": "#chan[x]", "topic": "[red]news"}]`),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
app := &App{ui: NewUI(), nick: testNick, target: channel}
|
|
||||||
|
|
||||||
if test.server != nil {
|
|
||||||
app.client = api.NewClient(test.server.URL)
|
|
||||||
app.connected = true
|
|
||||||
}
|
|
||||||
|
|
||||||
screen := runUI(t, app.ui)
|
|
||||||
|
|
||||||
// Stop the poll loop a /connect starts, as /quit does.
|
|
||||||
// Cleanups run last first, so this runs before the UI
|
|
||||||
// stops.
|
|
||||||
t.Cleanup(func() {
|
|
||||||
app.mu.Lock()
|
|
||||||
defer app.mu.Unlock()
|
|
||||||
|
|
||||||
if app.stopPoll != nil {
|
|
||||||
close(app.stopPoll)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if test.inChannel {
|
|
||||||
app.ui.SwitchToBuffer(channel)
|
|
||||||
}
|
|
||||||
|
|
||||||
app.handleInput(test.typed)
|
|
||||||
|
|
||||||
got, _ := shownLine(app.ui, screen, test.line)
|
|
||||||
if !strings.HasSuffix(got, test.want) {
|
|
||||||
t.Fatalf("message window shows %q, want it to end %q", got, test.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestColourTagInMessage checks that a colour tag in a message
|
|
||||||
// shows as written and leaves the message text white, as the
|
|
||||||
// client draws it.
|
|
||||||
func TestColourTagInMessage(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const want = "<bob> [red]x"
|
|
||||||
|
|
||||||
app := &App{ui: NewUI(), nick: testNick}
|
|
||||||
screen := runUI(t, app.ui)
|
|
||||||
app.ui.SwitchToBuffer(testChannel)
|
|
||||||
|
|
||||||
app.handleServerMessage(&api.Message{
|
|
||||||
Command: irc.CmdPrivmsg, From: testOtherNick, To: testChannel,
|
|
||||||
Body: []string{"[red]x"},
|
|
||||||
})
|
|
||||||
|
|
||||||
got, colour := shownLine(app.ui, screen, 0)
|
|
||||||
if !strings.HasSuffix(got, want) || colour != tcell.ColorWhite {
|
|
||||||
t.Fatalf("message window shows %q, its last character in %v; "+
|
|
||||||
"want it to end %q, in white", got, colour, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// bufferState returns the number of lines in the named
|
// bufferState returns the number of lines in the named
|
||||||
// buffer and how many of them are unread.
|
// buffer and how many of them are unread.
|
||||||
func bufferState(ui *UI, name string) (int, int) {
|
func bufferState(ui *UI, name string) (int, int) {
|
||||||
@@ -1174,41 +872,6 @@ func statusBarText(ui *UI) string {
|
|||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
// shownLine returns the given line of the message window, counting
|
|
||||||
// from 0, as the screen shows it, without the blanks after it, and
|
|
||||||
// the colour of its last character. Each line in these tests takes
|
|
||||||
// one row of the screen.
|
|
||||||
func shownLine(
|
|
||||||
ui *UI, screen tcell.SimulationScreen, line int,
|
|
||||||
) (string, tcell.Color) {
|
|
||||||
var (
|
|
||||||
text string
|
|
||||||
colour tcell.Color
|
|
||||||
)
|
|
||||||
|
|
||||||
ui.app.QueueUpdate(func() {
|
|
||||||
cells, width, _ := screen.GetContents()
|
|
||||||
cells = cells[line*width : (line+1)*width]
|
|
||||||
|
|
||||||
var row strings.Builder
|
|
||||||
|
|
||||||
for _, cell := range cells {
|
|
||||||
row.WriteString(string(cell.Runes))
|
|
||||||
}
|
|
||||||
|
|
||||||
text = strings.TrimRight(row.String(), " ")
|
|
||||||
if text == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Each character in these tests takes one cell.
|
|
||||||
last := cells[utf8.RuneCountInString(text)-1]
|
|
||||||
colour, _, _ = last.Style.Decompose()
|
|
||||||
})
|
|
||||||
|
|
||||||
return text, colour
|
|
||||||
}
|
|
||||||
|
|
||||||
// handledLines makes app.ui pass each typed line to
|
// handledLines makes app.ui pass each typed line to
|
||||||
// app.handleInput, as Run does, and returns a channel that
|
// app.handleInput, as Run does, and returns a channel that
|
||||||
// receives each line once it has been handled.
|
// receives each line once it has been handled.
|
||||||
@@ -1254,23 +917,6 @@ func newServer(t *testing.T, body string) *httptest.Server {
|
|||||||
return server
|
return server
|
||||||
}
|
}
|
||||||
|
|
||||||
// newRefusingServer starts a server that answers every request
|
|
||||||
// with the status 400 Bad Request and the body "[red]no" until
|
|
||||||
// the test ends. The client shows that body in its error.
|
|
||||||
func newRefusingServer(t *testing.T) *httptest.Server {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(
|
|
||||||
func(writer http.ResponseWriter, _ *http.Request) {
|
|
||||||
writer.WriteHeader(http.StatusBadRequest)
|
|
||||||
_, _ = io.WriteString(writer, "[red]no")
|
|
||||||
},
|
|
||||||
))
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
|
|
||||||
return server
|
|
||||||
}
|
|
||||||
|
|
||||||
// newHoldingServer starts a server that sends on the returned
|
// newHoldingServer starts a server that sends on the returned
|
||||||
// channel for each poll and holds the poll until its connection
|
// channel for each poll and holds the poll until its connection
|
||||||
// closes. It answers any other request with body.
|
// closes. It answers any other request with body.
|
||||||
@@ -1318,15 +964,11 @@ func waitPoll(t *testing.T, polled <-chan struct{}, server string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// runUI runs ui on a simulated screen until the test ends, and
|
// runUI runs ui on a simulated screen until the test ends.
|
||||||
// returns the screen.
|
func runUI(t *testing.T, ui *UI) {
|
||||||
//
|
|
||||||
//nolint:ireturn // tcell has no exported type for the screen
|
|
||||||
func runUI(t *testing.T, ui *UI) tcell.SimulationScreen {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
screen := tcell.NewSimulationScreen("")
|
ui.app.SetScreen(tcell.NewSimulationScreen(""))
|
||||||
ui.app.SetScreen(screen)
|
|
||||||
|
|
||||||
// One slot, so that Run's goroutine can end even after the
|
// One slot, so that Run's goroutine can end even after the
|
||||||
// cleanup below has stopped waiting for it.
|
// cleanup below has stopped waiting for it.
|
||||||
@@ -1346,6 +988,4 @@ func runUI(t *testing.T, ui *UI) tcell.SimulationScreen {
|
|||||||
t.Error("the UI did not stop: its event loop stopped responding")
|
t.Error("the UI did not stop: its event loop stopped responding")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return screen
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,16 +235,6 @@ func (ui *UI) initMessages() {
|
|||||||
ui.messages.SetBorder(false)
|
ui.messages.SetBorder(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// escape returns text so that the message window shows it exactly
|
|
||||||
// as written. It writes each "[" as "[[:]": a "[" followed by
|
|
||||||
// another "[" never starts a tag, and the tag "[:]" changes
|
|
||||||
// nothing. tview.Escape is not enough: it does not escape a bracket
|
|
||||||
// pair holding a symbol such as "|" or "^", and nothing it adds
|
|
||||||
// stops a tag with a URL, such as "[:::y|z]".
|
|
||||||
func escape(text string) string {
|
|
||||||
return strings.ReplaceAll(text, "[", "[[:]")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ui *UI) initStatusBar() {
|
func (ui *UI) initStatusBar() {
|
||||||
// Colour tags stay off. With them on, tview reads text in
|
// Colour tags stay off. With them on, tview reads text in
|
||||||
// square brackets, such as "[connected]" or part of a nick,
|
// square brackets, such as "[connected]" or part of a nick,
|
||||||
|
|||||||
+8
-1
@@ -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)
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
@@ -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{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
t.Logf("close db: %v", err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
conn.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
_, err = conn.ExecContext(
|
||||||
|
t.Context(), "PRAGMA foreign_keys = ON",
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("new database: %v", err)
|
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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user