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

USERHOST, VERSION, ADMIN, INFO, TIME, KILL and WALLOPS work on the
HTTP API and on the IRC listener. The USERHOST reply, the VERSION,
ADMIN and INFO text, KILL and WALLOPS are each one service function
that both transports call. User mode +w selects who receives WALLOPS.
MODE on your own nick queries or changes your user modes through one
parser on both transports: the whole mode string is checked before
anything is stored, and both flags are stored in one transaction. KILL
closes the victim's IRC connection after sending it KILL and ERROR,
without the operator waiting on it. The sessions table gains
is_wallops in 001_initial.sql, as the pre-1.0 rule in REPO_POLICIES.md
asks.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
This commit was merged in pull request #96.
This commit is contained in:
2026-10-08 07:31:37 +02:00
committed by clawbot
parent b119b094f5
commit 0f2e63d0cc
19 changed files with 2552 additions and 129 deletions
+394
View File
@@ -4,6 +4,8 @@ import (
"strings"
"testing"
"time"
"sneak.berlin/go/neoirc/internal/config"
)
// TestIntegrationTwoClients is a comprehensive integration
@@ -762,6 +764,371 @@ func TestIntegrationTwoClients(t *testing.T) {
)
}
// ── Tier 3 Utility Command Integration Tests ──────────
// TestIntegrationUserhost verifies the USERHOST command
// returns user@host info for connected nicks.
func TestIntegrationUserhost(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
bob := env.dial(t)
bob.register("bob")
bob.send("AWAY :lunch")
bob.readUntil(func(l string) bool {
return strings.Contains(l, " 306 ")
})
for _, step := range []struct {
line, want string
}{
{"USERHOST alice", " 302 alice alice=+alice@"},
{"USERHOST alice bob", " :alice=+alice@"},
{"USERHOST alice bob", " bob=-bob@"},
{"USERHOST nobody", " 302 alice :"},
{"USERHOST", " 461 alice USERHOST :"},
} {
alice.send(step.line)
reply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 302 ") ||
strings.Contains(l, " 461 ")
})
assertContains(t, reply, step.want, step.line)
}
}
// TestIntegrationVersion verifies the VERSION command
// returns the server version string.
func TestIntegrationVersion(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.send("VERSION")
aliceReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 351 ")
})
assertContains(
t, aliceReply, " 351 ",
"RPL_VERSION",
)
assertContains(
t, aliceReply, "neoirc",
"VERSION reply contains server name",
)
}
// TestIntegrationAdmin verifies the ADMIN command returns
// server admin info (256–259 numerics).
func TestIntegrationAdmin(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.send("ADMIN")
aliceReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 259 ")
})
assertContains(
t, aliceReply, " 256 ",
"RPL_ADMINME",
)
assertContains(
t, aliceReply, " 257 ",
"RPL_ADMINLOC1",
)
assertContains(
t, aliceReply, " 258 ",
"RPL_ADMINLOC2",
)
assertContains(
t, aliceReply, " 259 ",
"RPL_ADMINEMAIL",
)
}
// TestIntegrationInfo verifies the INFO command returns
// server information (371/374 numerics).
func TestIntegrationInfo(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.send("INFO")
aliceReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 374 ")
})
assertContains(
t, aliceReply, " 371 ",
"RPL_INFO",
)
assertContains(
t, aliceReply, " 374 ",
"RPL_ENDOFINFO",
)
assertContains(
t, aliceReply, "neoirc",
"INFO reply mentions server name",
)
}
// TestIntegrationTime verifies the TIME command returns
// the server time (391 numeric).
func TestIntegrationTime(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.send("TIME")
aliceReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 391 ")
})
assertContains(
t, aliceReply, " 391 ",
"RPL_TIME",
)
assertContains(
t, aliceReply, testServerName,
"TIME reply includes server name",
)
}
// becomeOper sends OPER with newTestEnvWithOper's
// credentials and waits for RPL_YOUREOPER.
func (tc *testClient) becomeOper() {
tc.t.Helper()
tc.send("OPER testoper testpass")
tc.readUntil(func(l string) bool {
return strings.Contains(l, " 381 ")
})
}
// TestIntegrationKillRefused covers the KILL errors: not an
// operator, no such nick, and killing yourself.
func TestIntegrationKillRefused(t *testing.T) {
t.Parallel()
env := newTestEnvWithOper(t)
alice := env.dial(t)
alice.register("alice")
bob := env.dial(t)
bob.register("bob")
for _, step := range []struct {
line, numeric string
}{
{"KILL bob :nope", " 481 "},
{"OPER testoper testpass", " 381 "},
{"KILL nobody123 :gone", " 401 "},
{"KILL alice :me", " 483 "},
} {
alice.send(step.line)
reply := alice.readUntil(func(l string) bool {
return strings.Contains(l, step.numeric)
})
assertContains(t, reply, step.numeric, step.line)
}
}
// TestIntegrationKill checks that the victim of a KILL is
// told why, is disconnected, and is gone from its channels.
func TestIntegrationKill(t *testing.T) {
t.Parallel()
env := newTestEnvWithOper(t)
alice := env.dial(t)
alice.register("alice")
bob := env.dial(t)
bob.register("bob")
alice.joinAndDrain("#killtest")
bob.joinAndDrain("#killtest")
// Drain alice's view of bob's join.
alice.readUntil(func(l string) bool {
return strings.Contains(l, "JOIN") &&
strings.Contains(l, "bob")
})
alice.becomeOper()
alice.send("KILL bob :bad behavior")
bobLines := bob.readUntilClosed()
assertContains(
t, bobLines, "KILL",
"victim receives KILL before disconnect",
)
assertContains(
t, bobLines, "ERROR :Closing Link",
"victim receives ERROR before disconnect",
)
assertContains(
t, bobLines, "bad behavior",
"KILL reason delivered to victim",
)
// alice should see bob's QUIT relay.
aliceSeesQuit := alice.readUntil(func(l string) bool {
return strings.Contains(l, "QUIT") &&
strings.Contains(l, "bob")
})
assertContains(
t, aliceSeesQuit, "Killed",
"KILL reason in QUIT message",
)
// bob must be gone from the channel member list.
alice.send("NAMES #killtest")
aliceNames := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 366 ")
})
assertContains(
t, aliceNames, "alice",
"alice still in NAMES after killing bob",
)
assertNotContains(
t, aliceNames, "bob",
"killed user must not appear in NAMES",
)
// ...nor from WHO.
alice.send("WHO #killtest")
aliceWho := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 315 ")
})
assertNotContains(
t, aliceWho, "bob",
"killed user must not appear in WHO",
)
}
// TestIntegrationWallops verifies the WALLOPS command:
// oper can broadcast to +w users.
func TestIntegrationWallops(t *testing.T) {
t.Parallel()
env := newTestEnvWithOper(t)
alice := env.dial(t)
alice.register("alice")
bob := env.dial(t)
bob.register("bob")
// Non-oper WALLOPS should fail.
alice.send("WALLOPS :test broadcast")
aliceWallopsFail := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 481 ")
})
assertContains(
t, aliceWallopsFail, " 481 ",
"ERR_NOPRIVILEGES for non-oper WALLOPS",
)
alice.becomeOper()
// bob sets +w to receive wallops.
bob.send("MODE bob +w")
bob.readUntil(func(l string) bool {
return strings.Contains(l, " 221 ")
})
// alice sends WALLOPS.
alice.send("WALLOPS :important announcement")
// bob (who has +w) should receive it.
bobWallops := bob.readUntil(func(l string) bool {
return strings.Contains(
l, "important announcement",
)
})
assertContains(
t, bobWallops, "important announcement",
"bob receives WALLOPS message",
)
assertContains(
t, bobWallops, "WALLOPS",
"message is WALLOPS command",
)
}
// TestIntegrationUserMode covers MODE for a nick: changes
// and queries of your own modes in any letter case, refusal
// for another nick, and a rejected mode string changing
// nothing.
func TestIntegrationUserMode(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
bob := env.dial(t)
bob.register("bob")
const (
notYours = " 502 alice :Can't change mode for other users"
unknown = " 501 alice :Unknown MODE flag"
)
for _, step := range []struct {
line, want string
}{
{"MODE ALICE +w", " 221 alice +w"},
{"MODE bob", notYours},
{"MODE bob -w", notYours},
{"MODE alice xw", unknown},
{"MODE alice -w+z", unknown},
{"MODE alice", " 221 alice +w"},
{"MODE alice +w-w", " 221 alice +"},
} {
alice.send(step.line)
reply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 221 ") ||
strings.Contains(l, " 501 ") ||
strings.Contains(l, " 502 ")
})
last := reply[len(reply)-1]
if !strings.HasSuffix(last, step.want) {
t.Errorf("%s: want %q, got %q", step.line, step.want, last)
}
}
}
// TestIntegrationModeSecret tests +s (secret) channel
// mode — verifies that +s can be set and the mode is
// reflected in MODE queries.
@@ -915,3 +1282,30 @@ func TestIntegrationThirdClientObserver(t *testing.T) {
"carol receives trio message",
)
}
// TestIntegrationDefaultServerNameFallback checks that with
// SERVER_NAME unset, as it is by default, VERSION, ADMIN and
// TIME name the server "neoirc".
func TestIntegrationDefaultServerNameFallback(t *testing.T) {
t.Parallel()
env := newTestEnvWithConfig(t, &config.Config{})
alice := env.dial(t)
alice.register("alice")
for _, step := range []struct {
line, lastNumeric, want string
}{
{"VERSION", " 351 ", " 351 alice neoirc-test. neoirc "},
{"ADMIN", " 259 ", " 256 alice neoirc "},
{"TIME", " 391 ", " 391 alice neoirc "},
} {
alice.send(step.line)
reply := alice.readUntil(func(l string) bool {
return strings.Contains(l, step.lastNumeric)
})
assertContains(t, reply, step.want, step.line)
}
}