feat: add OPER command and oper-only WHOIS client info
Some checks failed
check / check (push) Failing after 1m52s

- Add OPER command with NEOIRC_OPER_NAME/NEOIRC_OPER_PASSWORD config
- Add is_oper column to sessions table
- Add RPL_WHOISACTUALLY (338): show client IP/hostname to opers
- Add RPL_WHOISOPERATOR (313): show oper status in WHOIS
- Add GetOperCount for accurate LUSERS oper count
- Fix README schema: add ip/is_oper to sessions, ip/hostname to clients
- Add OPER command documentation and numeric references to README
- Refactor executeWhois to stay under funlen limit
- Add comprehensive tests for OPER auth, oper WHOIS, non-oper WHOIS

Closes #81
This commit is contained in:
clawbot
2026-03-17 10:48:04 -07:00
parent 16258722c7
commit 3571c50216
9 changed files with 807 additions and 36 deletions

View File

@@ -2532,3 +2532,315 @@ func assertNamesHostmask(
targetNick, msgs,
)
}
const testOperName = "admin"
const testOperPassword = "secretpass"
// newTestServerWithOper creates a test server with oper
// credentials configured (admin / secretpass).
func newTestServerWithOper(
t *testing.T,
) *testServer {
t.Helper()
dbPath := filepath.Join(
t.TempDir(), "test.db",
)
dbURL := "file:" + dbPath +
"?_journal_mode=WAL&_busy_timeout=5000"
var srv *server.Server
app := fxtest.New(t,
fx.Provide(
newTestGlobals,
logger.New,
func(
lifecycle fx.Lifecycle,
globs *globals.Globals,
log *logger.Logger,
) (*config.Config, error) {
cfg, err := config.New(
lifecycle, config.Params{ //nolint:exhaustruct
Globals: globs, Logger: log,
},
)
if err != nil {
return nil, fmt.Errorf(
"test config: %w", err,
)
}
cfg.DBURL = dbURL
cfg.Port = 0
cfg.HashcashBits = 0
cfg.OperName = testOperName
cfg.OperPassword = testOperPassword
return cfg, nil
},
newTestDB,
stats.New,
newTestHealthcheck,
newTestMiddleware,
newTestHandlers,
newTestServerFx,
),
fx.Populate(&srv),
)
const startupDelay = 100 * time.Millisecond
app.RequireStart()
time.Sleep(startupDelay)
httpSrv := httptest.NewServer(srv)
t.Cleanup(func() {
httpSrv.Close()
app.RequireStop()
})
return &testServer{
httpServer: httpSrv,
t: t,
fxApp: app,
}
}
func TestOperCommandSuccess(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("operuser")
_, lastID := tserver.pollMessages(token, 0)
// Send OPER command.
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 381 RPL_YOUREOPER.
if !findNumeric(msgs, "381") {
t.Fatalf(
"expected RPL_YOUREOPER (381), got %v",
msgs,
)
}
}
func TestOperCommandFailure(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("badoper")
_, lastID := tserver.pollMessages(token, 0)
// Send OPER with wrong password.
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, "wrongpass"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 491 ERR_NOOPERHOST.
if !findNumeric(msgs, "491") {
t.Fatalf(
"expected ERR_NOOPERHOST (491), got %v",
msgs,
)
}
}
func TestOperCommandNeedMoreParams(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("shortoper")
_, lastID := tserver.pollMessages(token, 0)
// Send OPER with only one parameter.
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 461 ERR_NEEDMOREPARAMS.
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestOperWhoisShowsClientInfo(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create a target user.
_ = tserver.createSession("target")
// Create an oper user.
operToken := tserver.createSession("theoper")
_, lastID := tserver.pollMessages(operToken, 0)
// Authenticate as oper.
tserver.sendCommand(operToken, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
var msgs []map[string]any
msgs, lastID = tserver.pollMessages(operToken, lastID)
if !findNumeric(msgs, "381") {
t.Fatalf(
"expected RPL_YOUREOPER (381), got %v",
msgs,
)
}
// Now WHOIS the target.
tserver.sendCommand(operToken, map[string]any{
commandKey: "WHOIS",
toKey: "target",
})
msgs, _ = tserver.pollMessages(operToken, lastID)
// Expect 338 RPL_WHOISACTUALLY with client IP.
whoisActually := findNumericWithParams(msgs, "338")
if whoisActually == nil {
t.Fatalf(
"expected RPL_WHOISACTUALLY (338) for "+
"oper WHOIS, got %v",
msgs,
)
}
params := getNumericParams(whoisActually)
if len(params) < 2 {
t.Fatalf(
"expected at least 2 params in 338, "+
"got %v",
params,
)
}
// First param should be the target nick.
if params[0] != "target" {
t.Fatalf(
"expected first param 'target', got %s",
params[0],
)
}
// Second param should be a non-empty IP.
if params[1] == "" {
t.Fatal("expected non-empty IP in 338 params")
}
}
func TestNonOperWhoisHidesClientInfo(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create a target user.
_ = tserver.createSession("hidden")
// Create a regular (non-oper) user.
regToken := tserver.createSession("regular")
_, lastID := tserver.pollMessages(regToken, 0)
// WHOIS the target without oper status.
tserver.sendCommand(regToken, map[string]any{
commandKey: "WHOIS",
toKey: "hidden",
})
msgs, _ := tserver.pollMessages(regToken, lastID)
// Should NOT see 338 RPL_WHOISACTUALLY.
if findNumeric(msgs, "338") {
t.Fatalf(
"non-oper should not see "+
"RPL_WHOISACTUALLY (338), got %v",
msgs,
)
}
// But should see 311 RPL_WHOISUSER (normal WHOIS).
if !findNumeric(msgs, "311") {
t.Fatalf(
"expected RPL_WHOISUSER (311), got %v",
msgs,
)
}
}
func TestWhoisShowsOperatorStatus(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create oper user and authenticate.
operToken := tserver.createSession("iamoper")
_, lastID := tserver.pollMessages(operToken, 0)
tserver.sendCommand(operToken, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
msgs, _ := tserver.pollMessages(operToken, lastID)
if !findNumeric(msgs, "381") {
t.Fatalf("expected 381, got %v", msgs)
}
// Another user does WHOIS on the oper.
queryToken := tserver.createSession("asker")
_, queryLastID := tserver.pollMessages(queryToken, 0)
tserver.sendCommand(queryToken, map[string]any{
commandKey: "WHOIS",
toKey: "iamoper",
})
msgs, _ = tserver.pollMessages(queryToken, queryLastID)
// Should see 313 RPL_WHOISOPERATOR.
if !findNumeric(msgs, "313") {
t.Fatalf(
"expected RPL_WHOISOPERATOR (313) in "+
"WHOIS of oper, got %v",
msgs,
)
}
}
func TestOperNoOlineConfigured(t *testing.T) {
// Standard test server has no oper configured.
tserver := newTestServer(t)
token := tserver.createSession("nooline")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, "password"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Should get 491 since no o-line is configured.
if !findNumeric(msgs, "491") {
t.Fatalf(
"expected ERR_NOOPERHOST (491) when no "+
"o-line configured, got %v",
msgs,
)
}
}