M1 step 4a: IRC protocol core (host-tested)

lib/irc: IrcMessage parse/serialize, base64, ReconnectPolicy
(5 s .. 5 min), IrcConfig (validated, persisted) and IrcSession:
registration with SASL PLAIN or NickServ, nick fallback, PING, Buffers
capped at 50 lines with unread counts, Mentions, CTCP ACTION/VERSION,
topics, /names on request only, others' join/part/quit hidden, user
commands, and rejoining after a pause. No networking: the session
returns lines to send, Log entries and Notifications.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-02 15:03:21 +02:00
co-authored by Claude Opus 5.5
parent d3948ccc3a
commit 311eb38723
11 changed files with 1142 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
#include <unity.h>
#include "../memory_store.h"
#include "irc_config.h"
using namespace roro;
void setUp() {}
void tearDown() {}
void test_defaults_point_at_libera_over_tls() {
MemoryStore store;
IrcConfig c(store);
c.load("roro_2fa4");
TEST_ASSERT_EQUAL_STRING("irc.libera.chat", c.host.c_str());
TEST_ASSERT_EQUAL(6697, c.port);
TEST_ASSERT_TRUE(c.tls);
TEST_ASSERT_FALSE(c.allowSelfSigned);
TEST_ASSERT_EQUAL_STRING("roro_2fa4", c.nick.c_str());
TEST_ASSERT_TRUE(c.autojoin.empty());
}
void test_save_and_reload() {
MemoryStore store;
{
IrcConfig c(store);
c.load("x");
c.nick = "clement";
c.saslUser = "clement";
c.saslPassword = "s3cret";
c.autojoin = {"#roro", "#meshtastic"};
TEST_ASSERT_TRUE(c.save().empty());
}
IrcConfig again(store);
again.load("x");
TEST_ASSERT_EQUAL_STRING("clement", again.nick.c_str());
TEST_ASSERT_EQUAL_STRING("s3cret", again.saslPassword.c_str());
TEST_ASSERT_EQUAL(2, again.autojoin.size());
TEST_ASSERT_EQUAL_STRING("#meshtastic", again.autojoin[1].c_str());
}
void test_invalid_values_are_refused_with_a_reason() {
MemoryStore store;
IrcConfig c(store);
c.load("x");
c.nick = "1abc"; // can't start with a digit
TEST_ASSERT_FALSE(c.save().empty());
c.nick = "has space";
TEST_ASSERT_FALSE(c.save().empty());
c.nick = "ok_nick[1]";
c.port = 0;
TEST_ASSERT_FALSE(c.save().empty());
c.port = 6697;
c.host = "";
TEST_ASSERT_FALSE(c.save().empty());
c.host = "irc.libera.chat";
c.autojoin = {"roro"}; // IRC channels start with # or &
TEST_ASSERT_FALSE(c.save().empty());
}
void test_autojoin_text_round_trip() {
TEST_ASSERT_EQUAL(2, IrcConfig::parseChannels("#a, #b").size());
TEST_ASSERT_EQUAL(2, IrcConfig::parseChannels("#a #b").size());
TEST_ASSERT_EQUAL_STRING("#a #b", IrcConfig::formatChannels({"#a", "#b"}).c_str());
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_defaults_point_at_libera_over_tls);
RUN_TEST(test_save_and_reload);
RUN_TEST(test_invalid_values_are_refused_with_a_reason);
RUN_TEST(test_autojoin_text_round_trip);
return UNITY_END();
}
@@ -0,0 +1,74 @@
#include <unity.h>
#include "base64.h"
#include "irc_message.h"
#include "reconnect_policy.h"
using namespace roro;
void setUp() {}
void tearDown() {}
void test_parse_full_message() {
auto m = IrcMessage::parse(":alice!a@host PRIVMSG #roro :hello there");
TEST_ASSERT_EQUAL_STRING("alice!a@host", m.prefix.c_str());
TEST_ASSERT_EQUAL_STRING("alice", m.nick().c_str());
TEST_ASSERT_EQUAL_STRING("PRIVMSG", m.command.c_str());
TEST_ASSERT_EQUAL(2, m.params.size());
TEST_ASSERT_EQUAL_STRING("#roro", m.params[0].c_str());
TEST_ASSERT_EQUAL_STRING("hello there", m.params[1].c_str());
}
void test_parse_without_prefix_and_with_tags() {
auto m = IrcMessage::parse("@time=2026-10-02T12:00:00Z PING :abc");
TEST_ASSERT_EQUAL_STRING("", m.prefix.c_str());
TEST_ASSERT_EQUAL_STRING("PING", m.command.c_str());
TEST_ASSERT_EQUAL_STRING("abc", m.params[0].c_str());
}
void test_parse_strips_line_endings_and_handles_empty_trailing() {
auto m = IrcMessage::parse(":srv 332 me #roro :\r\n");
TEST_ASSERT_EQUAL(3, m.params.size());
TEST_ASSERT_EQUAL_STRING("", m.params[2].c_str());
}
void test_parse_middle_params_only() {
auto m = IrcMessage::parse(":srv MODE #roro +nt");
TEST_ASSERT_EQUAL(2, m.params.size());
TEST_ASSERT_EQUAL_STRING("+nt", m.params[1].c_str());
}
void test_serialize_adds_colon_only_when_needed() {
TEST_ASSERT_EQUAL_STRING("NICK roro", IrcMessage::serialize("NICK", {"roro"}).c_str());
TEST_ASSERT_EQUAL_STRING("PRIVMSG #roro :hello there", IrcMessage::serialize("PRIVMSG", {"#roro", "hello there"}).c_str());
TEST_ASSERT_EQUAL_STRING("PRIVMSG #roro ::)", IrcMessage::serialize("PRIVMSG", {"#roro", ":)"}).c_str());
TEST_ASSERT_EQUAL_STRING("TOPIC #roro :", IrcMessage::serialize("TOPIC", {"#roro", ""}).c_str());
}
void test_base64() {
TEST_ASSERT_EQUAL_STRING("", base64Encode("").c_str());
TEST_ASSERT_EQUAL_STRING("Zg==", base64Encode("f").c_str());
TEST_ASSERT_EQUAL_STRING("Zm8=", base64Encode("fo").c_str());
TEST_ASSERT_EQUAL_STRING("Zm9v", base64Encode("foo").c_str());
TEST_ASSERT_EQUAL_STRING("dXNlcgB1c2VyAHBhc3M=", base64Encode(std::string("user\0user\0pass", 14)).c_str());
}
void test_reconnect_delays_grow_then_cap_and_reset() {
ReconnectPolicy p;
uint32_t expected[] = {5000, 10000, 30000, 60000, 120000, 300000, 300000};
for (uint32_t e : expected) TEST_ASSERT_EQUAL_UINT32(e, p.nextDelayMs());
p.reset();
TEST_ASSERT_EQUAL_UINT32(5000, p.nextDelayMs());
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_parse_full_message);
RUN_TEST(test_parse_without_prefix_and_with_tags);
RUN_TEST(test_parse_strips_line_endings_and_handles_empty_trailing);
RUN_TEST(test_parse_middle_params_only);
RUN_TEST(test_serialize_adds_colon_only_when_needed);
RUN_TEST(test_base64);
RUN_TEST(test_reconnect_delays_grow_then_cap_and_reset);
return UNITY_END();
}
+345
View File
@@ -0,0 +1,345 @@
#include <unity.h>
#include <algorithm>
#include <memory>
#include "../memory_store.h"
#include "irc_session.h"
using namespace roro;
void setUp() {}
void tearDown() {}
struct Fixture {
MemoryStore store;
IrcConfig config{store};
std::unique_ptr<IrcSession> session;
IrcEffects fx;
explicit Fixture(bool sasl = false) {
config.load("roro");
config.nick = "roro";
config.autojoin = {"#roro"};
if (sasl) {
config.saslUser = "acct";
config.saslPassword = "pw";
}
session.reset(new IrcSession(config));
}
void recv(const std::string& line) { session->receive(line, 1000); }
IrcEffects& take() {
fx = session->takeEffects();
return fx;
}
bool sent(const std::string& line) {
return std::find(fx.send.begin(), fx.send.end(), line) != fx.send.end();
}
void registerNow() {
session->connected(0);
recv(":srv 001 roro :Welcome");
take();
}
int buffer(const std::string& name) {
for (int i = 0; i < session->bufferCount(); i++)
if (session->buffer(i).name == name) return i;
return -1;
}
};
void test_plain_registration_sends_nick_and_user() {
Fixture f;
f.session->connected(0);
f.take();
TEST_ASSERT_TRUE(f.sent("NICK roro"));
TEST_ASSERT_TRUE(f.sent("USER roro 0 * roro9stack"));
}
void test_welcome_registers_and_joins_autojoin_channels() {
Fixture f;
f.session->connected(0);
f.take();
f.recv(":srv 001 roro :Welcome");
f.take();
TEST_ASSERT_TRUE(f.session->registered());
TEST_ASSERT_TRUE(f.sent("JOIN #roro"));
}
void test_nickserv_identify_after_welcome() {
Fixture f;
f.config.nickservPassword = "pw";
f.session.reset(new IrcSession(f.config));
f.registerNow();
f.session->connected(0);
f.recv(":srv 001 roro :Welcome");
f.take();
TEST_ASSERT_TRUE(f.sent("PRIVMSG NickServ :IDENTIFY pw"));
}
void test_sasl_plain_flow() {
Fixture f(true);
f.session->connected(0);
f.take();
TEST_ASSERT_TRUE(f.sent("CAP REQ :sasl"));
f.recv(":srv CAP * ACK :sasl");
f.take();
TEST_ASSERT_TRUE(f.sent("AUTHENTICATE PLAIN"));
f.recv("AUTHENTICATE +");
f.take();
TEST_ASSERT_TRUE(f.sent("AUTHENTICATE YWNjdABhY2N0AHB3")); // acct\0acct\0pw
f.recv(":srv 903 roro :SASL authentication successful");
f.take();
TEST_ASSERT_TRUE(f.sent("CAP END"));
}
void test_sasl_failure_still_ends_negotiation_and_says_so() {
Fixture f(true);
f.session->connected(0);
f.recv(":srv CAP * ACK :sasl");
f.recv("AUTHENTICATE +");
f.take();
f.recv(":srv 904 roro :SASL authentication failed");
f.take();
TEST_ASSERT_TRUE(f.sent("CAP END"));
const auto& server = f.session->buffer(0);
TEST_ASSERT_TRUE(server.lines.back().text.find("SASL") != std::string::npos);
}
void test_nick_in_use_during_registration_tries_another() {
Fixture f;
f.session->connected(0);
f.take();
f.recv(":srv 433 * roro :Nickname is already in use");
f.take();
TEST_ASSERT_TRUE(f.sent("NICK roro_"));
}
void test_ping_is_answered() {
Fixture f;
f.registerNow();
f.recv("PING :tungsten.libera.chat");
f.take();
TEST_ASSERT_TRUE(f.sent("PONG tungsten.libera.chat"));
}
void test_own_join_creates_a_channel_buffer() {
Fixture f;
f.registerNow();
f.recv(":roro!u@h JOIN #roro");
int b = f.buffer("#roro");
TEST_ASSERT_TRUE(b > 0);
TEST_ASSERT_TRUE(f.session->buffer(b).joined);
}
void test_others_joining_is_hidden() {
Fixture f;
f.registerNow();
f.recv(":roro!u@h JOIN #roro");
size_t before = f.session->buffer(f.buffer("#roro")).lines.size();
f.recv(":bob!u@h JOIN #roro");
f.recv(":bob!u@h PART #roro");
f.recv(":bob!u@h QUIT :bye");
TEST_ASSERT_EQUAL(before, f.session->buffer(f.buffer("#roro")).lines.size());
}
void test_channel_message_goes_to_its_buffer_and_is_logged() {
Fixture f;
f.registerNow();
f.recv(":roro!u@h JOIN #roro");
f.take();
f.recv(":alice!u@h PRIVMSG #roro :hello all");
auto& fx = f.take();
const auto& b = f.session->buffer(f.buffer("#roro"));
TEST_ASSERT_EQUAL_STRING("alice", b.lines.back().nick.c_str());
TEST_ASSERT_EQUAL_STRING("hello all", b.lines.back().text.c_str());
TEST_ASSERT_EQUAL(1, fx.logs.size());
TEST_ASSERT_EQUAL_STRING("#roro", fx.logs[0].buffer.c_str());
}
void test_unread_counts_unless_viewing_that_buffer() {
Fixture f;
f.registerNow();
f.recv(":roro!u@h JOIN #roro");
int b = f.buffer("#roro");
f.recv(":alice!u@h PRIVMSG #roro :one");
f.recv(":alice!u@h PRIVMSG #roro :two");
TEST_ASSERT_EQUAL(2, f.session->buffer(b).unread);
TEST_ASSERT_EQUAL(2, f.session->totalUnread());
f.session->setViewing(b);
TEST_ASSERT_EQUAL(0, f.session->buffer(b).unread);
f.recv(":alice!u@h PRIVMSG #roro :three");
TEST_ASSERT_EQUAL(0, f.session->buffer(b).unread);
}
void test_mention_notifies_ordinary_traffic_does_not() {
Fixture f;
f.registerNow();
f.recv(":roro!u@h JOIN #roro");
f.take();
f.recv(":alice!u@h PRIVMSG #roro :lunch?");
TEST_ASSERT_EQUAL(0, f.take().notifications.size());
f.recv(":alice!u@h PRIVMSG #roro :Roro: ping");
TEST_ASSERT_EQUAL(1, f.take().notifications.size());
f.recv(":alice!u@h PRIVMSG #roro :rorojam is not you");
TEST_ASSERT_EQUAL(0, f.take().notifications.size());
}
void test_private_message_opens_a_query_buffer_and_notifies() {
Fixture f;
f.registerNow();
f.recv(":alice!u@h PRIVMSG roro :psst");
TEST_ASSERT_TRUE(f.buffer("alice") > 0);
TEST_ASSERT_EQUAL(1, f.take().notifications.size());
}
void test_no_notification_while_viewing_that_buffer() {
Fixture f;
f.registerNow();
f.recv(":alice!u@h PRIVMSG roro :first");
f.take();
f.session->setViewing(f.buffer("alice"));
f.recv(":alice!u@h PRIVMSG roro :second");
TEST_ASSERT_EQUAL(0, f.take().notifications.size());
}
void test_action_and_ctcp_version() {
Fixture f;
f.registerNow();
f.recv(":roro!u@h JOIN #roro");
f.recv(":alice!u@h PRIVMSG #roro :\x01" "ACTION waves\x01");
const auto& line = f.session->buffer(f.buffer("#roro")).lines.back();
TEST_ASSERT_EQUAL(static_cast<int>(IrcLine::Kind::Action), static_cast<int>(line.kind));
TEST_ASSERT_EQUAL_STRING("waves", line.text.c_str());
f.take();
f.recv(":alice!u@h PRIVMSG roro :\x01VERSION\x01");
f.take();
TEST_ASSERT_TRUE(f.sent("NOTICE alice :\x01VERSION roro9stack\x01"));
}
void test_topic_is_kept() {
Fixture f;
f.registerNow();
f.recv(":roro!u@h JOIN #roro");
f.recv(":srv 332 roro #roro :Welcome to roro");
TEST_ASSERT_EQUAL_STRING("Welcome to roro", f.session->buffer(f.buffer("#roro")).topic.c_str());
}
void test_input_message_and_commands() {
Fixture f;
f.registerNow();
f.recv(":roro!u@h JOIN #roro");
int b = f.buffer("#roro");
f.take();
f.session->input(b, "hello", 0);
f.session->input(b, "/me waves", 0);
f.session->input(b, "/topic New topic", 0);
f.session->input(b, "/join #other", 0);
f.session->input(b, "/part", 0);
f.session->input(b, "/nick roro2", 0);
f.session->input(b, "/names", 0);
f.session->input(b, "/raw MODE #roro +t", 0);
f.session->input(b, "//not a command", 0);
f.take();
TEST_ASSERT_TRUE(f.sent("PRIVMSG #roro hello"));
TEST_ASSERT_TRUE(f.sent("PRIVMSG #roro :\x01" "ACTION waves\x01"));
TEST_ASSERT_TRUE(f.sent("TOPIC #roro :New topic"));
TEST_ASSERT_TRUE(f.sent("JOIN #other"));
TEST_ASSERT_TRUE(f.sent("PART #roro"));
TEST_ASSERT_TRUE(f.sent("NICK roro2"));
TEST_ASSERT_TRUE(f.sent("NAMES #roro"));
TEST_ASSERT_TRUE(f.sent("MODE #roro +t"));
TEST_ASSERT_TRUE(f.sent("PRIVMSG #roro :/not a command"));
// Own messages appear in the buffer
TEST_ASSERT_EQUAL_STRING("/not a command", f.session->buffer(b).lines.back().text.c_str());
}
void test_msg_opens_a_query_buffer() {
Fixture f;
f.registerNow();
f.session->input(0, "/msg alice hi there", 0);
f.take();
TEST_ASSERT_TRUE(f.sent("PRIVMSG alice :hi there"));
TEST_ASSERT_TRUE(f.buffer("alice") > 0);
}
void test_text_in_the_server_buffer_explains_instead_of_sending() {
Fixture f;
f.registerNow();
f.session->input(0, "hello", 0);
TEST_ASSERT_TRUE(f.take().send.empty());
TEST_ASSERT_TRUE(f.session->buffer(0).lines.back().text.find("/join") != std::string::npos);
}
void test_quit_sends_and_is_remembered() {
Fixture f;
f.registerNow();
f.session->input(0, "/quit bye", 0);
f.take();
TEST_ASSERT_TRUE(f.sent("QUIT bye"));
TEST_ASSERT_TRUE(f.session->quitRequested());
}
void test_names_reply_only_shown_when_asked() {
Fixture f;
f.registerNow();
f.recv(":roro!u@h JOIN #roro");
int b = f.buffer("#roro");
size_t before = f.session->buffer(b).lines.size();
f.recv(":srv 353 roro = #roro :roro alice bob");
TEST_ASSERT_EQUAL(before, f.session->buffer(b).lines.size());
f.session->input(b, "/names", 0);
f.recv(":srv 353 roro = #roro :roro alice bob");
TEST_ASSERT_TRUE(f.session->buffer(b).lines.back().text.find("alice bob") != std::string::npos);
}
void test_disconnect_marks_a_gap_and_reconnect_rejoins() {
Fixture f;
f.registerNow();
f.recv(":roro!u@h JOIN #roro");
f.session->input(1, "/join #extra", 0);
f.recv(":roro!u@h JOIN #extra");
f.session->disconnected(5000, "paused for Wi-Fi monitoring");
TEST_ASSERT_FALSE(f.session->registered());
TEST_ASSERT_TRUE(f.session->buffer(f.buffer("#roro")).lines.back().text.find("paused") != std::string::npos);
f.take();
f.session->connected(6000);
f.recv(":srv 001 roro :Welcome");
f.take();
TEST_ASSERT_TRUE(f.sent("JOIN #roro,#extra"));
}
void test_buffer_keeps_only_recent_lines() {
Fixture f;
f.registerNow();
f.recv(":roro!u@h JOIN #roro");
for (int i = 0; i < IrcSession::kLinesPerBuffer + 10; i++) f.recv(":a!u@h PRIVMSG #roro :x");
TEST_ASSERT_EQUAL(IrcSession::kLinesPerBuffer, f.session->buffer(f.buffer("#roro")).lines.size());
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_plain_registration_sends_nick_and_user);
RUN_TEST(test_welcome_registers_and_joins_autojoin_channels);
RUN_TEST(test_nickserv_identify_after_welcome);
RUN_TEST(test_sasl_plain_flow);
RUN_TEST(test_sasl_failure_still_ends_negotiation_and_says_so);
RUN_TEST(test_nick_in_use_during_registration_tries_another);
RUN_TEST(test_ping_is_answered);
RUN_TEST(test_own_join_creates_a_channel_buffer);
RUN_TEST(test_others_joining_is_hidden);
RUN_TEST(test_channel_message_goes_to_its_buffer_and_is_logged);
RUN_TEST(test_unread_counts_unless_viewing_that_buffer);
RUN_TEST(test_mention_notifies_ordinary_traffic_does_not);
RUN_TEST(test_private_message_opens_a_query_buffer_and_notifies);
RUN_TEST(test_no_notification_while_viewing_that_buffer);
RUN_TEST(test_action_and_ctcp_version);
RUN_TEST(test_topic_is_kept);
RUN_TEST(test_input_message_and_commands);
RUN_TEST(test_msg_opens_a_query_buffer);
RUN_TEST(test_text_in_the_server_buffer_explains_instead_of_sending);
RUN_TEST(test_quit_sends_and_is_remembered);
RUN_TEST(test_names_reply_only_shown_when_asked);
RUN_TEST(test_disconnect_marks_a_gap_and_reconnect_rejoins);
RUN_TEST(test_buffer_keeps_only_recent_lines);
return UNITY_END();
}