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
129 lines
2.9 KiB
Go
129 lines
2.9 KiB
Go
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)
|
|
}
|
|
}
|