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) } }