feat: implement Tier 2 channel modes (+b/+i/+s/+k/+l)
Implement the second tier of IRC channel features: 1. Ban system (+b): Add/remove/list bans with wildcard matching. Bans prevent both joining and sending messages. Schema: channel_bans table with mask, set_by, created_at. 2. Invite-only (+i): Channel mode requiring invitation to join. INVITE command for operators. Invites stored in DB and cleared after successful JOIN. 3. Secret (+s): Hides channel from LIST for non-members and from WHOIS channel lists when querier is not in same channel. 4. Channel key (+k): Password-protected channels. Key required on JOIN, set/cleared by operators. 5. User limit (+l): Maximum member count enforcement. Rejects JOIN when channel is at capacity. Updated ISUPPORT CHANMODES to b,k,Hl,imnst. Updated RPL_MYINFO available modes to ikmnostl. Comprehensive tests for all features at both DB and handler levels. README updated with full documentation of all new modes. closes #86
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -4378,3 +4378,486 @@ func TestKickDefaultReason(t *testing.T) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tier 2 Handler Tests ---
|
||||
|
||||
const (
|
||||
inviteCmd = "INVITE"
|
||||
joinedStatus = "joined"
|
||||
)
|
||||
|
||||
// TestBanAddRemoveList verifies +b add, list, and -b
|
||||
// remove via MODE commands.
|
||||
func TestBanAddRemoveList(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
opToken := tserver.createSession("banop")
|
||||
|
||||
tserver.sendCommand(opToken, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#bans",
|
||||
})
|
||||
|
||||
// Add a ban.
|
||||
tserver.sendCommand(opToken, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#bans",
|
||||
bodyKey: []string{"+b", "*!*@evil.com"},
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(opToken, 0)
|
||||
|
||||
// List bans (+b with no argument).
|
||||
tserver.sendCommand(opToken, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#bans",
|
||||
bodyKey: []string{"+b"},
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(opToken, lastID)
|
||||
|
||||
// Should have RPL_BANLIST (367).
|
||||
banMsg := findNumericWithParams(msgs, "367")
|
||||
if banMsg == nil {
|
||||
t.Fatalf("expected 367 RPL_BANLIST, got %v", msgs)
|
||||
}
|
||||
|
||||
// Should have RPL_ENDOFBANLIST (368).
|
||||
if !findNumeric(msgs, "368") {
|
||||
t.Fatal("expected 368 RPL_ENDOFBANLIST")
|
||||
}
|
||||
|
||||
// Remove the ban.
|
||||
tserver.sendCommand(opToken, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#bans",
|
||||
bodyKey: []string{"-b", "*!*@evil.com"},
|
||||
})
|
||||
|
||||
_, lastID = tserver.pollMessages(opToken, lastID)
|
||||
|
||||
// List again — should be empty (just end-of-list).
|
||||
tserver.sendCommand(opToken, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#bans",
|
||||
bodyKey: []string{"+b"},
|
||||
})
|
||||
|
||||
msgs, _ = tserver.pollMessages(opToken, lastID)
|
||||
banMsg = findNumericWithParams(msgs, "367")
|
||||
if banMsg != nil {
|
||||
t.Fatal("expected no 367 after ban removal")
|
||||
}
|
||||
|
||||
if !findNumeric(msgs, "368") {
|
||||
t.Fatal("expected 368 RPL_ENDOFBANLIST")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBanBlocksJoin verifies that a banned user cannot
|
||||
// join a channel.
|
||||
func TestBanBlocksJoin(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
opToken := tserver.createSession("banop2")
|
||||
userToken := tserver.createSession("banned2")
|
||||
|
||||
// Op creates channel and sets a ban.
|
||||
tserver.sendCommand(opToken, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#banjoin",
|
||||
})
|
||||
tserver.sendCommand(opToken, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#banjoin",
|
||||
bodyKey: []string{"+b", "banned2!*@*"},
|
||||
})
|
||||
|
||||
// Banned user tries to join.
|
||||
_, lastID := tserver.pollMessages(userToken, 0)
|
||||
tserver.sendCommand(userToken, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#banjoin",
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(userToken, lastID)
|
||||
|
||||
// Should get ERR_BANNEDFROMCHAN (474).
|
||||
if !findNumeric(msgs, "474") {
|
||||
t.Fatalf("expected 474 ERR_BANNEDFROMCHAN, got %v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBanBlocksPrivmsg verifies that a banned user who
|
||||
// is already in a channel cannot send messages.
|
||||
func TestBanBlocksPrivmsg(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
opToken := tserver.createSession("banmsgop")
|
||||
userToken := tserver.createSession("banmsgusr")
|
||||
|
||||
// Both join.
|
||||
tserver.sendCommand(opToken, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#banmsg",
|
||||
})
|
||||
tserver.sendCommand(userToken, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#banmsg",
|
||||
})
|
||||
|
||||
// Op bans the user.
|
||||
tserver.sendCommand(opToken, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#banmsg",
|
||||
bodyKey: []string{"+b", "banmsgusr!*@*"},
|
||||
})
|
||||
|
||||
// User tries to send a message.
|
||||
_, lastID := tserver.pollMessages(userToken, 0)
|
||||
tserver.sendCommand(userToken, map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: "#banmsg",
|
||||
bodyKey: []string{"hello"},
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(userToken, lastID)
|
||||
|
||||
// Should get ERR_CANNOTSENDTOCHAN (404).
|
||||
if !findNumeric(msgs, "404") {
|
||||
t.Fatalf("expected 404 ERR_CANNOTSENDTOCHAN, got %v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInviteOnlyJoin verifies +i behavior: join rejected
|
||||
// without invite, accepted with invite.
|
||||
func TestInviteOnlyJoin(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
opToken := tserver.createSession("invop")
|
||||
userToken := tserver.createSession("invusr")
|
||||
|
||||
// Op creates channel and sets +i.
|
||||
tserver.sendCommand(opToken, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#invonly",
|
||||
})
|
||||
tserver.sendCommand(opToken, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#invonly",
|
||||
bodyKey: []string{"+i"},
|
||||
})
|
||||
|
||||
// User tries to join without invite.
|
||||
_, lastID := tserver.pollMessages(userToken, 0)
|
||||
tserver.sendCommand(userToken, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#invonly",
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(userToken, lastID)
|
||||
|
||||
if !findNumeric(msgs, "473") {
|
||||
t.Fatalf(
|
||||
"expected 473 ERR_INVITEONLYCHAN, got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
|
||||
// Op invites user.
|
||||
tserver.sendCommand(opToken, map[string]any{
|
||||
commandKey: inviteCmd,
|
||||
bodyKey: []string{"invusr", "#invonly"},
|
||||
})
|
||||
|
||||
// User tries again — should succeed with invite.
|
||||
_, result := tserver.sendCommand(userToken, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#invonly",
|
||||
})
|
||||
|
||||
if result[statusKey] != joinedStatus {
|
||||
t.Fatalf(
|
||||
"expected join to succeed with invite, got %v",
|
||||
result,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecretChannelHiddenFromList verifies +s hides a
|
||||
// channel from LIST for non-members.
|
||||
func TestSecretChannelHiddenFromList(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
opToken := tserver.createSession("secop")
|
||||
outsiderToken := tserver.createSession("secout")
|
||||
|
||||
// Op creates secret channel.
|
||||
tserver.sendCommand(opToken, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#secret",
|
||||
})
|
||||
tserver.sendCommand(opToken, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#secret",
|
||||
bodyKey: []string{"+s"},
|
||||
})
|
||||
|
||||
// Outsider does LIST.
|
||||
_, lastID := tserver.pollMessages(outsiderToken, 0)
|
||||
tserver.sendCommand(outsiderToken, map[string]any{
|
||||
commandKey: "LIST",
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(outsiderToken, lastID)
|
||||
|
||||
// Should NOT see #secret in any 322 (RPL_LIST).
|
||||
for _, msg := range msgs {
|
||||
code, ok := msg["code"].(float64)
|
||||
if !ok || int(code) != 322 {
|
||||
continue
|
||||
}
|
||||
|
||||
params := getNumericParams(msg)
|
||||
for _, p := range params {
|
||||
if p == "#secret" {
|
||||
t.Fatal("outsider should not see #secret in LIST")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Member does LIST — should see it.
|
||||
_, lastID = tserver.pollMessages(opToken, 0)
|
||||
tserver.sendCommand(opToken, map[string]any{
|
||||
commandKey: "LIST",
|
||||
})
|
||||
|
||||
msgs, _ = tserver.pollMessages(opToken, lastID)
|
||||
|
||||
found := false
|
||||
|
||||
for _, msg := range msgs {
|
||||
code, ok := msg["code"].(float64)
|
||||
if !ok || int(code) != 322 {
|
||||
continue
|
||||
}
|
||||
|
||||
params := getNumericParams(msg)
|
||||
for _, p := range params {
|
||||
if p == "#secret" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Fatal("member should see #secret in LIST")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelKeyJoin verifies +k behavior: wrong/missing
|
||||
// key is rejected, correct key allows join.
|
||||
func TestChannelKeyJoin(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
opToken := tserver.createSession("keyop")
|
||||
userToken := tserver.createSession("keyusr")
|
||||
|
||||
// Op creates keyed channel.
|
||||
tserver.sendCommand(opToken, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#keyed",
|
||||
})
|
||||
tserver.sendCommand(opToken, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#keyed",
|
||||
bodyKey: []string{"+k", "mykey"},
|
||||
})
|
||||
|
||||
// User tries without key.
|
||||
_, lastID := tserver.pollMessages(userToken, 0)
|
||||
tserver.sendCommand(userToken, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#keyed",
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(userToken, lastID)
|
||||
|
||||
if !findNumeric(msgs, "475") {
|
||||
t.Fatalf(
|
||||
"expected 475 ERR_BADCHANNELKEY, got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
|
||||
// User tries with wrong key.
|
||||
tserver.sendCommand(userToken, map[string]any{
|
||||
commandKey: joinCmd,
|
||||
toKey: "#keyed",
|
||||
bodyKey: []string{"wrongkey"},
|
||||
})
|
||||
|
||||
msgs, _ = tserver.pollMessages(userToken, lastID)
|
||||
if !findNumeric(msgs, "475") {
|
||||
t.Fatalf(
|
||||
"expected 475 ERR_BADCHANNELKEY for wrong key, got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
|
||||
// User tries with correct key.
|
||||
_, result := tserver.sendCommand(userToken, map[string]any{
|
||||
commandKey: joinCmd,
|
||||
toKey: "#keyed",
|
||||
bodyKey: []string{"mykey"},
|
||||
})
|
||||
|
||||
if result[statusKey] != joinedStatus {
|
||||
t.Fatalf(
|
||||
"expected join to succeed with correct key, got %v",
|
||||
result,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUserLimitEnforcement verifies +l behavior: blocks
|
||||
// join when at capacity.
|
||||
func TestUserLimitEnforcement(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
opToken := tserver.createSession("limop")
|
||||
user1Token := tserver.createSession("limusr1")
|
||||
user2Token := tserver.createSession("limusr2")
|
||||
|
||||
// Op creates channel with limit 2.
|
||||
tserver.sendCommand(opToken, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#limited",
|
||||
})
|
||||
tserver.sendCommand(opToken, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#limited",
|
||||
bodyKey: []string{"+l", "2"},
|
||||
})
|
||||
|
||||
// User1 joins — should succeed (2 members now: op + user1).
|
||||
_, result := tserver.sendCommand(user1Token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#limited",
|
||||
})
|
||||
if result[statusKey] != joinedStatus {
|
||||
t.Fatalf("user1 should join, got %v", result)
|
||||
}
|
||||
|
||||
// User2 tries to join — should fail (at limit: 2/2).
|
||||
_, lastID := tserver.pollMessages(user2Token, 0)
|
||||
tserver.sendCommand(user2Token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#limited",
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(user2Token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "471") {
|
||||
t.Fatalf(
|
||||
"expected 471 ERR_CHANNELISFULL, got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModeStringIncludesNewModes verifies that querying
|
||||
// channel mode returns the new modes (+i, +s, +k, +l).
|
||||
func TestModeStringIncludesNewModes(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
opToken := tserver.createSession("modestrop")
|
||||
|
||||
tserver.sendCommand(opToken, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#modestr",
|
||||
})
|
||||
|
||||
// Set all tier 2 modes.
|
||||
for _, modeChange := range [][]string{
|
||||
{"+i"}, {"+s"}, {"+k", "pw"}, {"+l", "50"},
|
||||
} {
|
||||
tserver.sendCommand(opToken, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#modestr",
|
||||
bodyKey: modeChange,
|
||||
})
|
||||
}
|
||||
|
||||
_, lastID := tserver.pollMessages(opToken, 0)
|
||||
|
||||
// Query mode.
|
||||
tserver.sendCommand(opToken, map[string]any{
|
||||
commandKey: modeCmd, toKey: "#modestr",
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(opToken, lastID)
|
||||
modeMsg := findNumericWithParams(msgs, "324")
|
||||
|
||||
if modeMsg == nil {
|
||||
t.Fatal("expected 324 RPL_CHANNELMODEIS")
|
||||
}
|
||||
|
||||
params := getNumericParams(modeMsg)
|
||||
if len(params) < 2 {
|
||||
t.Fatalf("too few params in 324: %v", params)
|
||||
}
|
||||
|
||||
modeString := params[1]
|
||||
|
||||
for _, c := range []string{"i", "s", "k", "l"} {
|
||||
if !strings.Contains(modeString, c) {
|
||||
t.Fatalf(
|
||||
"mode string %q missing %q",
|
||||
modeString, c,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestISUPPORT verifies the 005 numeric includes the
|
||||
// updated CHANMODES string.
|
||||
func TestISUPPORT(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("isupport")
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, 0)
|
||||
|
||||
isupp := findNumericWithParams(msgs, "005")
|
||||
if isupp == nil {
|
||||
t.Fatal("expected 005 RPL_ISUPPORT")
|
||||
}
|
||||
|
||||
body, _ := isupp["body"].(string)
|
||||
params := getNumericParams(isupp)
|
||||
|
||||
combined := body + " " + strings.Join(params, " ")
|
||||
|
||||
if !strings.Contains(combined, "CHANMODES=b,k,Hl,imnst") {
|
||||
t.Fatalf(
|
||||
"ISUPPORT missing updated CHANMODES, got body=%q params=%v",
|
||||
body, params,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNonOpCannotSetModes verifies non-operators
|
||||
// cannot set +i, +s, +k, +l, +b.
|
||||
func TestNonOpCannotSetModes(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
opToken := tserver.createSession("modeopx")
|
||||
userToken := tserver.createSession("modeusrx")
|
||||
|
||||
tserver.sendCommand(opToken, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#noperm",
|
||||
})
|
||||
tserver.sendCommand(userToken, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#noperm",
|
||||
})
|
||||
|
||||
modes := [][]string{
|
||||
{"+i"}, {"+s"}, {"+k", "key"}, {"+l", "10"},
|
||||
{"+b", "bad!*@*"},
|
||||
}
|
||||
|
||||
for _, modeChange := range modes {
|
||||
_, lastID := tserver.pollMessages(userToken, 0)
|
||||
tserver.sendCommand(userToken, map[string]any{
|
||||
commandKey: modeCmd,
|
||||
toKey: "#noperm",
|
||||
bodyKey: modeChange,
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(userToken, lastID)
|
||||
|
||||
// Should get 482 ERR_CHANOPRIVSNEEDED.
|
||||
if !findNumeric(msgs, "482") {
|
||||
t.Fatalf(
|
||||
"expected 482 for %v, got %v",
|
||||
modeChange, msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user