Add the tier 3 utility IRC commands (closes #87)
check / check (push) Waiting to run

USERHOST, VERSION, ADMIN, INFO, TIME, KILL and WALLOPS on both the
HTTP API and the IRC listener, and user mode +w.

Model: opus-5-5
This commit is contained in:
2026-10-08 00:26:08 +00:00
parent b119b094f5
commit e28fa005ed
19 changed files with 3931 additions and 85 deletions
+128
View File
@@ -0,0 +1,128 @@
package ircserver_test
import (
"bufio"
"log/slog"
"net"
"os"
"strings"
"testing"
"time"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/ircserver"
)
// disconnectBudget is how long Disconnect is allowed to
// take when the victim never reads. It is far below the
// 30s writeTimeout that the old synchronous implementation
// would have burned on each of its two writes.
const disconnectBudget = 2 * time.Second
// TestDisconnectDoesNotBlockOnUnresponsiveVictim proves
// that an operator KILL cannot be stalled by its target.
// The victim's socket is a net.Pipe, so every write blocks
// until the peer reads and the peer here never does. The
// old implementation performed both notification writes on
// the killer's goroutine, which wedged the operator's own
// serve() loop on the IRC path and the API request on the
// HTTP path for as long as the victim cared to stay silent.
func TestDisconnectDoesNotBlockOnUnresponsiveVictim(
t *testing.T,
) {
t.Parallel()
serverSide, clientSide := net.Pipe()
t.Cleanup(func() {
_ = clientSide.Close()
})
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
))
cfg := &config.Config{ //nolint:exhaustruct
ServerName: "test.irc",
}
victim := ircserver.NewTestConn(
log, cfg, serverSide, "victim",
)
returned := make(chan struct{})
go func() {
victim.Disconnect("killed by oper")
close(returned)
}()
select {
case <-returned:
case <-time.After(disconnectBudget):
t.Fatal(
"Disconnect blocked on the victim's socket; " +
"the killer must not be held hostage",
)
}
}
// TestDisconnectNotifiesAndClosesVictim is the other half
// of the contract: moving the notification off the killer's
// goroutine must not lose it. A victim that does read gets
// both the KILL and the ERROR line, and then its socket is
// closed so its read loop unblocks.
func TestDisconnectNotifiesAndClosesVictim(t *testing.T) {
t.Parallel()
serverSide, clientSide := net.Pipe()
t.Cleanup(func() {
_ = clientSide.Close()
})
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
))
cfg := &config.Config{ //nolint:exhaustruct
ServerName: "test.irc",
}
victim := ircserver.NewTestConn(
log, cfg, serverSide, "victim",
)
lines := make(chan []string, 1)
go func() {
var got []string
scanner := bufio.NewScanner(clientSide)
for scanner.Scan() {
got = append(got, scanner.Text())
}
lines <- got
}()
victim.Disconnect("killed by oper")
var got []string
select {
case got = <-lines:
case <-time.After(5 * time.Second):
t.Fatal("victim socket was never closed")
}
joined := strings.Join(got, "\n")
if !strings.Contains(joined, "KILL victim") {
t.Errorf("missing KILL line, got: %q", joined)
}
if !strings.Contains(joined, "ERROR :Closing Link:") {
t.Errorf("missing ERROR line, got: %q", joined)
}
}