feat: implement Tier 2 channel modes (+b/+i/+s/+k/+l) (#92)
Some checks failed
check / check (push) Failing after 1m31s
Some checks failed
check / check (push) Failing after 1m31s
## Summary Implements the second tier of IRC channel features as described in [#86](#86). ## Features ### 1. Ban System (+b) - `channel_bans` table with mask, set_by, created_at - Add/remove/list bans via MODE +b/-b - Wildcard matching (`*!*@*.example.com`, `badnick!*@*`, etc.) - Ban enforcement on both JOIN and PRIVMSG - RPL_BANLIST (367) / RPL_ENDOFBANLIST (368) for ban listing ### 2. Invite-Only (+i) - `is_invite_only` column on channels table - INVITE command: operators can invite users - `channel_invites` table tracks pending invites - Invites consumed on successful JOIN - ERR_INVITEONLYCHAN (473) for uninvited JOIN attempts ### 3. Secret (+s) - `is_secret` column on channels table - Secret channels hidden from LIST for non-members - Secret channels hidden from WHOIS channel list for non-members ### 4. Channel Key (+k) - `channel_key` column on channels table - MODE +k sets key, MODE -k clears it - Key required on JOIN (`JOIN #channel key`) - ERR_BADCHANNELKEY (475) for wrong/missing key ### 5. User Limit (+l) - `user_limit` column on channels table (0 = no limit) - MODE +l sets limit, MODE -l removes it - ERR_CHANNELISFULL (471) when limit reached ## ISUPPORT Changes - CHANMODES updated to `b,k,Hl,imnst` - RPL_MYINFO modes updated to `ikmnostl` ## Tests ### Database-level tests: - Wildcard matching (10 patterns) - Ban CRUD operations - Session ban checking - Invite-only flag toggle - Invite CRUD + clearing - Secret channel filtering (LIST and WHOIS) - Channel key set/get/clear - User limit set/get/clear ### Handler-level tests: - Ban add/remove/list via MODE - Ban blocks JOIN - Ban blocks PRIVMSG - Invite-only JOIN rejection + INVITE acceptance - Secret channel hidden from LIST - Channel key required on JOIN - User limit enforcement - Mode string includes new modes - ISUPPORT updated CHANMODES - Non-operators cannot set any Tier 2 modes ## Schema Changes - Added `is_invite_only`, `is_secret`, `channel_key`, `user_limit` to `channels` table - Added `channel_bans` table - Added `channel_invites` table - All changes in `001_initial.sql` (pre-1.0.0 repo) closes #86 Co-authored-by: user <user@Mac.lan guest wan> Reviewed-on: #92 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
This commit was merged in pull request #92.
This commit is contained in:
@@ -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